From d66c1b48efe01059e493988da821f583cff5958e Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 22:14:49 +0000 Subject: [PATCH] feat: queue authored messages for reconnect (#234) --- README.md | 12 ++- frontend/authored-outbox.js | 133 +++++++++++++++++++++++++++++++++ frontend/drafts.js | 31 ++++++++ frontend/index.html | 67 +++++++++++++++-- frontend/my-work.js | 12 ++- frontend/service-worker.js | 3 +- tests/test_authored_outbox.py | 136 ++++++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 8 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 frontend/authored-outbox.js create mode 100644 tests/test_authored_outbox.py diff --git a/README.md b/README.md index 2704da8..4eb64cd 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,14 @@ saved time and renders this snapshot read-only; opening live details, pagination and server mutations remain disabled until reconnection. **Clear offline work data** deletes the snapshot, and opting out deletes it automatically. -API responses and mutations are never cached by the service worker or queued; -submissions still require connectivity. Service-worker upgrades are atomic and -remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on -the same origin. +API responses and mutations are never cached by the service worker. New issue captures, +issue comments, pull-request comments, and unread-update replies use bounded local +outboxes when connectivity or a retryable server failure prevents delivery. Drafts +shows queued and needs-attention messages with explicit send/discard controls; reconnect +flushes messages sequentially with their original idempotency keys. State-sensitive +actions such as reviews, merges, closures, labels, milestones, and assignments are never +queued. Service-worker upgrades are atomic and remove only older +`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin. Run the test suite with: diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js new file mode 100644 index 0000000..e26cb36 --- /dev/null +++ b/frontend/authored-outbox.js @@ -0,0 +1,133 @@ +function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) { + const storageKey = 'stackchain.authored-outbox.v1'; + const makeId = createOperationId || (() => + globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) + ); + const pending = new Map(); + const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']); + + function read() { + try { + const record = JSON.parse(storage?.getItem(storageKey) || 'null'); + if (record?.version !== 1 || !Array.isArray(record.items)) return []; + return record.items.filter(item => item && supportedKinds.has(item.kind)); + } catch (_error) { return []; } + } + + function write(items) { + storage?.setItem(storageKey, JSON.stringify({ version: 1, items })); + } + + function enqueue(message) { + if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.'); + const items = read(); + const requestedOperationId = String(message.operationId || '').slice(0, 128); + const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId); + if (existing) return { ...existing }; + if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.'); + const id = String(requestedOperationId || makeId()).slice(0, 128); + const item = { + id, + operationId: requestedOperationId || id, + kind: message.kind, + repository: String(message.repository || ''), + number: Number(message.number || 0), + notificationId: Number(message.notificationId || 0), + body: String(message.body || ''), + status: 'queued', + queuedAt: Number(now()), + }; + items.push(item); + write(items); + return item; + } + + function update(id, changes) { + let updated = null; + write(read().map(item => { + if (item.id !== id) return item; + const body = String(changes?.body ?? item.body); + updated = { + ...item, + body, + operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128), + status: 'queued', + }; + delete updated.error; + return updated; + })); + return updated; + } + + function discard(id) { + const items = read(); + if (!items.some(item => item.id === id)) return false; + write(items.filter(item => item.id !== id)); + return true; + } + + function endpoint(item) { + if (item.kind === 'update-reply') { + return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply'; + } + const repository = item.repository.split('/').map(encodeURIComponent).join('/'); + const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; + return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments'; + } + + async function sendItem(item) { + if (pending.has(item.id)) return pending.get(item.id); + const request = (async () => { + try { + const result = await fetchJson(endpoint(item), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ body: item.body }), + }); + discard(item.id); + return { result }; + } catch (error) { + const status = Number(error?.status || 0); + const permanent = status >= 400 && status < 500; + if (permanent) { + write(read().map(candidate => candidate.id === item.id ? { + ...candidate, + status: 'attention', + error: String(error.message || 'Message needs attention').slice(0, 240), + } : candidate)); + } + return { error, transient: !permanent }; + } + })(); + pending.set(item.id, request); + try { return await request; } + finally { if (pending.get(item.id) === request) pending.delete(item.id); } + } + + async function flush() { + const confirmed = []; + for (const item of read()) { + if (item.status === 'attention') continue; + const outcome = await sendItem(item); + if (outcome.result) confirmed.push(outcome.result); + if (outcome.transient) break; + } + return { confirmed, remaining: read() }; + } + + async function retry(id) { + const item = read().find(candidate => candidate.id === id); + if (!item) return { confirmed: [], remaining: read() }; + const queued = item.status === 'attention' ? update(id, item) : item; + const outcome = await sendItem(queued); + return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() }; + } + + return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox; diff --git a/frontend/drafts.js b/frontend/drafts.js index 2d974b1..e8473dd 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -118,6 +118,32 @@ function createDraftInbox({ storage, now = () => Date.now() }) { } catch (_error) { return []; } } + function parseAuthoredOutbox(raw) { + try { + const record = JSON.parse(raw); + if (record?.version !== 1 || !Array.isArray(record.items)) return []; + return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string') + .map(item => { + const isUpdate = item.kind === 'update-reply'; + const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue'; + const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number; + return { + id: 'stackchain.authored-outbox.v1:' + item.id, + outbox_id: item.id, + kind: 'authored-outbox', + status: item.status === 'attention' ? 'attention' : 'queued', + label: item.status === 'attention' ? 'Needs attention' : 'Queued message', + repository: isUpdate ? '' : item.repository, + title: target, + preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')), + updated_at: Number(item.queuedAt || 0), + route: isUpdate ? { kind:'update', notification_id:item.notificationId } : + { kind:routeKind, repository:item.repository, number:item.number }, + }; + }); + } catch (_error) { return []; } + } + function list() { const index = readIndex(); const seen = new Set(); @@ -132,6 +158,11 @@ function createDraftInbox({ storage, now = () => Date.now() }) { seen.add(key); return; } + if (key === 'stackchain.authored-outbox.v1') { + parseAuthoredOutbox(raw).forEach(item => drafts.push(item)); + seen.add(key); + return; + } const parsed = parse(key, raw); if (!parsed) return; seen.add(key); diff --git a/frontend/index.html b/frontend/index.html index 404a1e6..461ebbc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -719,6 +719,7 @@ textarea { resize: vertical; min-height: 120px; } + @@ -830,6 +831,7 @@ textarea { resize: vertical; min-height: 120px; } }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson }); + const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson }); const shareParams = new URLSearchParams(location.search); const sharedLaunch = { title: shareParams.get('title') || '', @@ -985,6 +987,7 @@ textarea { resize: vertical; min-height: 120px; } const notificationReplier = createNotificationReplier({ post: postNotificationReply, storage: localStorage, + authoredOutbox, onStatus: message => { qs('#update-reply-status').textContent = message; }, }); const notificationReader = createNotificationReader({ @@ -1251,13 +1254,18 @@ textarea { resize: vertical; min-height: 120px; } function renderDrafts() { const list = qs('#my-work-list'); list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { + const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; const outboxActions = item.kind === 'issue-outbox' ? '' + '' + '' : + item.kind === 'authored-outbox' ? + '' + + '' + + '' : '' + ''; - const state = item.kind === 'issue-outbox' ? + const state = isOutbox ? '' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '' : ''; return '
' + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + @@ -1290,7 +1298,8 @@ textarea { resize: vertical; min-height: 120px; } const item = lastDrafts[Number(button.dataset.draftIndex)]; if (!item?.outbox_id) return; button.disabled = true; - applyOutboxResult(await issueOutbox.retry(item.outbox_id)); + if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id)); + else applyOutboxResult(await issueOutbox.retry(item.outbox_id)); }); }); list.querySelectorAll('.draft-discard').forEach(button => { @@ -1298,6 +1307,7 @@ textarea { resize: vertical; min-height: 120px; } if (!window.confirm('Discard this unfinished draft?')) return; const item = lastDrafts[Number(button.dataset.draftIndex)]; if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id); + else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id); else if (item) draftInbox.discard(item.id); lastDrafts = draftInbox.list(); const count = qs('[data-work-count="draft"]'); @@ -1970,6 +1980,25 @@ textarea { resize: vertical; min-height: 120px; } applyOutboxResult(await issueOutbox.flush()); } + function applyAuthoredOutboxResult(result) { + refreshMyWorkView(); + const attention = (result.remaining || []).some(item => item.status === 'attention'); + qs('#my-work-action-status').textContent = result.confirmed?.length ? + result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' : + attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' : + 'Message queued for sync when the connection returns.'; + } + + async function flushAuthoredOutbox() { + if (!navigator.onLine || !authoredOutbox.list().length) return; + applyAuthoredOutboxResult(await authoredOutbox.flush()); + } + + function canQueueMessage(error) { + const status = Number(error?.status || 0); + return !status || status >= 500; + } + function inlineAnchor(target) { return { path: target.dataset.reviewFilename, @@ -2802,8 +2831,17 @@ textarea { resize: vertical; min-height: 120px; } qs('#issue-comment').value = ''; qs('#issue-comment-status').textContent = 'Comment posted.'; } catch (error) { - qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.'; - qs('#issue-comment').focus(); + if (canQueueMessage(error)) { + const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation'); + authoredOutbox.enqueue({ kind:'issue-comment', repository:selectedIssue.repository, + number:selectedIssue.number, body, operationId }); + qs('#issue-comment').value = ''; + qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.'; + refreshMyWorkView(); + } else { + qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.'; + qs('#issue-comment').focus(); + } } finally { button.disabled = false; } @@ -2894,8 +2932,17 @@ textarea { resize: vertical; min-height: 120px; } qs('#pull-comment').value = ''; qs('#pull-comment-status').textContent = 'Comment posted.'; } catch (error) { - qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.'; - qs('#pull-comment').focus(); + if (canQueueMessage(error)) { + const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation'); + authoredOutbox.enqueue({ kind:'pull-comment', repository:selectedPull.repository, + number:selectedPull.number, body, operationId }); + qs('#pull-comment').value = ''; + qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.'; + refreshMyWorkView(); + } else { + qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.'; + qs('#pull-comment').focus(); + } } finally { button.disabled = false; } @@ -2948,7 +2995,11 @@ textarea { resize: vertical; min-height: 120px; } qs('#send-update-reply').disabled = true; const result = await notificationReplier.submit(selectedUpdate, body); qs('#send-update-reply').disabled = false; - if (result) { + if (result?.queued) { + qs('#update-reply').value = ''; + refreshMyWorkView(); + qs('#my-work-action-status').textContent = 'Reply queued for sync.'; + } else if (result) { notificationReader.appendReply(result); qs('#update-reply').value = ''; qs('#mark-update-read-next').focus(); @@ -3152,6 +3203,7 @@ textarea { resize: vertical; min-height: 120px; } window.addEventListener('offline', showOfflineStatus); window.addEventListener('online', reconnectLiveData); window.addEventListener('online', flushIssueOutbox); + window.addEventListener('online', flushAuthoredOutbox); qs('#refresh').addEventListener('click', load); qs('#start-work-session').addEventListener('click', () => { @@ -3237,6 +3289,7 @@ textarea { resize: vertical; min-height: 120px; } } contextPoller.start(); flushIssueOutbox(); + flushAuthoredOutbox(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); }); diff --git a/frontend/my-work.js b/frontend/my-work.js index c1c08a2..0875cb1 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -344,7 +344,7 @@ function createNotificationReader({ } function createNotificationReplier({ - post, storage, onStatus, + post, storage, onStatus, authoredOutbox, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(), }) { let pending = false; @@ -380,7 +380,15 @@ function createNotificationReplier({ catch (_error) { /* A confirmed result no longer needs replay identity. */ } onStatus('Reply posted. You can mark this update read when ready.'); return result; - } catch (_error) { + } catch (error) { + const status = Number(error?.status || 0); + if (authoredOutbox && (!status || status >= 500)) { + authoredOutbox.enqueue({ + kind: 'update-reply', notificationId: item.notification_id, body, operationId, + }); + onStatus('Queued for sync when the connection returns.'); + return { queued: true }; + } onStatus('Could not send reply. Your draft is safe; retry.'); return false; } finally { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index aa21115..d735ae8 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'stackchain-dashboard-shell-v5'; +const CACHE = 'stackchain-dashboard-shell-v6'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const BASE = new URL('./', self.location.href).pathname; const SHELL = [ @@ -12,6 +12,7 @@ const SHELL = [ BASE + 'static/widgets.js', BASE + 'static/drafts.js', BASE + 'static/issue-outbox.js', + BASE + 'static/authored-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/my-work.js', BASE + 'static/pick-work.js', diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py new file mode 100644 index 0000000..98071aa --- /dev/null +++ b/tests/test_authored_outbox.py @@ -0,0 +1,136 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from src.views import dashboard + + +OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js" + + +def run_node(script: str): + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const calls = []; +const outbox = createAuthoredOutbox({{ + storage, now:() => 1234, + fetchJson:async (url, options) => {{ + calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); + return {{id:calls.length}}; + }}, +}}); +outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:'Issue note',operationId:'issue-op'}}); +outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}}); +outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}}); +const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list(); +outbox.flush().then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}}))); +""" + output = run_node(script) + + assert [item["kind"] for item in output["persisted"]] == [ + "issue-comment", "pull-comment", "update-reply" + ] + assert [item["operationId"] for item in output["persisted"]] == ["issue-op", "pull-op", "update-op"] + assert output["calls"] == [ + {"url": "api/v1/repos/stackchain/api/issues/7/comments", "key": "issue-op", "body": {"body": "Issue note"}}, + {"url": "api/v1/repos/stackchain/web/pulls/8/comments", "key": "pull-op", "body": {"body": "PR note"}}, + {"url": "api/v1/notifications/9/reply", "key": "update-op", "body": {"body": "Update note"}}, + ] + assert len(output["result"]["confirmed"]) == 3 + assert output["remaining"] == [] + + +def test_authored_outbox_classifies_failures_and_continues_past_attention_items(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +let phase = 'permanent'; +const calls = []; +const outbox = createAuthoredOutbox({{ + storage, + fetchJson:async (_url, options) => {{ + const body = JSON.parse(options.body).body; calls.push(body); + if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }} + if (body === 'Later' && phase === 'transient') {{ const e = new Error('Offline'); e.status=503; throw e; }} + return {{id:calls.length}}; + }}, +}}); +const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}}); +outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}}); +outbox.flush().then(async first => {{ + phase='transient'; + outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}}); + const second=await outbox.flush(); + process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}})); +}}); +""" + output = run_node(script) + + assert output["calls"] == ["Bad", "Good", "Later"] + assert len(output["first"]["confirmed"]) == 1 + assert output["remaining"][0]["id"] == output["badId"] + assert output["remaining"][0]["status"] == "attention" + assert output["remaining"][0]["error"] == "Reply rejected" + assert output["remaining"][1]["status"] == "queued" + + +def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +let release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0; +const outbox=createAuthoredOutbox({{ + storage, createOperationId:()=> 'new-' + (++sequence), + fetchJson:async()=>{{calls++; await gate; return {{id:1}};}}, +}}); +const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}}); +const edited=outbox.update(item.id,{{body:'New'}}); +const flush=outbox.flush(); const retry=outbox.retry(item.id); release(); +Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}}))); +""" + output = run_node(script) + + assert output["edited"]["body"] == "New" + assert output["edited"]["operationId"] == "new-1" + assert output["calls"] == 1 + assert output["remaining"] == [] + + +def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox=createAuthoredOutbox({{storage}}); +const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}}); +const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}}); +process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}})); +""" + output = run_node(script) + + assert output["first"] == output["second"] + assert len(output["items"]) == 1 + + +@pytest.mark.anyio +async def test_mobile_dashboard_loads_and_operates_authored_message_outbox(): + html = await dashboard() + + assert '' in html + assert "createAuthoredOutbox({ storage: localStorage" in html + assert "window.addEventListener('online', flushAuthoredOutbox)" in html + assert "authoredOutbox.enqueue" in html + assert "authoredOutbox.retry(item.outbox_id)" in html + assert "authoredOutbox.discard(item.outbox_id)" in html + assert "if (result?.queued)" in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 3fe1453..bcbc9e6 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -84,6 +84,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/widgets.js", "/dashboard/static/drafts.js", "/dashboard/static/issue-outbox.js", + "/dashboard/static/authored-outbox.js", "/dashboard/static/offline-work.js", "/dashboard/static/my-work.js", "/dashboard/static/pick-work.js", -- 2.43.0