personal memory agent
0
fork

Configure Feed

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

convey/import: adopt wave 0 error primitives (wave 1)

Migrate loadImports and loadMoreImports to apiJson(...) with shape validation, upgrade the first-paint loader scaffold, drop the Retry button from first-paint failures, and render load-more failures as a singleton .surface-state-refresh-error banner above #importLoadMore while preserving visible rows. Move the importer websocket listener to the options overload with correlationKey 'import_id', IMPORT_STALL_TIMEOUT_MS = 10 * 60 * 1000, and onTimeout: markRowStalled; add the amber stalled-row UI with import_id surfaced and clear it when a later started or status event arrives. IMPORT_ROW_EVENTS gating prevents file_imported, enrichment_ready, and observe events from clobbering row state.

Co-authored-by: OpenAI Codex <codex@openai.com>

+187 -24
+187 -24
apps/import/workspace.html
··· 403 403 border-color: #f0c2c2; 404 404 } 405 405 406 + .import-completion-summary.stalled { 407 + border: 1px solid var(--color-warning, #f59e0b); 408 + background: rgba(245, 158, 11, 0.08); 409 + } 410 + 406 411 .import-history-header { 407 412 display: flex; 408 413 justify-content: space-between; ··· 476 481 .import-status.failed { background: #fff0f0; color: #9b2c2c; } 477 482 .import-status.running { background: #cfe2ff; color: #084298; display: inline-flex; align-items: center; gap: 6px; } 478 483 .import-status.running::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #084298; animation: pulse 1.5s ease-in-out infinite; } 484 + .import-status.stalled { background: rgba(245, 158, 11, 0.16); color: #92400e; } 479 485 .progress-detail { font-size: 0.8em; color: #8891a0; margin-top: 2px; } 486 + .progress-detail.import-stalled-meta { color: #92400e; } 480 487 .file-size { color: #4b5565; font-size: 0.9rem; } 481 488 .nowrap { white-space: nowrap; } 482 489 .timestamp-link { color: var(--facet-color, #0f4c81); text-decoration: none; } ··· 512 519 transition: background 0.1s ease; 513 520 } 514 521 522 + .import-table tbody tr.import-row--stalled { 523 + background: rgba(245, 158, 11, 0.08); 524 + } 525 + 526 + .import-table tbody tr.import-row--stalled:hover { 527 + background: rgba(245, 158, 11, 0.12); 528 + } 529 + 515 530 .import-table tbody tr:active { 516 531 background: #e8edf5; 517 532 } ··· 607 622 <div class="import-list"> 608 623 <h2>Import History</h2> 609 624 <div id="importListContent"> 610 - <div class="no-imports">Loading imports...</div> 625 + <div class="surface-state surface-state--loading" role="status" aria-busy="true"> 626 + <div class="surface-state-spinner" aria-hidden="true"></div> 627 + <span class="surface-state-text" data-role="loading-status">Loading imports...</span> 628 + </div> 611 629 </div> 612 630 </div> 613 631 </div> ··· 657 675 segmenting: 'Preparing segments...', 658 676 transcribing: 'Transcribing audio...', 659 677 }; 678 + const IMPORT_STALL_TIMEOUT_MS = 10 * 60 * 1000; // longest observed real import; stalled rows surface amber after this without a terminal event 679 + const IMPORT_ROW_EVENTS = new Set(['started', 'status', 'completed', 'error']); 680 + const IMPORT_TERMINAL_EVENTS = new Set(['completed', 'error']); 660 681 661 682 let cachedSources = []; 662 683 let sourceMetadataByName = {}; ··· 671 692 let currentPage = 1; 672 693 let totalImports = 0; 673 694 const escapeHtml = window.AppServices.escapeHtml; 695 + let importEventsCleanup = null; 696 + 697 + function malformedImportHistoryResponse(url) { 698 + return new window.ApiError({ 699 + status: 200, 700 + statusText: 'OK', 701 + serverMessage: 'Malformed import history response', 702 + url, 703 + cause: 'parse', 704 + }); 705 + } 706 + 707 + function clearImportListErrors() { 708 + const container = document.getElementById('importListContent'); 709 + const loadMoreError = document.getElementById('importLoadMoreError'); 710 + if (loadMoreError) { 711 + loadMoreError.remove(); 712 + } 713 + if (!container) { 714 + return; 715 + } 716 + const siblingError = container.nextElementSibling; 717 + if (siblingError && siblingError.classList.contains('surface-state-refresh-error')) { 718 + siblingError.remove(); 719 + } 720 + } 721 + 722 + function trackPendingImport(importId) { 723 + if (!importId || !importEventsCleanup?.pending) { 724 + return; 725 + } 726 + importEventsCleanup.pending.track(importId); 727 + } 728 + 729 + function clearPendingImport(importId) { 730 + if (!importId || !importEventsCleanup?.pending) { 731 + return; 732 + } 733 + importEventsCleanup.pending.clear(importId); 734 + } 674 735 675 736 function navigateTo(view, options = {}) { 676 737 const { updateHash = true } = options; ··· 979 1040 currentPage = 1; 980 1041 await loadSourceGrid(); 981 1042 try { 982 - const response = await fetch('/app/import/api/list?page=1&per_page=25'); 983 - const data = await response.json(); 984 - const imports = data.imports || []; 1043 + const data = await window.apiJson('/app/import/api/list?page=1&per_page=25'); 1044 + if (!Array.isArray(data.imports) || typeof data.total !== 'number' || typeof data.page !== 'number' || typeof data.pages !== 'number') { 1045 + throw malformedImportHistoryResponse('/app/import/api/list?page=1&per_page=25'); 1046 + } 1047 + const imports = data.imports; 985 1048 importsCache = imports; 986 1049 totalImports = data.total; 987 1050 const container = document.getElementById('importListContent'); 1051 + clearImportListErrors(); 988 1052 989 1053 if (imports.length === 0) { 990 1054 container.innerHTML = ` ··· 998 1062 return; 999 1063 } 1000 1064 1001 - let html = buildHistoryHeader(data.total, data.total_entries_written, data.total_entities_seeded); 1065 + let html = buildHistoryHeader(data.total, data.total_entries_written || 0, data.total_entities_seeded || 0); 1002 1066 html += '<table class="import-table">'; 1003 1067 html += '<thead><tr>'; 1004 1068 html += '<th>Imported At</th>'; ··· 1036 1100 }; 1037 1101 } 1038 1102 1103 + imports.forEach(imp => { 1104 + if (imp.status === 'running' || imp.status === 'pending') { 1105 + trackPendingImport(imp.timestamp); 1106 + } else { 1107 + clearPendingImport(imp.timestamp); 1108 + } 1109 + }); 1110 + 1039 1111 filterImportsByFacet(window.selectedFacet); 1040 1112 } catch (err) { 1041 - document.getElementById('importListContent').innerHTML = ` 1042 - <div class="no-imports"> 1043 - <div class="no-imports-icon">⚠️</div> 1044 - <div class="no-imports-heading">Couldn't load your import history</div> 1045 - <div class="no-imports-body error">This might be temporary.</div> 1046 - <div class="no-imports-action"><button class="import-primary-btn" onclick="loadImports()">Retry</button></div> 1047 - </div> 1048 - `; 1113 + window.SurfaceState.replaceLoading('importListContent', window.SurfaceState.errorCard({ 1114 + heading: 'Couldn\'t load your import history', 1115 + desc: 'Reload the page to try again.', 1116 + serverMessage: err?.serverMessage || err?.message || '', 1117 + })); 1118 + } 1119 + } 1120 + 1121 + function renderLoadMoreError(err) { 1122 + const table = document.querySelector('.import-table'); 1123 + if (!table || !table.parentNode) { 1124 + return; 1125 + } 1126 + 1127 + let errorEl = document.getElementById('importLoadMoreError'); 1128 + if (!errorEl) { 1129 + errorEl = document.createElement('div'); 1130 + errorEl.id = 'importLoadMoreError'; 1131 + errorEl.className = 'surface-state-refresh-error'; 1132 + } 1133 + 1134 + const serverMessage = err?.serverMessage || err?.message || ''; 1135 + errorEl.innerHTML = `<strong>Couldn&#39;t load more imports.</strong> Reload the page to try again.${serverMessage ? ` ${escapeHtml(serverMessage)}` : ''}`; 1136 + 1137 + const loadMoreContainer = document.getElementById('importLoadMore'); 1138 + if (loadMoreContainer) { 1139 + table.parentNode.insertBefore(errorEl, loadMoreContainer); 1140 + } else { 1141 + table.parentNode.appendChild(errorEl); 1049 1142 } 1050 1143 } 1051 1144 1052 1145 async function loadMoreImports() { 1053 1146 currentPage++; 1054 1147 try { 1055 - const response = await fetch(`/app/import/api/list?page=${currentPage}&per_page=25`); 1056 - const data = await response.json(); 1148 + const data = await window.apiJson(`/app/import/api/list?page=${currentPage}&per_page=25`); 1149 + if (!Array.isArray(data.imports) || typeof data.total !== 'number' || typeof data.page !== 'number' || typeof data.pages !== 'number') { 1150 + throw malformedImportHistoryResponse(`/app/import/api/list?page=${currentPage}&per_page=25`); 1151 + } 1057 1152 1058 - importsCache = importsCache.concat(data.imports || []); 1153 + importsCache = importsCache.concat(data.imports); 1154 + const existingError = document.getElementById('importLoadMoreError'); 1155 + if (existingError) { 1156 + existingError.remove(); 1157 + } 1059 1158 1060 1159 const tbody = document.querySelector('.import-table tbody'); 1061 1160 if (tbody) { 1062 1161 let rowsHtml = ''; 1063 - (data.imports || []).forEach(imp => { 1162 + data.imports.forEach(imp => { 1064 1163 rowsHtml += renderImportRow(imp); 1065 1164 }); 1066 1165 tbody.insertAdjacentHTML('beforeend', rowsHtml); ··· 1078 1177 } 1079 1178 } 1080 1179 1180 + data.imports.forEach(imp => { 1181 + if (imp.status === 'running' || imp.status === 'pending') { 1182 + trackPendingImport(imp.timestamp); 1183 + } else { 1184 + clearPendingImport(imp.timestamp); 1185 + } 1186 + }); 1187 + 1081 1188 filterImportsByFacet(window.selectedFacet); 1082 1189 } catch (err) { 1083 - console.error('Failed to load more imports:', err); 1190 + currentPage--; 1191 + renderLoadMoreError(err); 1084 1192 } 1085 1193 } 1086 1194 ··· 1120 1228 } 1121 1229 } 1122 1230 1231 + function markRowStalled(importId) { 1232 + if (!importId) { 1233 + return; 1234 + } 1235 + 1236 + clearPendingImport(importId); 1237 + importEvents[importId] = { ...(importEvents[importId] || {}), stalled: true }; 1238 + 1239 + const row = document.querySelector(`tr[data-import-id="${importId}"]`); 1240 + if (row) { 1241 + row.classList.add('import-row--stalled'); 1242 + const statusCell = row.querySelector('.status-cell'); 1243 + if (statusCell) { 1244 + statusCell.innerHTML = `<span class="import-status stalled">Stalled</span><div class="progress-detail import-stalled-meta">Stalled — no updates in 10 minutes. Import ID: ${escapeHtml(importId)}. Reload to retry.</div>`; 1245 + } 1246 + } 1247 + 1248 + refreshInlineProgress(importId); 1249 + } 1250 + 1123 1251 function updateImportRow(importId, eventData) { 1124 1252 if (importId) { 1125 1253 importEvents[importId] = { ...(importEvents[importId] || {}), ...eventData }; 1254 + if (IMPORT_ROW_EVENTS.has(eventData.event)) { 1255 + importEvents[importId].stalled = false; 1256 + } 1126 1257 } 1127 1258 1128 1259 const row = document.querySelector(`tr[data-import-id="${importId}"]`); ··· 1136 1267 const statsCell = row.querySelector('.stats-cell'); 1137 1268 const durationCell = row.querySelector('.duration-cell'); 1138 1269 const sourceCell = row.querySelector('.source-cell'); 1270 + 1271 + if (IMPORT_ROW_EVENTS.has(eventData.event)) { 1272 + row.classList.remove('import-row--stalled'); 1273 + } 1139 1274 1140 1275 if (sourceCell && (eventData.source_type || eventData.source_display)) { 1141 1276 const displayText = eventData.source_display || sourceMetadataByName[eventData.source_type]?.display_name || row.children[1]?.textContent || '-'; ··· 1626 1761 source_type: source.name, 1627 1762 source_display: source.display_name, 1628 1763 }; 1764 + trackPendingImport(ts); 1629 1765 window._guidedForceImport = false; 1630 1766 loadImports(); 1631 1767 navigateTo(`progress/${ts}`); ··· 1650 1786 } 1651 1787 1652 1788 const displayName = currentEvent?.source_display || importInfo?.source_display || source?.display_name || 'Import'; 1653 - const stageLabel = currentEvent?.event === 'completed' 1654 - ? 'Import complete' 1655 - : currentEvent?.event === 'error' 1656 - ? 'Import failed' 1657 - : humanStageName(currentEvent?.stage || 'initialization', displayName); 1789 + const stageLabel = currentEvent?.stalled 1790 + ? 'Stalled — no updates in 10 minutes' 1791 + : currentEvent?.event === 'completed' 1792 + ? 'Import complete' 1793 + : currentEvent?.event === 'error' 1794 + ? 'Import failed' 1795 + : humanStageName(currentEvent?.stage || 'initialization', displayName); 1658 1796 const progressHtml = currentEvent ? renderProgressStats(currentEvent) : ''; 1659 1797 let completionHtml = ''; 1660 1798 ··· 1685 1823 </div> 1686 1824 </div> 1687 1825 `; 1826 + } else if (currentEvent?.stalled) { 1827 + completionHtml = ` 1828 + <div class="import-completion-summary stalled"> 1829 + <div><strong>Import ID:</strong> ${escapeHtml(importId)}</div> 1830 + <div>Stalled — no updates in 10 minutes. Reload to retry.</div> 1831 + <div class="import-action-row"> 1832 + <a href="#" class="import-secondary-btn" onclick="showGrid(); return false;">Import another source</a> 1833 + </div> 1834 + </div> 1835 + `; 1688 1836 } 1689 1837 1690 1838 guideSteps.innerHTML = ` ··· 1747 1895 source_type: 'quick', 1748 1896 source_display: 'Quick Import', 1749 1897 }; 1898 + trackPendingImport(ts); 1750 1899 loadImports(); 1751 1900 navigateTo(`progress/${ts}`); 1752 1901 } catch (err) { ··· 1788 1937 } 1789 1938 } 1790 1939 1791 - appEvents.listen('importer', eventData => updateImportRow(eventData.import_id, eventData)); 1940 + importEventsCleanup = appEvents.listen('importer', { 1941 + correlationKey: 'import_id', 1942 + timeout: IMPORT_STALL_TIMEOUT_MS, 1943 + onTimeout: markRowStalled, 1944 + }, eventData => { 1945 + if (!IMPORT_ROW_EVENTS.has(eventData.event)) { 1946 + return; 1947 + } 1948 + if (IMPORT_TERMINAL_EVENTS.has(eventData.event)) { 1949 + clearPendingImport(eventData.import_id); 1950 + } else { 1951 + trackPendingImport(eventData.import_id); 1952 + } 1953 + updateImportRow(eventData.import_id, eventData); 1954 + }); 1792 1955 1793 1956 loadSourceGrid(); 1794 1957 loadFacets();