personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

convey/static: add shared error-handling primitives (wave 0)

Add convey/static/api.js with apiJson, ApiError, and saveControl.

Extend SurfaceState with errorCard and replaceLoading, AppServices
with registerTask and getTaskHealth, and appEvents with the
listen overload plus onParseError. Expose window.logError from
error-handler.js for websocket parse/drop routing.

Migrate pairing, the starred-app toggle, tokens loading, and the
support background poller to the shared primitives. Add four static
smoke pages under convey/static/tests/ plus the Wave 0 design and
decision log in scratch/design-convey-error-wave0.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1349 -185
+11 -23
apps/support/background.html
··· 1 1 {# Support background service: polls for ticket updates, manages badges #} 2 2 3 - const POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes 4 - let pollTimer = null; 3 + let badgeTask = null; 5 4 6 5 AppServices.register('support', { 7 6 initialize() { 8 - updateBadge(); 9 - pollTimer = setInterval(updateBadge, POLL_INTERVAL); 7 + badgeTask = AppServices.registerTask('support', 'badge-count', { 8 + intervalMs: 5 * 60 * 1000, 9 + run: async ({ apiJson }) => apiJson('/app/support/api/badge-count'), 10 + onSuccess: (data) => { 11 + AppServices.badges.app.set('support', data.count ?? 0); 12 + } 13 + }); 10 14 11 15 // Listen for proactive suggestions from callosum 12 16 if (window.appEvents) { ··· 27 31 }, 28 32 29 33 stop() { 30 - if (pollTimer) { 31 - clearInterval(pollTimer); 32 - pollTimer = null; 34 + if (badgeTask) { 35 + badgeTask.stop(); 36 + badgeTask = null; 33 37 } 34 38 } 35 39 }); 36 - 37 - async function updateBadge() { 38 - try { 39 - const resp = await fetch('/app/support/api/badge-count'); 40 - if (!resp.ok) return; 41 - const data = await resp.json(); 42 - const count = data.count || 0; 43 - if (count > 0) { 44 - AppServices.badges.app.set('support', count); 45 - } else { 46 - AppServices.badges.app.clear('support'); 47 - } 48 - } catch (e) { 49 - // Silently ignore — support may be disabled or portal unreachable 50 - } 51 - }
+11 -20
apps/tokens/workspace.html
··· 1 1 <main class="tokens-dashboard"> 2 - <div id="loading"> 2 + <div id="tokens-loading"> 3 3 <div class="surface-state surface-state--loading" role="status" aria-busy="true"> 4 4 <div class="surface-state-spinner" aria-hidden="true"></div> 5 5 <span class="surface-state-text" data-role="loading-status">Loading token usage data...</span> ··· 169 169 padding: 1em 1.5em; 170 170 } 171 171 172 - #loading { 172 + #tokens-loading { 173 173 display: flex; 174 174 align-items: center; 175 175 justify-content: center; ··· 617 617 // Load token usage data 618 618 async function loadTokenData(day) { 619 619 try { 620 - const response = await fetch(`/app/tokens/api/usage?day=${day}`); 621 - if (response.status === 401 || response.status === 403) { 622 - window.location.href = '/'; 623 - return; 624 - } 625 - if (!response.ok) { 626 - throw new Error(`Server returned ${response.status}`); 627 - } 628 - const data = await response.json(); 620 + const data = await window.apiJson(`/app/tokens/api/usage?day=${day}`); 629 621 currentData = data; 630 622 renderDashboard(data); 631 - document.getElementById('loading').style.display = 'none'; 623 + document.getElementById('tokens-loading').style.display = 'none'; 632 624 document.getElementById('dashboard').style.display = 'block'; 633 - } catch (error) { 634 - console.error('Failed to load token data:', error); 635 - document.getElementById('loading').innerHTML = 636 - window.SurfaceState.error({ 637 - heading: 'Unable to load token data', 638 - desc: 'Try refreshing, or check the health dashboard if this persists.', 639 - action: '<button onclick="document.getElementById(\'loading\').innerHTML = window.SurfaceState.loading({ text: \'Loading token usage data...\' }); loadTokenData(currentDay);">Retry</button>' 640 - }); 625 + } catch (err) { 626 + console.error('Failed to load token data:', err); 627 + window.SurfaceState.replaceLoading('tokens-loading', window.SurfaceState.errorCard({ 628 + heading: 'Couldn\'t load token usage', 629 + desc: 'Reload the page to try again.', 630 + serverMessage: err?.serverMessage ?? err?.message, 631 + })); 641 632 } 642 633 } 643 634
+247
convey/static/api.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-only 2 + // Copyright (c) 2026 sol pbc 3 + 4 + (function() { 5 + const savedControlValues = new WeakMap(); 6 + 7 + class ApiError extends Error { 8 + constructor({ status, statusText, serverMessage, url, cause }) { 9 + super(serverMessage); 10 + this.name = 'ApiError'; 11 + this.status = status; 12 + this.statusText = statusText; 13 + this.serverMessage = serverMessage; 14 + this.url = url; 15 + if (cause) { 16 + this.cause = cause; 17 + } 18 + } 19 + } 20 + 21 + function normalizeRequestOptions(opts) { 22 + const source = opts || {}; 23 + const fetchOptions = { credentials: 'same-origin' }; 24 + 25 + Object.keys(source).forEach(key => { 26 + if (key === 'headers' || key === 'noAuthRedirect') return; 27 + fetchOptions[key] = source[key]; 28 + }); 29 + 30 + if (source.headers !== undefined) { 31 + fetchOptions.headers = new Headers(source.headers); 32 + } 33 + 34 + return { 35 + fetchOptions, 36 + noAuthRedirect: !!source.noAuthRedirect 37 + }; 38 + } 39 + 40 + function parseJsonPayload(text) { 41 + if (text === '') { 42 + return { ok: true, payload: {} }; 43 + } 44 + try { 45 + return { ok: true, payload: JSON.parse(text) }; 46 + } catch (error) { 47 + return { ok: false, error }; 48 + } 49 + } 50 + 51 + function getEscapeHtml() { 52 + if (window.AppServices && typeof window.AppServices.escapeHtml === 'function') { 53 + return window.AppServices.escapeHtml; 54 + } 55 + return function escapeHtml(value) { 56 + const div = document.createElement('div'); 57 + div.textContent = String(value ?? ''); 58 + return div.innerHTML; 59 + }; 60 + } 61 + 62 + function readControlValue(el) { 63 + if (el instanceof HTMLInputElement) { 64 + const type = (el.type || '').toLowerCase(); 65 + if (type === 'checkbox' || type === 'radio') { 66 + return el.checked; 67 + } 68 + return el.value; 69 + } 70 + if (el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) { 71 + return el.value; 72 + } 73 + if ('value' in el) { 74 + return el.value; 75 + } 76 + return undefined; 77 + } 78 + 79 + function writeControlValue(el, value) { 80 + if (el instanceof HTMLInputElement) { 81 + const type = (el.type || '').toLowerCase(); 82 + if (type === 'checkbox' || type === 'radio') { 83 + el.checked = !!value; 84 + return; 85 + } 86 + el.value = value ?? ''; 87 + return; 88 + } 89 + if (el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) { 90 + el.value = value ?? ''; 91 + return; 92 + } 93 + if ('value' in el) { 94 + el.value = value ?? ''; 95 + } 96 + } 97 + 98 + function getInitialControlValue(el) { 99 + if (el instanceof HTMLInputElement) { 100 + const type = (el.type || '').toLowerCase(); 101 + if (type === 'checkbox' || type === 'radio') { 102 + return el.defaultChecked; 103 + } 104 + return el.defaultValue ?? el.value; 105 + } 106 + if (el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) { 107 + return el.defaultValue ?? el.value; 108 + } 109 + if ('defaultValue' in el) { 110 + return el.defaultValue ?? el.value; 111 + } 112 + return readControlValue(el); 113 + } 114 + 115 + function getExistingControlError(el, errorHost) { 116 + if (errorHost) { 117 + return errorHost.querySelector('[data-control-save-error]'); 118 + } 119 + const sibling = el.nextElementSibling; 120 + if (sibling && sibling.matches('[data-control-save-error]')) { 121 + return sibling; 122 + } 123 + return null; 124 + } 125 + 126 + function clearControlError(el, errorHost) { 127 + const existing = getExistingControlError(el, errorHost); 128 + if (existing) { 129 + existing.remove(); 130 + } 131 + } 132 + 133 + function renderControlError(el, errorHost, message) { 134 + const escaped = getEscapeHtml()(message); 135 + const html = `<span class="control-save-error" role="alert" data-control-save-error>${escaped}</span>`; 136 + const existing = getExistingControlError(el, errorHost); 137 + if (existing) { 138 + existing.remove(); 139 + } 140 + if (errorHost) { 141 + errorHost.insertAdjacentHTML('beforeend', html); 142 + return; 143 + } 144 + el.insertAdjacentHTML('afterend', html); 145 + } 146 + 147 + async function apiJson(url, opts) { 148 + const { fetchOptions, noAuthRedirect } = normalizeRequestOptions(opts); 149 + const response = await fetch(url, fetchOptions); 150 + 151 + if ((response.status === 401 || response.status === 403) && !noAuthRedirect) { 152 + window.location.href = '/'; 153 + throw new ApiError({ 154 + status: response.status, 155 + statusText: response.statusText, 156 + serverMessage: 'Authentication required', 157 + url: url 158 + }); 159 + } 160 + 161 + const text = await response.text(); 162 + 163 + if (!response.ok) { 164 + let payload = null; 165 + if (text !== '') { 166 + const parsed = parseJsonPayload(text); 167 + payload = parsed.ok ? parsed.payload : null; 168 + } 169 + const serverMessage = payload?.error 170 + ?? payload?.message 171 + ?? `Request failed (HTTP ${response.status})`; 172 + throw new ApiError({ 173 + status: response.status, 174 + statusText: response.statusText, 175 + serverMessage, 176 + url: url 177 + }); 178 + } 179 + 180 + const parsed = parseJsonPayload(text); 181 + if (parsed.ok) { 182 + return parsed.payload; 183 + } 184 + 185 + throw new ApiError({ 186 + status: response.status, 187 + statusText: response.statusText, 188 + serverMessage: 'Malformed server response', 189 + url: url, 190 + cause: 'parse' 191 + }); 192 + } 193 + 194 + function saveControl({ 195 + el, 196 + fetchArgs, 197 + revertOnError = true, 198 + onSuccess, 199 + onError, 200 + errorHost, 201 + readValue, 202 + writeValue 203 + }) { 204 + if (!el) { 205 + throw new Error('saveControl requires an element'); 206 + } 207 + 208 + const hasCustomReader = typeof readValue === 'function'; 209 + const reader = hasCustomReader ? readValue : readControlValue; 210 + const writer = typeof writeValue === 'function' ? writeValue : writeControlValue; 211 + const previousValue = hasCustomReader 212 + ? reader(el) 213 + : (savedControlValues.has(el) ? savedControlValues.get(el) : getInitialControlValue(el)); 214 + 215 + const promise = (async () => { 216 + try { 217 + const result = Array.isArray(fetchArgs) 218 + ? await apiJson(fetchArgs[0], fetchArgs[1]) 219 + : await fetchArgs(); 220 + savedControlValues.set(el, readControlValue(el)); 221 + clearControlError(el, errorHost); 222 + if (typeof onSuccess === 'function') { 223 + onSuccess(result); 224 + } 225 + return result; 226 + } catch (error) { 227 + if (revertOnError !== false) { 228 + writer(el, previousValue); 229 + } 230 + const serverMessage = error instanceof ApiError 231 + ? error.serverMessage 232 + : (error && error.message ? error.message : 'Request failed'); 233 + renderControlError(el, errorHost, serverMessage); 234 + if (typeof onError === 'function') { 235 + onError(error); 236 + } 237 + throw error; 238 + } 239 + })(); 240 + 241 + return promise; 242 + } 243 + 244 + window.ApiError = ApiError; 245 + window.apiJson = apiJson; 246 + window.saveControl = saveControl; 247 + })();
+35
convey/static/app.css
··· 2719 2719 to { transform: rotate(360deg); } 2720 2720 } 2721 2721 2722 + .surface-state-server-message { 2723 + margin-top: 8px; 2724 + font-size: 13px; 2725 + color: var(--text-muted, #6b7280); 2726 + font-family: monospace; 2727 + word-break: break-word; 2728 + } 2729 + 2730 + .surface-state-refresh-error { 2731 + margin-top: 12px; 2732 + padding: 12px 16px; 2733 + border: 1px solid var(--color-warning, #f59e0b); 2734 + border-radius: 6px; 2735 + background: rgba(245, 158, 11, 0.08); 2736 + } 2737 + 2738 + .control-save-error { 2739 + display: inline-block; 2740 + margin-left: 8px; 2741 + color: var(--color-error, #dc2626); 2742 + font-size: 13px; 2743 + } 2744 + 2745 + .menu-item-bg-failing::after { 2746 + content: ''; 2747 + position: absolute; 2748 + top: 4px; 2749 + right: 4px; 2750 + width: 8px; 2751 + height: 8px; 2752 + border-radius: 50%; 2753 + background: var(--color-warning, #f59e0b); 2754 + border: 2px solid var(--facet-bg-primary, #fff); 2755 + } 2756 + 2722 2757 @media (max-width: 768px) { 2723 2758 .chat-app { 2724 2759 padding: 0 12px 16px;
+239 -43
convey/static/app.js
··· 612 612 updateScrollShadows(); 613 613 } 614 614 615 + function setAppStarState(appName, menuItem, starToggle, isStarred) { 616 + if (isStarred) { 617 + if (!starredApps.includes(appName)) { 618 + starredApps.push(appName); 619 + } 620 + } else { 621 + starredApps = starredApps.filter(name => name !== appName); 622 + } 623 + 624 + menuItem.dataset.starred = String(isStarred); 625 + starToggle.textContent = isStarred ? '★' : '☆'; 626 + starToggle.setAttribute('aria-pressed', String(isStarred)); 627 + reorderMenuItems(); 628 + } 629 + 615 630 // Toggle star status for an app 616 631 async function toggleAppStar(appName) { 617 632 const isStarred = starredApps.includes(appName); ··· 624 639 const starToggle = menuItem.querySelector('.star-toggle'); 625 640 if (!starToggle) return; 626 641 627 - // Update local state 628 - if (newStarredStatus) { 629 - starredApps.push(appName); 630 - } else { 631 - starredApps = starredApps.filter(name => name !== appName); 632 - } 633 - 634 - // Update DOM 635 - menuItem.dataset.starred = newStarredStatus; 636 - starToggle.textContent = newStarredStatus ? '★' : '☆'; 637 - starToggle.setAttribute('aria-pressed', String(newStarredStatus)); 642 + const previousState = { 643 + starredApps: [...starredApps], 644 + starred: menuItem.dataset.starred, 645 + text: starToggle.textContent, 646 + pressed: starToggle.getAttribute('aria-pressed') 647 + }; 638 648 639 - // Reorder menu items to reflect new grouping 640 - reorderMenuItems(); 649 + setAppStarState(appName, menuItem, starToggle, newStarredStatus); 641 650 642 - // Save to backend 643 651 try { 644 - const response = await fetch('/api/config/apps/star', { 645 - method: 'POST', 646 - headers: { 'Content-Type': 'application/json' }, 647 - body: JSON.stringify({ app: appName, starred: newStarredStatus }) 652 + await window.saveControl({ 653 + el: starToggle, 654 + fetchArgs: ['/api/config/apps/star', { 655 + method: 'POST', 656 + headers: { 'Content-Type': 'application/json' }, 657 + body: JSON.stringify({ app: appName, starred: newStarredStatus }) 658 + }], 659 + onError: (error) => { 660 + console.error('Failed to toggle app star:', error); 661 + if (window.AppServices?.notifications) { 662 + window.AppServices.notifications.show({ 663 + app: 'system', 664 + title: 'Failed to save star status', 665 + message: error.message, 666 + autoDismiss: 5000 667 + }); 668 + } 669 + }, 670 + readValue: () => previousState, 671 + writeValue: (_el, snapshot) => { 672 + starredApps = [...snapshot.starredApps]; 673 + menuItem.dataset.starred = snapshot.starred; 674 + starToggle.textContent = snapshot.text; 675 + starToggle.setAttribute('aria-pressed', snapshot.pressed); 676 + reorderMenuItems(); 677 + } 648 678 }); 649 - 650 - if (!response.ok) throw new Error('Failed to save star status'); 651 - 652 - // No reload needed - DOM already updated 653 - 654 679 } catch (error) { 655 - console.error('Failed to toggle app star:', error); 656 - if (window.AppServices?.notifications) { 657 - window.AppServices.notifications.show({ 658 - app: 'system', 659 - title: 'Failed to save star status', 660 - message: error.message, 661 - autoDismiss: 5000 662 - }); 663 - } 664 - 665 - // Revert optimistic update on error 666 - if (newStarredStatus) { 667 - starredApps = starredApps.filter(name => name !== appName); 668 - } else { 669 - starredApps.push(appName); 670 - } 671 - menuItem.dataset.starred = !newStarredStatus; 672 - starToggle.textContent = !newStarredStatus ? '★' : '☆'; 673 - starToggle.setAttribute('aria-pressed', String(!newStarredStatus)); 674 - reorderMenuItems(); 680 + // saveControl already reverted UI state and surfaced the failure. 675 681 } 676 682 } 677 683 ··· 1515 1521 */ 1516 1522 window.SurfaceState = (() => { 1517 1523 const HEADING_LEVELS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); 1524 + const ERROR_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 21 19H3z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>'; 1518 1525 1519 1526 function escapeHtml(value) { 1520 1527 return String(value ?? '') ··· 1563 1570 error(options = {}) { 1564 1571 return render('error', { ...options, role: 'alert' }); 1565 1572 }, 1573 + 1574 + /** 1575 + * Render a standard error card HTML string for first-paint or refresh failures. 1576 + * All user-visible text is escaped, and no action slot is provided by design. 1577 + * 1578 + * @param {object} options 1579 + * @param {string} [options.heading="Couldn't load this section"] 1580 + * @param {string} [options.desc="Reload the page to try again."] 1581 + * @param {string} [options.serverMessage] 1582 + * @returns {string} 1583 + */ 1584 + errorCard({ 1585 + heading = 'Couldn\'t load this section', 1586 + desc = 'Reload the page to try again.', 1587 + serverMessage = '' 1588 + } = {}) { 1589 + return `<div class="surface-state surface-state--error" role="alert">` 1590 + + `<div class="surface-state-icon" aria-hidden="true">${ERROR_ICON}</div>` 1591 + + `<h2 class="surface-state-heading">${escapeHtml(heading)}</h2>` 1592 + + `<p class="surface-state-desc">${escapeHtml(desc)}</p>` 1593 + + `${serverMessage ? `<p class="surface-state-server-message">${escapeHtml(serverMessage)}</p>` : ''}` 1594 + + `</div>`; 1595 + }, 1596 + 1597 + /** 1598 + * Replace an initial loading scaffold or append a singleton refresh error beside it. 1599 + * Prevents the apps/entities anti-pattern where an `.error-message` is stuffed inside 1600 + * the loading scaffold (`apps/entities/workspace.html:2671-2674`). 1601 + * 1602 + * @param {string} containerId 1603 + * @param {string} errorCardHtml 1604 + * @returns {HTMLElement|null} 1605 + */ 1606 + replaceLoading(containerId, errorCardHtml) { 1607 + const container = document.getElementById(containerId); 1608 + if (!container) { 1609 + return null; 1610 + } 1611 + 1612 + if (container.querySelector('.surface-state--loading')) { 1613 + container.innerHTML = errorCardHtml; 1614 + return container; 1615 + } 1616 + 1617 + const parent = container.parentElement; 1618 + if (parent) { 1619 + Array.from(parent.children).forEach(child => { 1620 + if (child !== container && child.classList.contains('surface-state-refresh-error')) { 1621 + child.remove(); 1622 + } 1623 + }); 1624 + } 1625 + 1626 + if (container.nextElementSibling?.classList.contains('surface-state-refresh-error')) { 1627 + container.nextElementSibling.remove(); 1628 + } 1629 + 1630 + const wrapper = document.createElement('div'); 1631 + wrapper.className = 'surface-state-refresh-error'; 1632 + wrapper.innerHTML = errorCardHtml; 1633 + container.insertAdjacentElement('afterend', wrapper); 1634 + return container; 1635 + } 1566 1636 }; 1567 1637 })(); 1568 1638 ··· 1572 1642 */ 1573 1643 window.AppServices = { 1574 1644 services: {}, 1645 + _tasks: {}, 1575 1646 1576 1647 /** 1577 1648 * Register an app background service ··· 1587 1658 console.error(`[AppServices] Failed to initialize ${appName} service:`, err); 1588 1659 } 1589 1660 } 1661 + }, 1662 + 1663 + registerTask(appName, taskName, { 1664 + run, 1665 + intervalMs, 1666 + onSuccess, 1667 + onError, 1668 + failuresBeforeFailing = 3 1669 + }) { 1670 + if (typeof run !== 'function') { 1671 + throw new Error('AppServices.registerTask requires a run() function'); 1672 + } 1673 + 1674 + if (!this._tasks[appName]) { 1675 + this._tasks[appName] = {}; 1676 + } 1677 + 1678 + const health = { 1679 + disabled: false, 1680 + failing: false, 1681 + lastError: '', 1682 + lastRunAt: null, 1683 + lastSuccessAt: null, 1684 + consecutiveFailures: 0, 1685 + intervalId: null 1686 + }; 1687 + this._tasks[appName][taskName] = health; 1688 + 1689 + const getMenuItem = () => document.querySelector(`.menu-item[data-app-name="${appName}"]`); 1690 + const clearFailingClassIfHealthy = () => { 1691 + const records = Object.values(this._tasks[appName] || {}); 1692 + if (!records.some(record => record && record.failing)) { 1693 + getMenuItem()?.classList.remove('menu-item-bg-failing'); 1694 + } 1695 + }; 1696 + const apiJsonForTask = (url, opts) => window.apiJson(url, { ...(opts || {}), noAuthRedirect: true }); 1697 + 1698 + const runTask = async () => { 1699 + health.lastRunAt = Date.now(); 1700 + 1701 + try { 1702 + const result = await run({ apiJson: apiJsonForTask }); 1703 + health.disabled = false; 1704 + health.lastError = ''; 1705 + health.lastSuccessAt = Date.now(); 1706 + health.consecutiveFailures = 0; 1707 + if (health.failing) { 1708 + health.failing = false; 1709 + clearFailingClassIfHealthy(); 1710 + } 1711 + if (typeof onSuccess === 'function') { 1712 + onSuccess(result); 1713 + } 1714 + return result; 1715 + } catch (error) { 1716 + const message = error?.message || 'Request failed'; 1717 + health.lastError = message; 1718 + 1719 + if (error instanceof window.ApiError && error.status === 403) { 1720 + health.disabled = true; 1721 + health.failing = false; 1722 + clearFailingClassIfHealthy(); 1723 + if (health.intervalId) { 1724 + window.clearInterval(health.intervalId); 1725 + health.intervalId = null; 1726 + } 1727 + if (typeof onError === 'function') { 1728 + onError(error); 1729 + } 1730 + return undefined; 1731 + } 1732 + 1733 + health.disabled = false; 1734 + health.consecutiveFailures += 1; 1735 + if (typeof onError === 'function') { 1736 + onError(error); 1737 + } 1738 + 1739 + if (health.consecutiveFailures >= failuresBeforeFailing && !health.failing) { 1740 + health.failing = true; 1741 + getMenuItem()?.classList.add('menu-item-bg-failing'); 1742 + this.notifications.show({ 1743 + app: 'system', 1744 + title: `${appName} background task failing`, 1745 + message, 1746 + dismissible: true, 1747 + autoDismiss: false 1748 + }); 1749 + } 1750 + 1751 + throw error; 1752 + } 1753 + }; 1754 + 1755 + const stop = () => { 1756 + if (health.intervalId) { 1757 + window.clearInterval(health.intervalId); 1758 + health.intervalId = null; 1759 + } 1760 + }; 1761 + 1762 + const runNow = () => runTask(); 1763 + const ignoreTaskRejection = () => { 1764 + // runTask already updates task health and owner-visible failure state. 1765 + }; 1766 + 1767 + if (Number.isFinite(intervalMs) && intervalMs > 0) { 1768 + health.intervalId = window.setInterval(() => { 1769 + runTask().catch(ignoreTaskRejection); 1770 + }, intervalMs); 1771 + } 1772 + 1773 + runTask().catch(ignoreTaskRejection); 1774 + 1775 + return { 1776 + stop, 1777 + runNow, 1778 + getHealth() { 1779 + return { ...health }; 1780 + } 1781 + }; 1782 + }, 1783 + 1784 + getTaskHealth(appName) { 1785 + return { ...(this._tasks[appName] || {}) }; 1590 1786 }, 1591 1787 1592 1788 /**
+19 -5
convey/static/error-handler.js
··· 25 25 } 26 26 27 27 // Log error to bottom panel 28 - function logError(text) { 28 + function appendLogLine(text) { 29 29 if (errorLog) { 30 30 if (!document.getElementById('error-log-dismiss')) { 31 31 var btn = document.createElement('button'); ··· 48 48 } 49 49 } 50 50 51 + function formatErrorText(error, context) { 52 + const prefix = context && context.context ? `[${context.context}] ` : ''; 53 + if (typeof error === 'string') { 54 + return prefix + error; 55 + } 56 + if (error instanceof Error) { 57 + return prefix + error.message; 58 + } 59 + return prefix + String(error ?? 'unknown error'); 60 + } 61 + 62 + window.logError = (error, context) => { 63 + markError(); 64 + appendLogLine(formatErrorText(error, context)); 65 + }; 66 + 51 67 // Mark status icon as error state (red with glow) 52 68 function markError() { 53 69 if (statusIcon) { ··· 57 73 58 74 // Global error handler 59 75 window.addEventListener('error', (e) => { 60 - markError(); 61 - logError(`❌ ${e.message} @ ${e.filename}:${e.lineno}`); 76 + window.logError(`❌ ${e.message} @ ${e.filename}:${e.lineno}`); 62 77 }); 63 78 64 79 // Unhandled promise rejection handler 65 80 window.addEventListener('unhandledrejection', (e) => { 66 - markError(); 67 - logError(`⚠️ Promise: ${e.reason}`); 81 + window.logError(`⚠️ Promise: ${e.reason}`); 68 82 }); 69 83 70 84 // Modal controls
+4 -18
convey/static/pairing.js
··· 19 19 let pollTimer = null; 20 20 21 21 async function fetchJson(url, options) { 22 - const response = await fetch( 23 - url, 24 - Object.assign({ credentials: "same-origin" }, options || {}), 25 - ); 26 - 27 - let payload = null; 28 - try { 29 - payload = await response.json(); 30 - } catch (error) { 31 - payload = null; 32 - } 33 - 34 - if (!response.ok) { 35 - const message = payload && payload.error ? payload.error : "Request failed"; 36 - throw new Error(message); 37 - } 38 - 39 - return payload || {}; 22 + return window.apiJson(url, { 23 + ...(options || {}), 24 + noAuthRedirect: true, 25 + }); 40 26 } 41 27 42 28 function setFeedback(message, isError) {
+4
convey/static/tests/README.md
··· 1 + Open `convey/static/tests/api.html` in a browser; each assertion reports pass/fail inline. 2 + Open `convey/static/tests/surface-state.html` in a browser; each assertion reports pass/fail inline. 3 + Open `convey/static/tests/ws-listen.html` in a browser; each assertion reports pass/fail inline. 4 + Open `convey/static/tests/register-task.html` in a browser; each assertion reports pass/fail inline.
+126
convey/static/tests/api.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <meta charset="utf-8"> 4 + <title>api.js smoke</title> 5 + <style> 6 + body { font: 14px/1.4 sans-serif; padding: 16px; } 7 + .pass { color: #166534; } 8 + .fail { color: #b91c1c; } 9 + </style> 10 + <h1>api.js smoke</h1> 11 + <label><input id="save-toggle" type="checkbox"> save toggle</label> 12 + <div id="results"></div> 13 + <script src="../api.js"></script> 14 + <script> 15 + const results = document.getElementById('results'); 16 + let failures = 0; 17 + 18 + function assert(name, condition, detail) { 19 + const row = document.createElement('div'); 20 + row.className = condition ? 'pass' : 'fail'; 21 + row.textContent = `${condition ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`; 22 + results.appendChild(row); 23 + if (!condition) failures += 1; 24 + } 25 + 26 + function response({ status = 200, statusText = 'OK', body = '' }) { 27 + return { status, statusText, ok: status >= 200 && status < 300, text: async () => body }; 28 + } 29 + 30 + async function withFetch(impl, fn) { 31 + const originalFetch = window.fetch; 32 + window.fetch = impl; 33 + try { 34 + return await fn(); 35 + } finally { 36 + window.fetch = originalFetch; 37 + } 38 + } 39 + 40 + async function expectApiError(name, fn, check) { 41 + try { 42 + await fn(); 43 + assert(name, false, 'did not throw'); 44 + } catch (error) { 45 + assert(name, error instanceof window.ApiError && check(error), error.message); 46 + } 47 + } 48 + 49 + async function run() { 50 + await withFetch(async () => response({ body: '{"ok":true}' }), async () => { 51 + const payload = await window.apiJson('/ok'); 52 + assert('2xx json', payload.ok === true); 53 + }); 54 + 55 + await withFetch(async () => response({ body: '' }), async () => { 56 + const payload = await window.apiJson('/empty'); 57 + assert('2xx empty body', Object.keys(payload).length === 0); 58 + }); 59 + 60 + await expectApiError('2xx bad json', () => withFetch( 61 + async () => response({ body: '{bad json' }), 62 + () => window.apiJson('/parse') 63 + ), error => error.cause === 'parse' && error.serverMessage === 'Malformed server response'); 64 + 65 + await expectApiError('400 with error', () => withFetch( 66 + async () => response({ status: 400, statusText: 'Bad', body: '{"error":"broken"}' }), 67 + () => window.apiJson('/bad') 68 + ), error => error.status === 400 && error.serverMessage === 'broken'); 69 + 70 + await expectApiError('400 with message', () => withFetch( 71 + async () => response({ status: 400, statusText: 'Bad', body: '{"message":"nope"}' }), 72 + () => window.apiJson('/bad') 73 + ), error => error.serverMessage === 'nope'); 74 + 75 + await expectApiError('400 fallback message', () => withFetch( 76 + async () => response({ status: 400, statusText: 'Bad', body: '' }), 77 + () => window.apiJson('/bad') 78 + ), error => error.serverMessage === 'Request failed (HTTP 400)'); 79 + 80 + await expectApiError('401 no redirect', () => withFetch( 81 + async () => response({ status: 401, statusText: 'Unauthorized', body: '{"error":"login"}' }), 82 + () => window.apiJson('/auth', { noAuthRedirect: true }) 83 + ), error => error.status === 401 && error.serverMessage === 'login'); 84 + 85 + await expectApiError('403 no redirect', () => withFetch( 86 + async () => response({ status: 403, statusText: 'Forbidden', body: '{"message":"disabled"}' }), 87 + () => window.apiJson('/auth', { noAuthRedirect: true }) 88 + ), error => error.status === 403 && error.serverMessage === 'disabled'); 89 + 90 + const checkbox = document.getElementById('save-toggle'); 91 + 92 + await withFetch(async () => response({ body: '{"saved":true}' }), async () => { 93 + checkbox.checked = true; 94 + await window.saveControl({ el: checkbox, fetchArgs: ['/save', { method: 'POST' }] }); 95 + assert('saveControl success keeps checkbox state', checkbox.checked === true); 96 + assert('saveControl success clears error', !document.querySelector('[data-control-save-error]')); 97 + }); 98 + 99 + await withFetch(async () => response({ status: 400, statusText: 'Bad', body: '{"error":"first fail"}' }), async () => { 100 + checkbox.checked = false; 101 + try { await window.saveControl({ el: checkbox, fetchArgs: ['/save', { method: 'POST' }] }); } catch (_) {} 102 + const errorEl = document.querySelector('[data-control-save-error]'); 103 + assert('saveControl revert on failure', checkbox.checked === true); 104 + assert('saveControl failure renders error', !!errorEl && errorEl.textContent.includes('first fail')); 105 + }); 106 + 107 + await withFetch(async () => response({ status: 400, statusText: 'Bad', body: '{"error":"second fail"}' }), async () => { 108 + checkbox.checked = false; 109 + try { await window.saveControl({ el: checkbox, fetchArgs: ['/save', { method: 'POST' }] }); } catch (_) {} 110 + const errors = document.querySelectorAll('[data-control-save-error]'); 111 + assert('saveControl replaces existing error span', errors.length === 1 && errors[0].textContent.includes('second fail')); 112 + }); 113 + 114 + await withFetch(async () => response({ body: '{"saved":true}' }), async () => { 115 + checkbox.checked = false; 116 + await window.saveControl({ el: checkbox, fetchArgs: ['/save', { method: 'POST' }] }); 117 + assert('saveControl success clears prior error', !document.querySelector('[data-control-save-error]')); 118 + assert('saveControl stores new stable value', checkbox.checked === false); 119 + }); 120 + 121 + assert('summary', failures === 0, failures ? `${failures} failure(s)` : 'all checks passed'); 122 + } 123 + 124 + run(); 125 + </script> 126 + </html>
+98
convey/static/tests/register-task.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <meta charset="utf-8"> 4 + <title>registerTask smoke</title> 5 + <style> 6 + body { font: 14px/1.4 sans-serif; padding: 16px; } 7 + .pass { color: #166534; } 8 + .fail { color: #b91c1c; } 9 + </style> 10 + <div class="facet-bar"><button class="status-icon"></button><div class="facet-pills-container"></div></div> 11 + <nav class="menu-bar"> 12 + <ul class="menu-items"> 13 + <li class="menu-item" data-app-name="support" data-starred="false"> 14 + <a class="menu-item-link" href="#"><span class="icon">S</span><span class="label">Support</span></a> 15 + <button class="star-toggle" type="button" aria-pressed="false">☆</button> 16 + <button class="drag-handle" type="button">↕</button> 17 + </li> 18 + </ul> 19 + </nav> 20 + <div id="notification-center"></div> 21 + <div id="results"></div> 22 + <script> 23 + window.facetsData = []; 24 + window.selectedFacet = null; 25 + window.appFacetCounts = {}; 26 + window.marked = { parse(value) { return value; } }; 27 + window.DOMPurify = { sanitize(value) { return value; } }; 28 + </script> 29 + <script src="../api.js"></script> 30 + <script src="../app.js"></script> 31 + <script> 32 + const results = document.getElementById('results'); 33 + let failures = 0; 34 + 35 + function assert(name, condition, detail) { 36 + const row = document.createElement('div'); 37 + row.className = condition ? 'pass' : 'fail'; 38 + row.textContent = `${condition ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`; 39 + results.appendChild(row); 40 + if (!condition) failures += 1; 41 + } 42 + 43 + function wait(ms) { 44 + return new Promise(resolve => setTimeout(resolve, ms)); 45 + } 46 + 47 + async function settle(promise) { 48 + try { return await promise; } catch (_) { return null; } 49 + } 50 + 51 + (async function run() { 52 + const notifications = []; 53 + window.AppServices.notifications.show = function(options) { 54 + notifications.push(options); 55 + return notifications.length; 56 + }; 57 + window.AppServices.notifications.dismiss = function() {}; 58 + 59 + const events = [ 60 + new Error('fail one'), 61 + new Error('fail two'), 62 + new Error('fail three'), 63 + { count: 1 }, 64 + new window.ApiError({ status: 403, statusText: 'Forbidden', serverMessage: 'disabled', url: '/app/support/api/badge-count' }) 65 + ]; 66 + 67 + const task = window.AppServices.registerTask('support', 'badge-count', { 68 + intervalMs: 60000, 69 + run: async () => { 70 + const next = events.shift(); 71 + if (next instanceof Error) throw next; 72 + return next; 73 + } 74 + }); 75 + 76 + await wait(0); 77 + await settle(task.runNow()); 78 + await settle(task.runNow()); 79 + const menuItem = document.querySelector('.menu-item[data-app-name="support"]'); 80 + assert('three failures add failing pip', menuItem.classList.contains('menu-item-bg-failing')); 81 + assert('failing transition shows one notification', notifications.length === 1 && notifications[0].title === 'support background task failing'); 82 + 83 + await task.runNow(); 84 + const successHealth = task.getHealth(); 85 + assert('success clears failing pip', !menuItem.classList.contains('menu-item-bg-failing')); 86 + assert('success resets health counters', successHealth.consecutiveFailures === 0 && successHealth.lastSuccessAt !== null && successHealth.failing === false); 87 + 88 + await task.runNow(); 89 + const disabledHealth = task.getHealth(); 90 + const taskHealth = window.AppServices.getTaskHealth('support')['badge-count']; 91 + assert('403 disables task without notification spam', disabledHealth.disabled === true && notifications.length === 1); 92 + assert('getTaskHealth exposes stored record', !!taskHealth && 'lastError' in taskHealth && 'intervalId' in taskHealth); 93 + 94 + task.stop(); 95 + assert('summary', failures === 0, failures ? `${failures} failure(s)` : 'all checks passed'); 96 + })(); 97 + </script> 98 + </html>
+64
convey/static/tests/surface-state.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <meta charset="utf-8"> 4 + <title>SurfaceState smoke</title> 5 + <style> 6 + body { font: 14px/1.4 sans-serif; padding: 16px; } 7 + .pass { color: #166534; } 8 + .fail { color: #b91c1c; } 9 + </style> 10 + <div class="facet-bar"><button class="status-icon"></button><div class="facet-pills-container"></div></div> 11 + <nav class="menu-bar"><ul class="menu-items"></ul></nav> 12 + <div id="notification-center"></div> 13 + <div id="first-paint"><div class="surface-state surface-state--loading"></div></div> 14 + <div id="refresh-parent"><div id="refresh-target"><div>Loaded content</div></div></div> 15 + <div id="results"></div> 16 + <script> 17 + window.facetsData = []; 18 + window.selectedFacet = null; 19 + window.appFacetCounts = {}; 20 + window.marked = { parse(value) { return value; } }; 21 + window.DOMPurify = { sanitize(value) { return value; } }; 22 + </script> 23 + <script src="../app.js"></script> 24 + <script> 25 + const results = document.getElementById('results'); 26 + let failures = 0; 27 + 28 + function assert(name, condition, detail) { 29 + const row = document.createElement('div'); 30 + row.className = condition ? 'pass' : 'fail'; 31 + row.textContent = `${condition ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`; 32 + results.appendChild(row); 33 + if (!condition) failures += 1; 34 + } 35 + 36 + function parse(html) { 37 + const wrap = document.createElement('div'); 38 + wrap.innerHTML = html; 39 + return wrap.firstElementChild; 40 + } 41 + 42 + const basic = parse(window.SurfaceState.errorCard({ heading: 'Oops', desc: 'Reload later.' })); 43 + assert('errorCard wrapper uses alert role', basic.getAttribute('role') === 'alert'); 44 + assert('errorCard omits server message when absent', !basic.querySelector('.surface-state-server-message')); 45 + 46 + const withServer = window.SurfaceState.errorCard({ serverMessage: 'Bad <payload>' }); 47 + assert('errorCard escapes server message', withServer.includes('&lt;payload&gt;')); 48 + 49 + window.SurfaceState.replaceLoading('first-paint', window.SurfaceState.errorCard({ serverMessage: 'first paint' })); 50 + const firstPaint = document.getElementById('first-paint'); 51 + assert('replaceLoading swaps first-paint loading scaffold', !!firstPaint.querySelector('.surface-state--error')); 52 + assert('replaceLoading does not add sibling on first paint', !firstPaint.nextElementSibling || !firstPaint.nextElementSibling.classList.contains('surface-state-refresh-error')); 53 + 54 + window.SurfaceState.replaceLoading('refresh-target', window.SurfaceState.errorCard({ serverMessage: 'refresh one' })); 55 + assert('replaceLoading appends refresh sibling', document.getElementById('refresh-target').nextElementSibling.classList.contains('surface-state-refresh-error')); 56 + 57 + window.SurfaceState.replaceLoading('refresh-target', window.SurfaceState.errorCard({ serverMessage: 'refresh two' })); 58 + const refreshErrors = document.querySelectorAll('.surface-state-refresh-error'); 59 + assert('replaceLoading keeps refresh error singleton', refreshErrors.length === 1); 60 + assert('replaceLoading refresh error updates content', refreshErrors[0].textContent.includes('refresh two')); 61 + 62 + assert('summary', failures === 0, failures ? `${failures} failure(s)` : 'all checks passed'); 63 + </script> 64 + </html>
+97
convey/static/tests/ws-listen.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <meta charset="utf-8"> 4 + <title>websocket.js smoke</title> 5 + <style> 6 + body { font: 14px/1.4 sans-serif; padding: 16px; } 7 + .pass { color: #166534; } 8 + .fail { color: #b91c1c; } 9 + </style> 10 + <div class="facet-bar"><button class="status-icon"><span id="quiet-notif-badge"></span></button></div> 11 + <div id="results"></div> 12 + <script> 13 + const parseLogs = []; 14 + class MockWebSocket { 15 + constructor(url) { 16 + this.url = url; 17 + MockWebSocket.instance = this; 18 + setTimeout(() => this.onopen && this.onopen(), 0); 19 + } 20 + emit(payload) { this.onmessage && this.onmessage({ data: payload }); } 21 + } 22 + window.WebSocket = MockWebSocket; 23 + window.AppServices = { notifications: { show() { return 1; }, dismiss() {} } }; 24 + window.logError = function(error, context) { parseLogs.push({ error, context }); }; 25 + </script> 26 + <script src="../websocket.js"></script> 27 + <script> 28 + const results = document.getElementById('results'); 29 + let failures = 0; 30 + 31 + function assert(name, condition, detail) { 32 + const row = document.createElement('div'); 33 + row.className = condition ? 'pass' : 'fail'; 34 + row.textContent = `${condition ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`; 35 + results.appendChild(row); 36 + if (!condition) failures += 1; 37 + } 38 + 39 + function wait(ms) { 40 + return new Promise(resolve => setTimeout(resolve, ms)); 41 + } 42 + 43 + (async function run() { 44 + await wait(0); 45 + const socket = window.WebSocket.instance; 46 + 47 + let seenAlpha = 0; 48 + const cleanupAlpha = window.appEvents.listen('alpha', (msg) => { if (msg.value === 1) seenAlpha += 1; }); 49 + socket.emit(JSON.stringify({ tract: 'alpha', value: 1 })); 50 + assert('listen(tract, fn) remains compatible', seenAlpha === 1); 51 + 52 + let parseHits = 0; 53 + let dropped = 0; 54 + const offParseA = window.appEvents.onParseError(() => { parseHits += 1; }); 55 + const offParseB = window.appEvents.onParseError(() => { parseHits += 1; }); 56 + window.appEvents.listen('beta', { schema: ['use_id', 'event'], onDrop() { dropped += 1; } }, () => {}); 57 + socket.emit(JSON.stringify({ tract: 'beta', use_id: 'abc' })); 58 + await wait(0); 59 + assert('schema array routes drops to onDrop', dropped === 1); 60 + assert('schema array fanout hits onParseError handlers', parseHits === 2); 61 + 62 + let schemaFnHits = 0; 63 + window.appEvents.listen('gamma', { schema: msg => msg.ok === true }, () => { schemaFnHits += 1; }); 64 + socket.emit(JSON.stringify({ tract: 'gamma', ok: true })); 65 + socket.emit(JSON.stringify({ tract: 'gamma', ok: false })); 66 + await wait(0); 67 + assert('schema function accepts valid messages', schemaFnHits === 1); 68 + assert('schema function rejection routes through parse handlers', parseHits === 4); 69 + 70 + let timedOut = null; 71 + const timeoutCleanup = window.appEvents.listen('importer', { timeout: 10, onTimeout(id) { timedOut = id; } }, () => {}); 72 + timeoutCleanup.pending.track('job-1'); 73 + await wait(25); 74 + assert('pending.track triggers timeout callback', timedOut === 'job-1'); 75 + 76 + let cancelledTimeout = false; 77 + const cancelCleanup = window.appEvents.listen('importer', { timeout: 10, onTimeout() { cancelledTimeout = true; } }, () => {}); 78 + cancelCleanup.pending.track('job-2'); 79 + cancelCleanup.pending.clear('job-2'); 80 + await wait(25); 81 + assert('pending.clear cancels timeout', cancelledTimeout === false); 82 + 83 + const logCount = parseLogs.length; 84 + socket.emit('not-json'); 85 + await wait(0); 86 + assert('invalid JSON fans out to onParseError handlers', parseHits === 6); 87 + assert('invalid JSON routes through window.logError', parseLogs.length === logCount + 1 && parseLogs.at(-1).context.context === 'websocket-parse'); 88 + 89 + cleanupAlpha(); 90 + timeoutCleanup(); 91 + cancelCleanup(); 92 + offParseA(); 93 + offParseB(); 94 + assert('summary', failures === 0, failures ? `${failures} failure(s)` : 'all checks passed'); 95 + })(); 96 + </script> 97 + </html>
+221 -76
convey/static/websocket.js
··· 8 8 * Provides window.appEvents API for subscribing to events by tract. 9 9 */ 10 10 (function(){ 11 - const listeners = {}; // Keyed by tract: 'cortex', 'task', 'indexer', etc. 11 + const listeners = {}; 12 + const parseErrorHandlers = new Set(); 12 13 let ws; 13 14 let retry = 1000; 14 15 let statusIcon = null; ··· 20 21 let disconnectTimerId = null; 21 22 let disconnectCardId = null; 22 23 23 - // Update status icon (if present) 24 + function getTractListeners(tract) { 25 + if (!listeners[tract]) { 26 + listeners[tract] = []; 27 + } 28 + return listeners[tract]; 29 + } 30 + 31 + function notifyParseError(error, rawPayload) { 32 + parseErrorHandlers.forEach(handler => { 33 + try { 34 + handler(error, rawPayload); 35 + } catch (handlerError) { 36 + if (typeof window.logError === 'function') { 37 + window.logError(handlerError, { context: 'websocket-parse-handler' }); 38 + } 39 + } 40 + }); 41 + 42 + if (typeof window.logError === 'function') { 43 + window.logError(error, { context: 'websocket-parse' }); 44 + } 45 + } 46 + 47 + function createPendingController(options) { 48 + const pending = new Map(); 49 + const hasTimeout = Number.isFinite(options.timeout) && options.timeout > 0; 50 + const onTimeout = typeof options.onTimeout === 'function' ? options.onTimeout : null; 51 + 52 + return { 53 + track(correlationId) { 54 + if (!hasTimeout || !onTimeout || correlationId == null) { 55 + return correlationId; 56 + } 57 + this.clear(correlationId); 58 + const timeoutId = window.setTimeout(() => { 59 + pending.delete(correlationId); 60 + onTimeout(correlationId); 61 + }, options.timeout); 62 + pending.set(correlationId, timeoutId); 63 + return correlationId; 64 + }, 65 + 66 + clear(correlationId) { 67 + if (correlationId == null) { 68 + return; 69 + } 70 + const timeoutId = pending.get(correlationId); 71 + if (timeoutId) { 72 + window.clearTimeout(timeoutId); 73 + pending.delete(correlationId); 74 + } 75 + }, 76 + 77 + clearAll() { 78 + pending.forEach(timeoutId => window.clearTimeout(timeoutId)); 79 + pending.clear(); 80 + } 81 + }; 82 + } 83 + 84 + function getCorrelationId(msg, correlationKey) { 85 + if (!msg) { 86 + return undefined; 87 + } 88 + if (typeof correlationKey === 'function') { 89 + return correlationKey(msg); 90 + } 91 + return msg[correlationKey || 'use_id']; 92 + } 93 + 94 + function validateSchema(msg, schema) { 95 + if (!schema) { 96 + return; 97 + } 98 + if (Array.isArray(schema)) { 99 + const missing = schema.filter(key => msg == null || msg[key] === undefined); 100 + if (missing.length > 0) { 101 + throw new Error(`Missing required websocket field(s): ${missing.join(', ')}`); 102 + } 103 + return; 104 + } 105 + if (typeof schema === 'function') { 106 + const result = schema(msg); 107 + if (result === false) { 108 + throw new Error('WebSocket schema validation failed'); 109 + } 110 + } 111 + } 112 + 113 + function createListenerRecord(fn, options) { 114 + return { 115 + fn, 116 + options, 117 + pending: createPendingController(options) 118 + }; 119 + } 120 + 121 + function addListenerRecord(tract, record) { 122 + getTractListeners(tract).push(record); 123 + } 124 + 125 + function removeListenerRecord(tract, record) { 126 + if (!listeners[tract]) { 127 + return; 128 + } 129 + record.pending.clearAll(); 130 + listeners[tract] = listeners[tract].filter(candidate => candidate !== record); 131 + } 132 + 133 + function dispatchToRecords(tract, msg) { 134 + const records = listeners[tract]; 135 + if (!records || records.length === 0) { 136 + return; 137 + } 138 + 139 + records.slice().forEach(record => { 140 + try { 141 + const correlationId = getCorrelationId(msg, record.options.correlationKey); 142 + if (correlationId != null) { 143 + record.pending.clear(correlationId); 144 + } 145 + 146 + validateSchema(msg, record.options.schema); 147 + record.fn(msg); 148 + } catch (err) { 149 + if (record.options.schema) { 150 + if (typeof record.options.onDrop === 'function') { 151 + try { 152 + record.options.onDrop(msg, err); 153 + } catch (dropError) { 154 + if (typeof window.logError === 'function') { 155 + window.logError(dropError, { context: 'websocket-drop' }); 156 + } 157 + } 158 + } 159 + notifyParseError(err, msg); 160 + return; 161 + } 162 + 163 + console.error(`[WebSocket] Error in ${tract} listener:`, err); 164 + } 165 + }); 166 + } 167 + 24 168 function updateStatusIcon(state) { 25 169 if (!statusIcon) { 26 170 statusIcon = document.querySelector('.facet-bar .status-icon'); ··· 40 184 }; 41 185 42 186 statusIcon.innerHTML = svgs[state] || svgs.disconnected; 43 - if (badge) statusIcon.appendChild(badge); 187 + if (badge) { 188 + statusIcon.appendChild(badge); 189 + } 44 190 statusIcon.setAttribute('title', labels[state] || state); 45 191 } 46 192 ··· 48 194 window.updateStatusLabel?.(); 49 195 } 50 196 51 - // Connect to WebSocket 52 - function connect(){ 197 + function connect() { 53 198 updateStatusIcon('connecting'); 54 199 const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; 55 200 ws = new WebSocket(`${proto}//${location.host}/ws/events`); ··· 58 203 connectedAt = Date.now(); 59 204 updateStatusIcon('connected'); 60 205 retry = 1000; 61 - // Cancel disconnect notification timer if reconnected within grace period 206 + 62 207 if (disconnectTimerId) { 63 208 clearTimeout(disconnectTimerId); 64 209 disconnectTimerId = null; 65 210 } 66 - // If a disconnect card is showing, dismiss it and show brief reconnected card 211 + 67 212 if (disconnectCardId !== null) { 68 213 window.AppServices?.notifications?.dismiss(disconnectCardId); 69 214 const reconnectedId = window.AppServices?.notifications?.show({ ··· 78 223 } 79 224 disconnectCardId = null; 80 225 } 226 + 81 227 console.debug('[WebSocket] Connected to /ws/events'); 82 228 }; 83 229 84 230 ws.onclose = () => { 85 231 connectedAt = null; 86 232 updateStatusIcon('disconnected'); 87 - // Start 5-second grace timer before showing disconnect notification 233 + 88 234 if (!disconnectTimerId && disconnectCardId === null) { 89 235 disconnectTimerId = setTimeout(() => { 90 236 disconnectTimerId = null; ··· 100 246 } 101 247 }, 5000); 102 248 } 249 + 103 250 retry = Math.min(retry * 1.5, 15000); 104 251 console.debug(`[WebSocket] Disconnected, reconnecting in ${retry}ms`); 105 252 setTimeout(connect, retry); 106 253 }; 107 254 108 - ws.onmessage = e => { 255 + ws.onmessage = event => { 109 256 lastMessageAt = Date.now(); 110 257 111 258 let msg; 112 259 try { 113 - msg = JSON.parse(e.data); 114 - } catch(err) { 260 + msg = JSON.parse(event.data); 261 + } catch (err) { 115 262 console.warn('[WebSocket] Failed to parse message:', err); 263 + notifyParseError(err, event.data); 116 264 return; 117 265 } 118 266 119 267 const tract = msg.tract; 120 - 121 - // Call tract-specific listeners 122 - if(tract && listeners[tract]){ 123 - listeners[tract].forEach(fn => { 124 - try { 125 - fn(msg); 126 - } catch(err) { 127 - console.error(`[WebSocket] Error in ${tract} listener:`, err); 128 - } 129 - }); 130 - } 131 - 132 - // Call wildcard listeners 133 - if(listeners['*']){ 134 - listeners['*'].forEach(fn => { 135 - try { 136 - fn(msg); 137 - } catch(err) { 138 - console.error('[WebSocket] Error in wildcard listener:', err); 139 - } 140 - }); 268 + if (tract) { 269 + dispatchToRecords(tract, msg); 141 270 } 271 + dispatchToRecords('*', msg); 142 272 }; 143 273 144 - ws.onerror = (err) => { 274 + ws.onerror = err => { 145 275 console.error('[WebSocket] Error:', err); 146 276 }; 147 277 } 148 278 149 - // Expose global API 150 279 window.appEvents = { 151 280 /** 152 281 * Listen for events from a specific tract or all events. 153 282 * 154 283 * @param {string} tract - Tract name ('cortex', 'observe', 'indexer', etc.) or '*' for all 155 - * @param {function} fn - Callback function that receives the event object 156 - * @returns {function} Cleanup function to remove the listener 284 + * @param {function|object} optionsOrFn - Callback or options object 285 + * @param {function} [fn] - Callback when using the `(tract, options, fn)` overload 286 + * @returns {function} Cleanup function with `.pending.track(correlationId)` and `.pending.clear(correlationId)` 157 287 * 158 288 * @example 159 - * // Listen to cortex events 160 - * const cleanup = window.appEvents.listen('cortex', (msg) => { 161 - * console.log('Cortex event:', msg); 162 - * }); 163 - * 164 - * // Later, remove listener 165 - * cleanup(); 166 - * 167 - * @example 168 - * // Listen to all events 169 - * window.appEvents.listen('*', (msg) => { 170 - * console.log('Event:', msg.tract, msg.event); 289 + * const cleanup = window.appEvents.listen('importer', { 290 + * schema: ['event', 'use_id'], 291 + * timeout: 15000, 292 + * onTimeout(useId) { 293 + * console.warn('Importer request timed out:', useId); 294 + * } 295 + * }, (msg) => { 296 + * console.log('Importer event:', msg.event); 171 297 * }); 298 + * cleanup.pending.track('abc123'); 172 299 */ 173 - listen(tract, fn){ 174 - if(!listeners[tract]) listeners[tract] = []; 175 - listeners[tract].push(fn); 176 - // Return cleanup function 177 - return () => this.unlisten(tract, fn); 300 + listen(tract, optionsOrFn, fn) { 301 + const hasOptions = typeof optionsOrFn === 'object' && optionsOrFn !== null && typeof fn === 'function'; 302 + const options = hasOptions ? optionsOrFn : {}; 303 + const handler = hasOptions ? fn : optionsOrFn; 304 + 305 + if (typeof handler !== 'function') { 306 + throw new Error('appEvents.listen requires a callback'); 307 + } 308 + 309 + const record = createListenerRecord(handler, { 310 + correlationKey: hasOptions ? options.correlationKey || 'use_id' : 'use_id', 311 + onDrop: hasOptions ? options.onDrop : null, 312 + onTimeout: hasOptions ? options.onTimeout : null, 313 + schema: hasOptions ? options.schema : null, 314 + timeout: hasOptions ? options.timeout : null 315 + }); 316 + addListenerRecord(tract, record); 317 + 318 + const cleanup = () => { 319 + removeListenerRecord(tract, record); 320 + }; 321 + cleanup.pending = record.pending; 322 + return cleanup; 178 323 }, 179 324 180 - /** 181 - * Remove a specific listener for a tract. 182 - * 183 - * @param {string} tract - Tract name or '*' 184 - * @param {function} fn - The listener function to remove 185 - */ 186 - unlisten(tract, fn){ 187 - if(listeners[tract]){ 188 - listeners[tract] = listeners[tract].filter(f => f !== fn); 325 + unlisten(tract, fn) { 326 + if (!listeners[tract]) { 327 + return; 189 328 } 329 + listeners[tract].slice().forEach(record => { 330 + if (record.fn === fn) { 331 + removeListenerRecord(tract, record); 332 + } 333 + }); 190 334 }, 191 335 192 - /** 193 - * Get connection metrics. 194 - * 195 - * @returns {object} Object with connection status and timing info 196 - */ 197 - getMetrics(){ 336 + onParseError(fn) { 337 + if (typeof fn !== 'function') { 338 + throw new Error('appEvents.onParseError requires a callback'); 339 + } 340 + parseErrorHandlers.add(fn); 341 + return () => { 342 + parseErrorHandlers.delete(fn); 343 + }; 344 + }, 345 + 346 + getMetrics() { 198 347 const now = Date.now(); 199 348 return { 200 349 connected: connectionState === 'connected', ··· 207 356 } 208 357 }; 209 358 210 - // Built-in tract: forward notification events to the in-app notification UI 211 - listeners['notification'] = [function(msg) { 359 + addListenerRecord('notification', createListenerRecord(function(msg) { 212 360 window.AppServices?.notifications?.show(msg); 213 - }]; 361 + }, {})); 214 362 215 - // Built-in tract: navigate browser to a path and/or switch facet 216 - listeners['navigate'] = [function(msg) { 363 + addListenerRecord('navigate', createListenerRecord(function(msg) { 217 364 if (msg.facet && !msg.path) { 218 365 window.selectFacet && window.selectFacet(msg.facet); 219 366 } else if (msg.path) { ··· 224 371 } 225 372 window.location.href = msg.path; 226 373 } 227 - }]; 374 + }, {})); 228 375 229 - // Auto-connect when DOM is ready 230 376 if (document.readyState === 'loading') { 231 377 document.addEventListener('DOMContentLoaded', connect); 232 378 } else { 233 - // DOM already loaded, connect immediately 234 379 connect(); 235 380 } 236 381 })();
+1
convey/templates/app.html
··· 18 18 19 19 <!-- WebSocket connection for Callosum events --> 20 20 <script src="{{ url_for('root.static', filename='websocket.js') }}"></script> 21 + <script src="{{ url_for('root.static', filename='api.js') }}"></script> 21 22 22 23 <!-- Apply facet theme immediately to prevent flash --> 23 24 {% if selected_facet and app_registry.apps[app].facets_enabled() %}
+172
scratch/design-convey-error-wave0.md
··· 1 + # Design: convey-error-handling Wave 0 2 + 3 + This file is the lode-local decision log target for Wave 0. The final decision table will also be mirrored into the spec at commit time. 4 + 5 + ## Decision log 6 + 7 + | # | Decision | Chosen | Rationale | 8 + |---|----------|--------|-----------| 9 + | 1 | `api.js` housing | Add `convey/static/api.js`; insert it in `convey/templates/app.html` immediately after `websocket.js` and before the inline sidebar-state script. | That load point runs after `window.appEvents` exists and before any workspace/background consumers, while staying clear of `SurfaceState` and `AppServices` init ordering. | 10 + | 2 | `saveControl` housing | Keep `saveControl` in `convey/static/api.js` beside `ApiError` and `apiJson`. | The save helper depends on the fetch/error contract, so keeping them together avoids split ownership and duplicate imports. | 11 + | 3 | `appEvents.listen` overload | Keep the existing two-arg form and add `(tract, options, fn)` auto-detected by `typeof options === 'object' && typeof fn === 'function'`; the overloaded return stays the cleanup function and is augmented with `pending.track(corrId)` / `pending.clear(corrId)`. | Preserving the cleanup-function return keeps all existing listeners compatible. Attaching `pending` to the returned cleanup function avoids adding a second public API just for correlation tracking. | 12 + | 4 | `onParseError` routing | Add `appEvents.onParseError(fn)`; on websocket parse failure, keep the existing `console.warn`, call every registered parse-error handler, and call `window.logError(error, { context: 'websocket-parse' })` if defined. | This keeps current console visibility, adds explicit subscriber hooks, and routes parse failures into the existing owner-visible error log when available. It also means `error-handler.js` must expose `window.logError`. | 13 + | 5 | `replaceLoading` heuristic | Add `SurfaceState.replaceLoading(container, options)` with the exact heuristic from the scope: if `container.querySelector('.surface-state--loading')` is truthy, treat it as first paint and `replaceChildren(...)`; otherwise manage a singleton `.surface-state-refresh-error` sibling after the last non-error child. | The heuristic matches the audited failure split between first-paint emptiness and refresh-on-stale-content without forcing every caller to hand-roll placement rules. | 14 + | 6 | `registerTask` 403 behavior | On `ApiError.status === 403`, stop the interval, set `health.disabled = true`, do not tint the menu pip, do not notify, and retain the task record in `AppServices.getTaskHealth(appName)`. `registerTask` owns the no-auth-redirect behavior internally so consumers do not pass `noAuthRedirect`. | Support uses `403` as a real disabled state, not a broken-state signal. Centralizing that rule inside `registerTask` keeps background consumers simple and makes diagnostics consistent. | 15 + | 7 | `.menu-item-bg-failing` visual | Apply the class to the existing `<li class="menu-item">` root and render the failing indicator with a `::after` 8px amber pip using `var(--color-warning, #f59e0b)` and a `2px solid var(--facet-bg-primary, #fff)` border. No DOM mutation. | The menu already has a stable root for per-app state, and a pseudo-element avoids template churn while still reading on hover/focus/current states. | 16 + | 8 | Proof-of-adoption set | Adopt the proof set exactly as scoped: `convey/static/pairing.js`, starred-app toggle in `convey/static/app.js`, `apps/tokens/workspace.html`, and `apps/support/background.html`. Ship the `appEvents.listen` overload in Wave 0 without a real-site migration. | This gives one concrete adopter for each new primitive with low blast radius and good audit coverage. Deferring a live websocket-listener migration avoids binding timeout UX decisions into Wave 0 before later waves choose them per surface. | 17 + | 9 | Test form | Add self-contained static smoke pages under `convey/static/tests/` plus `convey/static/tests/README.md`; do not wire them into `tests/verify_browser.py` in this lode. | The repo currently has no `convey/static/tests/` harness. Static HTML pages are enough to prove the primitives without adding a pinchtab dependency to Wave 0. | 18 + | 10 | `ApiError` fields | `ApiError extends Error` with `status`, `statusText`, `serverMessage`, `url`, and optional `cause: 'parse'`; `message === serverMessage`; no `cause: 'network'`. | This keeps `instanceof ApiError` reliable, preserves the server text exactly, and leaves network failures on the existing global error path instead of inventing a second network-error contract. | 19 + | 11 | Error envelope robustness | `apiJson` extracts `payload?.error ?? payload?.message ?? 'Request failed (HTTP ${status})'` and documents that contract in JSDoc. | That catches the dominant server contract plus the existing todos `{"status":"error","message":"..."}` shape without requiring a server-side cleanup before Wave 1-3 adoption. | 20 + | 12 | Menu-item structural precondition | Do not add a new `position: relative` rule: the general `.menu-bar .menu-item` rule already has it in `convey/static/app.css`. | The pip already has a stable positioning anchor at the house-style rule level, so duplicating the property would be noise. | 21 + 22 + ## Primitive APIs (signatures only) 23 + 24 + ```js 25 + /** 26 + * @template T 27 + * @param {string} url 28 + * @param {RequestInit & { noAuthRedirect?: boolean }} [opts] 29 + * @returns {Promise<T>} 30 + * @throws {ApiError} 31 + */ 32 + window.apiJson = function apiJson(url, opts) {}; 33 + 34 + /** 35 + * @extends Error 36 + * @param {{ 37 + * status: number, 38 + * statusText: string, 39 + * serverMessage: string, 40 + * url: string, 41 + * cause?: 'parse' 42 + * }} init 43 + */ 44 + class ApiError extends Error {} 45 + 46 + /** 47 + * @template T 48 + * @param {{ 49 + * el: HTMLElement, 50 + * request: () => Promise<T>, 51 + * snapshot?: () => unknown, 52 + * revertOnError?: boolean | ((snapshot: unknown, error: Error) => void), 53 + * renderError?: (message: string, error: Error) => void, 54 + * clearError?: () => void, 55 + * onSuccess?: (result: T) => void 56 + * }} options 57 + * @returns {Promise<T>} 58 + */ 59 + window.saveControl = function saveControl(options) {}; 60 + 61 + /** 62 + * @param {{ 63 + * icon?: string, 64 + * heading?: string, 65 + * desc?: string, 66 + * action?: string | HTMLElement, 67 + * headingLevel?: string 68 + * }} [options] 69 + * @returns {HTMLElement} 70 + */ 71 + window.SurfaceState.errorCard = function errorCard(options) {}; 72 + 73 + /** 74 + * @param {Element} container 75 + * @param {{ 76 + * icon?: string, 77 + * heading?: string, 78 + * desc?: string, 79 + * action?: string | HTMLElement, 80 + * headingLevel?: string 81 + * }} [options] 82 + * @returns {HTMLElement} 83 + */ 84 + window.SurfaceState.replaceLoading = function replaceLoading(container, options) {}; 85 + 86 + /** 87 + * @param {string} tract 88 + * @param {(msg: any) => void} fn 89 + * @returns {() => void} 90 + */ 91 + window.appEvents.listen = function listen(tract, fn) {}; 92 + 93 + /** 94 + * @param {string} tract 95 + * @param {{ 96 + * schema?: (msg: any) => boolean, 97 + * timeout?: number, 98 + * onDrop?: (msg: any, error: Error) => void, 99 + * onTimeout?: (corrId: string) => void, 100 + * correlationKey?: string | ((msg: any) => string | null | undefined) 101 + * }} options 102 + * @param {(msg: any) => void} fn 103 + * @returns {(() => void) & { pending: { track(corrId: string): void, clear(corrId: string): void } }} 104 + */ 105 + window.appEvents.listen = function listen(tract, options, fn) {}; 106 + 107 + /** 108 + * @param {(error: Error, rawData: string) => void} fn 109 + * @returns {() => void} 110 + */ 111 + window.appEvents.onParseError = function onParseError(fn) {}; 112 + 113 + /** 114 + * @typedef {{ 115 + * disabled: boolean, 116 + * failing: boolean, 117 + * lastError: string | null, 118 + * lastRunAt: number | null, 119 + * lastSuccessAt: number | null, 120 + * consecutiveFailures: number 121 + * }} AppTaskHealth 122 + */ 123 + 124 + /** 125 + * @template T 126 + * @param {string} appName 127 + * @param {string} taskName 128 + * @param {{ 129 + * intervalMs: number, 130 + * run: (task: { apiJson: typeof window.apiJson }) => Promise<T>, 131 + * onSuccess?: (result: T) => void, 132 + * onError?: (error: Error) => void, 133 + * failuresBeforeFailing?: number 134 + * }} options 135 + * @returns {{ stop(): void, runNow(): Promise<void>, getHealth(): AppTaskHealth }} 136 + */ 137 + window.AppServices.registerTask = function registerTask(appName, taskName, options) {}; 138 + 139 + /** 140 + * @param {string} appName 141 + * @returns {Record<string, AppTaskHealth>} 142 + */ 143 + window.AppServices.getTaskHealth = function getTaskHealth(appName) {}; 144 + ``` 145 + 146 + ## Migration plan 147 + 148 + 1. `convey/static/error-handler.js` — expose `window.logError` while preserving the existing bottom-log behavior so websocket parse failures have an owner-visible sink. 149 + 2. `convey/templates/app.html` — insert `api.js` after `websocket.js`; no other load-order change. 150 + 3. `convey/static/api.js` — add `ApiError`, `apiJson`, and `saveControl`. 151 + 4. `convey/static/websocket.js` — add the `listen` overload, parse-error fanout, `onParseError`, and correlation timeout plumbing. 152 + 5. `convey/static/app.js` — add `SurfaceState.errorCard`, `SurfaceState.replaceLoading`, `AppServices.registerTask`, `AppServices.getTaskHealth`, starred-app toggle adoption, and menu failing-state class plumbing. 153 + 6. `convey/static/app.css` — add `.surface-state-refresh-error` and `.menu-item-bg-failing::after` styling. Do not add a new `.menu-item { position: relative; }` rule because it already exists. 154 + 7. `convey/static/pairing.js` — replace the local `fetchJson` implementation with a thin `apiJson(..., { noAuthRedirect: true })` wrapper and update both existing callers in that file. 155 + 8. `apps/tokens/workspace.html` — switch the load-failure catch block to `SurfaceState.errorCard` / `replaceLoading` and remove the Retry button per founder direction. 156 + 9. `apps/support/background.html` — migrate badge polling to `AppServices.registerTask` and delete the silent-swallow comment. 157 + 10. `convey/static/tests/README.md` plus `convey/static/tests/api.html`, `surface-state.html`, `ws-listen.html`, `register-task.html` — add static smoke coverage for the new primitives. 158 + 159 + No `convey/templates/menu_bar.html` change is needed; the existing `<li class="menu-item">` root is the anchor for the failing pip. 160 + 161 + ## Test plan 162 + 163 + - `convey/static/tests/api.html` — smoke `ApiError`, `apiJson`, error-envelope extraction, redirect suppression, and `saveControl` revert behavior with monkeypatched `window.fetch`. 164 + - `convey/static/tests/surface-state.html` — smoke `SurfaceState.errorCard` and `SurfaceState.replaceLoading` for both first-paint and refresh placement. 165 + - `convey/static/tests/ws-listen.html` — smoke `appEvents.listen` overload, pending tracking, timeout callbacks, parse-error fanout, and `onParseError`. 166 + - `convey/static/tests/register-task.html` — smoke `registerTask` lifecycle, success/failure transitions, 403 disable behavior, and `getTaskHealth`. 167 + 168 + ## Risks / open questions 169 + 170 + - `window.logError` is not currently public, so Wave 0 must expose it without regressing the existing `window.error` / `unhandledrejection` surfaces. 171 + - The websocket overload ships without a production adopter in Wave 0; the static smoke page is the only proof until later waves migrate a real listener. 172 + - Static smoke pages are manual in this wave and are not wired into CI or `verify_browser`, so enforcement still depends on reviewer discipline.