From 9dede4bf2a673790516ca631c8c7ea347ec55eca Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 11:57:30 +0000 Subject: [PATCH] feat: add offline delivery center (Closes #481) --- frontend/authored-outbox.js | 11 ++++++++ frontend/dashboard.css | 6 +++++ frontend/dashboard.js | 36 +++++++++++++++++++++++--- frontend/drafts.js | 25 +++++++++++++++--- frontend/issue-outbox.js | 10 ++++++++ tests/test_authored_outbox.py | 24 ++++++++++++++++++ tests/test_drafts.py | 48 +++++++++++++++++++++++++++++++++++ tests/test_issue_outbox.py | 24 ++++++++++++++++++ 8 files changed, 177 insertions(+), 7 deletions(-) diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index c2a56ce..7f38cd4 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -166,6 +166,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, async function sendItem(item, currentLogin) { if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; if (pending.has(item.id)) return pending.get(item.id); + const attemptAt = Number(now()); + write(read().map(candidate => candidate.id === item.id ? { + ...candidate, status:'sending', lastAttemptAt:attemptAt, + } : candidate), false); const request = (async () => { try { let result; @@ -209,13 +213,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, } catch (error) { const status = Number(error?.status || 0); const permanent = status >= 400 && status < 500; + const attemptError = String(error.message || 'Delivery failed').slice(0, 240); if (permanent) { write(read().map(candidate => candidate.id === item.id ? { ...candidate, status: 'attention', error: String(error.message || 'Message needs attention').slice(0, 240), + lastAttemptAt: attemptAt, + lastAttemptError: attemptError, ...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}), } : candidate)); + } else { + write(read().map(candidate => candidate.id === item.id ? { + ...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError, + } : candidate)); } return { error, transient: !permanent }; } diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 2a85b4a..49fa492 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -126,6 +126,12 @@ textarea { resize: vertical; min-height: 120px; } .draft-preview { color:var(--muted); overflow-wrap:anywhere; } .draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .draft-actions button { min-height:44px; width:100%; } +.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; } +.delivery-center h3, .delivery-center p { margin:0; } +.delivery-center button { min-height:44px; flex:0 0 auto; } +.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; } +.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; } +.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; } .create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); } .create-issue-actions button { min-height:44px; max-width:100%; width:100%; } .create-issue-actions #create-issue-status { grid-column:1/-1; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index fc6f544..dba01e9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1640,7 +1640,9 @@ function renderDrafts() { const list = qs('#my-work-list'); - list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { + const deliveryCenter = draftInbox.partition(lastDrafts); + const renderDraftCard = item => { + const index = lastDrafts.indexOf(item); const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; const isUnfiled = item.kind === 'unfiled-issue'; const reviewOutbox = item.outbox_kind === 'pull-review'; @@ -1674,15 +1676,41 @@ const state = (isOutbox || isUnfiled) ? '' + (item.quarantined ? 'Identity protected' : (isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' : - item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + + item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' : 'Queued for sync')) + '' + (item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : ''; + const attempt = item.last_attempt_error ? 'Last attempt ' + + escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '' : ''; return '
' + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + '' + escapeHtml(item.title) + '' + - '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + + '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + attempt + 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' + '
' + outboxActions + '
'; - }).join('') : '
No unfinished drafts.
'; + }; + const deliverySummary = '
' + + '

Delivery center

' + + '

Waiting ' + deliveryCenter.counts.waiting + ' · ' + + 'Sending ' + deliveryCenter.counts.sending + ' · ' + + 'Needs attention ' + deliveryCenter.counts.attention + '

' + + '
'; + const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') : + '
No queued deliveries.
'; + const draftCards = deliveryCenter.drafts.length ? deliveryCenter.drafts.map(renderDraftCard).join('') : + '
No unfinished drafts.
'; + list.innerHTML = deliverySummary + '
' + + '

Queued deliveries

' + deliveryCards + '
' + + '

Unfinished drafts

' + draftCards + '
'; + qs('#retry-waiting-deliveries').addEventListener('click', async event => { + const button = event.currentTarget; + if (!activeFlushLogin || !deliveryCenter.retryable.length) return; + button.disabled = true; + qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…'; + const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]); + applyOutboxResult(issueResult); + applyAuthoredOutboxResult(authoredResult); + qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.'; + }); list.querySelectorAll('.draft-resume').forEach(button => { button.addEventListener('click', () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; diff --git a/frontend/drafts.js b/frontend/drafts.js index 97f6419..a84bd8c 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -112,7 +112,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat id: 'stackchain.issue-outbox.v1:' + item.id, outbox_id: item.id, kind: 'issue-outbox', - status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'), + status: item.status === 'completion' ? 'completion' : + (item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')), label: item.deliveryState === 'uncertain' ? 'Verify delivery' : (item.status === 'completion' ? 'Created · ready to start' : (item.status === 'attention' ? 'Needs attention' : 'Queued issue')), @@ -125,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat quarantined, ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') + (currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '', + last_attempt_at: Number(item.lastAttemptAt || 0), + last_attempt_error: textPreview(item.lastAttemptError), updated_at: Number(item.queuedAt || 0), }; }); @@ -157,7 +160,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat outbox_id: item.id, outbox_kind: item.kind, kind: 'authored-outbox', - status: item.status === 'attention' ? 'attention' : 'queued', + status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued'), label: item.deliveryState === 'uncertain' ? 'Verify delivery' : (item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') : (isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))), @@ -170,6 +173,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat quarantined, ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') + (currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '', + last_attempt_at: Number(item.lastAttemptAt || 0), + last_attempt_error: textPreview(item.lastAttemptError), updated_at: Number(item.queuedAt || 0), route: isUpdate ? { kind:'update', notification_id:item.notificationId } : { kind:routeKind, repository:item.repository, number:item.number }, @@ -228,7 +233,21 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat } catch (_error) { return false; } } - return { list, discard }; + function partition(items = list()) { + const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox'); + const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox'); + const counts = deliveries.reduce((summary, item) => { + const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' : 'waiting'); + summary[state] += 1; + return summary; + }, { waiting:0, sending:0, attention:0 }); + const retryable = deliveries.filter(item => + item.status === 'queued' && !item.quarantined && item.delivery_state !== 'uncertain' + ); + return { drafts, deliveries, counts, retryable }; + } + + return { list, discard, partition }; } if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox; diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index daa7989..fe8a06f 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -256,6 +256,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge async function sendItem(item, currentLogin) { if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; if (pending.has(item.id)) return pending.get(item.id); + const attemptAt = Number(now()); + write(read().map(candidate => candidate.id === item.id ? { + ...candidate, status:'sending', lastAttemptAt:attemptAt, + } : candidate), false); const repository = item.repository.split('/').map(encodeURIComponent).join('/'); const request = (async () => { try { @@ -276,11 +280,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge return { issue, item }; } catch (error) { const status = Number(error?.status || 0); + const attemptError = String(error.message || 'Delivery failed').slice(0, 240); if (status >= 400 && status < 500) { write(read().map(candidate => candidate.id === item.id ? { ...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240), + lastAttemptAt: attemptAt, lastAttemptError: attemptError, ...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}), } : candidate)); + } else { + write(read().map(candidate => candidate.id === item.id ? { + ...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError, + } : candidate)); } return { error, transient: !(status >= 400 && status < 500) }; } diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index 3671bc3..d9f780e 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -77,6 +77,30 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi assert output["remaining"] == [] +def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details(): + 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)}}; +let release; const gate = new Promise(resolve => release = resolve); +const outbox = createAuthoredOutbox({{ + storage, getOwnerLogin:()=>'timmy', now:()=>777, + fetchJson:async()=>{{await gate; const error=new Error('Network unavailable');error.status=503;throw error;}}, +}}); +outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Wait',operationId:'attempt'}}); +const pending=outbox.flush('timmy'); +const sending=outbox.list()[0]; +release(); +pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}}))); +""" + output = run_node(script) + + assert output["sending"]["status"] == "sending" + assert output["after"]["status"] == "queued" + assert output["after"]["lastAttemptAt"] == 777 + assert output["after"]["lastAttemptError"] == "Network unavailable" + + def test_authored_outbox_persists_and_delivers_complete_pull_review_payload(): script = f""" const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 48d7789..950bd9e 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -225,6 +225,36 @@ process.stdout.write(JSON.stringify(drafts)); assert by_id["mine"]["quarantined"] is False +def test_draft_inbox_partitions_delivery_center_and_counts_only_safe_waiting_retries(): + script = f""" +const createDraftInbox = require({json.dumps(str(DRAFTS))}); +const values = new Map([ + ['stackchain.issue-comment.v1:stackchain/api#17', 'Unfinished comment'], + ['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[ + {{id:'waiting',repository:'o/r',title:'Waiting issue',body:'Body',ownerLogin:'timmy',status:'queued',queuedAt:100,lastAttemptAt:90,lastAttemptError:'Network unavailable'}}, + {{id:'sending',repository:'o/r',title:'Sending issue',body:'Body',ownerLogin:'timmy',status:'sending',queuedAt:200}}, + {{id:'attention',repository:'o/r',title:'Broken issue',body:'Body',ownerLogin:'timmy',status:'attention',queuedAt:300}}, + {{id:'other-user',repository:'o/r',title:'Private issue',body:'Body',ownerLogin:'alexander',status:'queued',queuedAt:400}} + ]}})], + ['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[ + {{id:'uncertain',kind:'issue-comment',repository:'o/r',number:4,body:'Maybe sent',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',queuedAt:500}} + ]}})], +]); +const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}); +process.stdout.write(JSON.stringify(inbox.partition())); +""" + output = run_node(script) + + assert len(output["drafts"]) == 1 + assert len(output["deliveries"]) == 5 + assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2} + assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"] + waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting") + assert waiting["last_attempt_at"] == 90 + assert waiting["last_attempt_error"] == "Network unavailable" + + @pytest.mark.anyio async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): html = await dashboard() @@ -245,6 +275,24 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): assert "error.code = payload.detail?.code" in html +@pytest.mark.anyio +async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_waiting_work(): + html = await dashboard() + + assert 'class="delivery-center"' in html + assert 'id="retry-waiting-deliveries"' in html + assert 'Waiting ' in html + assert 'Sending ' in html + assert 'Needs attention ' in html + assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html + assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html + assert "deliveryCenter.retryable.length" in html + assert "item.status === 'sending' ? 'Sending'" in html + assert "item.last_attempt_error ? 'Last attempt '" in html + assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html + assert '.delivery-center button { min-height:44px;' in html + + @pytest.mark.anyio async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot(): html = await dashboard() diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 0a15964..1557a28 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -202,6 +202,30 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul assert output["remaining"][0]["status"] == "queued" +def test_issue_outbox_exposes_sending_then_preserves_transient_attempt_details(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +let release; const gate = new Promise(resolve => release = resolve); +const outbox = createIssueOutbox({{ + storage, getOwnerLogin:()=>'timmy', createOperationId:()=>'attempt', now:()=>888, + fetchJson:async()=>{{await gate;const error=new Error('Gateway timeout');error.status=503;throw error;}}, +}}); +outbox.enqueue({{repository:'o/r',title:'Wait',body:'Context'}}); +const pending=outbox.flush('timmy'); +const sending=outbox.list()[0]; +release(); +pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}}))); +""" + output = run_node(script) + + assert output["sending"]["status"] == "sending" + assert output["after"]["status"] == "queued" + assert output["after"]["lastAttemptAt"] == 888 + assert output["after"]["lastAttemptError"] == "Gateway timeout" + + def test_foreground_attachment_retry_does_not_create_a_second_issue(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); -- 2.43.0