From dc18004e02766382dd156e016b2d896ff7ab2395 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 16:40:31 +0000 Subject: [PATCH] fix: recover orphaned authored operations (#319) --- frontend/authored-outbox.js | 15 ++++- frontend/background-issue-sync.js | 11 +++- frontend/dashboard.js | 8 ++- frontend/drafts.js | 8 ++- frontend/issue-outbox.js | 17 +++++- frontend/service-worker.js | 5 +- src/idempotency.py | 8 ++- src/main.py | 17 +++++- tests/test_authored_idempotency.py | 93 +++++++++++++++++++++++++++++ tests/test_authored_outbox.py | 46 ++++++++++++++ tests/test_background_issue_sync.py | 25 ++++++++ tests/test_drafts.py | 19 ++++++ tests/test_idempotency_ledger.py | 18 ++++++ tests/test_issue_outbox.py | 28 +++++++++ tests/test_service_worker.py | 21 ++++++- 15 files changed, 321 insertions(+), 18 deletions(-) diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index f0c90da..ae8a42c 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -77,6 +77,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, status: 'queued', }; delete updated.error; + delete updated.deliveryState; return updated; })); return updated; @@ -134,6 +135,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, ...candidate, status: 'attention', error: String(error.message || 'Message needs attention').slice(0, 240), + ...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}), } : candidate)); } return { error, transient: !permanent }; @@ -170,7 +172,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, if (!currentLogin || item.ownerLogin !== currentLogin) { return { confirmed: [], remaining: read(), blocked: 1 }; } - const queued = item.status === 'attention' ? update(id, item) : item; + let queued = item; + if (item.status === 'attention') { + queued = { + ...item, + operationId: String(makeId()).slice(0, 128), + status: 'queued', + }; + delete queued.error; + delete queued.deliveryState; + write(read().map(candidate => candidate.id === id ? queued : candidate)); + } const outcome = await sendItem(queued, currentLogin); return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 }; } @@ -189,6 +201,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, ...item, status: 'attention', error: String(background.error || 'Message needs attention').slice(0, 240), + ...(background.deliveryState ? { deliveryState: background.deliveryState } : {}), }]; return [item]; }); diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 66509c5..14c89e3 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -182,7 +182,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n claimBatch, complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })), release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })), - fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })), + fail: (id, error, deliveryState) => update(id, item => ({ + ...item, status: 'attention', claimUntil: 0, error, + ...(deliveryState ? { deliveryState } : {}), + })), snapshot: () => transact(async records => (await records.getAll()).filter(item => item.recordType !== 'receipt-preference')), countBlocked: ownerLogin => transact(async records => @@ -301,7 +304,11 @@ function createBackgroundIssueSync({ throw error; } if (status >= 400 && status < 500) { - await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240)); + await store.fail( + item.id, + String(error?.message || 'Issue needs attention').slice(0, 240), + error?.code === 'delivery_uncertain' ? 'uncertain' : undefined, + ); return { attention: true, error, receipt: receiptFor(item, 'attention') }; } await store.release(item.id); diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9018b28..f06c51d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -144,8 +144,9 @@ const response = await fetch(url, options); const payload = await response.json().catch(() => ({})); if (!response.ok) { - const error = new Error(payload.error || payload.detail || 'Review request failed.'); + const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.'); error.status = response.status; + error.code = payload.detail?.code; throw error; } return payload; @@ -709,16 +710,17 @@ list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; const isUnfiled = item.kind === 'unfiled-issue'; + const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now'; const outboxActions = item.quarantined ? '' + '' : item.kind === 'issue-outbox' ? '' + - '' + + '' + '' : item.kind === 'authored-outbox' ? '' + - '' + + '' + '' : '' + diff --git a/frontend/drafts.js b/frontend/drafts.js index 61c7b2d..4ddcba4 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -113,7 +113,9 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat outbox_id: item.id, kind: 'issue-outbox', status: item.status === 'attention' ? 'attention' : 'queued', - label: item.status === 'attention' ? 'Needs attention' : 'Queued issue', + label: item.deliveryState === 'uncertain' ? 'Verify delivery' : + (item.status === 'attention' ? 'Needs attention' : 'Queued issue'), + delivery_state: item.deliveryState, repository: item.repository, title: textPreview(item.title) || 'Untitled queued issue', preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')), @@ -144,7 +146,9 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat outbox_id: item.id, kind: 'authored-outbox', status: item.status === 'attention' ? 'attention' : 'queued', - label: item.status === 'attention' ? 'Needs attention' : 'Queued message', + label: item.deliveryState === 'uncertain' ? 'Verify delivery' : + (item.status === 'attention' ? 'Needs attention' : 'Queued message'), + delivery_state: item.deliveryState, repository: isUpdate ? '' : item.repository, title: target, preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')), diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 1b4a026..35aab89 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -89,6 +89,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (nextMilestoneId === undefined) delete updated.milestoneId; if (nextDueDate === undefined) delete updated.dueDate; delete updated.error; + delete updated.deliveryState; return updated; }), mirror); return updated; @@ -152,6 +153,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge 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), + ...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}), } : candidate)); } return { error, transient: !(status >= 400 && status < 500) }; @@ -188,8 +190,18 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (!currentLogin || item.ownerLogin !== currentLogin) { return { confirmed: [], remaining: read(), blocked: 1 }; } - update(id, item); - const result = await sendItem({ ...item, status: 'queued' }, currentLogin); + let queued = item; + if (item.status === 'attention') { + queued = { + ...item, + operationId: String(operationId()).slice(0, 128), + status: 'queued', + }; + delete queued.error; + delete queued.deliveryState; + write(read().map(candidate => candidate.id === id ? queued : candidate)); + } + const result = await sendItem(queued, currentLogin); return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 }; } @@ -205,6 +217,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (background?.status === 'sent') return []; if (background?.status === 'attention') return [{ ...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240), + ...(background.deliveryState ? { deliveryState: background.deliveryState } : {}), }]; return [item]; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 22eb532..376ef88 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v33'; +const CACHE = 'stackchain-dashboard-shell-v34'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -86,8 +86,9 @@ async function fetchJson(url, options = {}) { const response = await fetch(new URL(url, self.location.origin), requestOptions); const payload = await response.json().catch(() => ({})); if (!response.ok) { - const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.'); + const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.'); error.status = response.status; + error.code = payload.detail?.code; throw error; } return payload; diff --git a/src/idempotency.py b/src/idempotency.py index 7ee5946..cac35c6 100644 --- a/src/idempotency.py +++ b/src/idempotency.py @@ -78,7 +78,7 @@ class IdempotencyLedger: (now - self.ttl_seconds,), ) row = connection.execute( - "SELECT fingerprint, status, response_json FROM idempotency_operations " + "SELECT fingerprint, status, response_json, created_at FROM idempotency_operations " "WHERE key = ?", (key,), ).fetchone() @@ -87,9 +87,13 @@ class IdempotencyLedger: return Reservation("conflict") if row[1] == "completed": return Reservation("completed", json.loads(row[2])) + if row[3] <= now - self.ttl_seconds: + return Reservation("uncertain") return Reservation("pending") count = connection.execute( - "SELECT COUNT(*) FROM idempotency_operations" + "SELECT COUNT(*) FROM idempotency_operations " + "WHERE status = 'completed' OR created_at > ?", + (now - self.ttl_seconds,), ).fetchone()[0] if count >= self.max_entries: completed = connection.execute( diff --git a/src/main.py b/src/main.py index d89bc1a..1243375 100644 --- a/src/main.py +++ b/src/main.py @@ -314,9 +314,22 @@ async def _run_idempotent_authored_action( detail="Authored action queue is busy; please retry", headers={"Retry-After": "1"}, ) - existing = _authored_action_operations.get(idempotency_key) - if reservation.state == "pending": + if reservation.state == "uncertain" and ( + existing is None or existing[0] != fingerprint + ): + operation.close() + raise HTTPException( + status_code=422, + detail={ + "code": "delivery_uncertain", + "message": ( + "Delivery could not be confirmed. Verify it was not posted before retrying." + ), + }, + ) + + if reservation.state in {"pending", "uncertain"}: operation.close() if existing is None or existing[0] != fingerprint: raise HTTPException( diff --git a/tests/test_authored_idempotency.py b/tests/test_authored_idempotency.py index 8d6ebd3..de27bd8 100644 --- a/tests/test_authored_idempotency.py +++ b/tests/test_authored_idempotency.py @@ -116,6 +116,54 @@ async def test_notification_reply_retry_recovers_after_caller_timeout(monkeypatc assert calls == 1 +@pytest.mark.anyio +async def test_live_authored_task_remains_joinable_after_reservation_age_threshold( + monkeypatch, tmp_path +): + calls = 0 + now = [1_000.0] + release = asyncio.Event() + + async def reply(_thread_id, body): + nonlocal calls + calls += 1 + await release.wait() + return {"id": 319, "body": body} + + monkeypatch.setattr( + main, + "_idempotency_ledger", + IdempotencyLedger( + tmp_path / "live.sqlite3", + ttl_seconds=1, + max_entries=256, + clock=lambda: now[0], + ), + ) + monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01) + monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply) + transport = httpx.ASGITransport(app=main.app) + headers = {"Idempotency-Key": "live-after-threshold-319"} + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + timed_out = await client.post( + "/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers, + ) + now[0] += 2 + monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 1.0) + joined = asyncio.create_task(client.post( + "/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers, + )) + await asyncio.sleep(0.02) + assert not joined.done(), "a live local operation must be joined instead of rejected as stale" + release.set() + recovered = await joined + + assert timed_out.status_code == 503 + assert recovered.status_code == 201 + assert calls == 1 + + @pytest.mark.anyio async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch): calls = [] @@ -233,6 +281,51 @@ async def test_orphaned_pending_comment_fails_closed_without_upstream_retry( assert calls == 0 +@pytest.mark.anyio +async def test_stale_orphaned_comment_returns_non_retryable_uncertain_delivery( + monkeypatch, tmp_path +): + calls = 0 + now = [1_000.0] + + async def assigned(_repository, _number): + return True + + async def comment(_repository, _number, _body): + nonlocal calls + calls += 1 + return {"id": 319} + + ledger = IdempotencyLedger( + tmp_path / "stale-orphan.sqlite3", + ttl_seconds=10, + max_entries=256, + clock=lambda: now[0], + ) + fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first") + assert ledger.reserve("stale-orphan-319", fingerprint).state == "reserved" + now[0] += 11 + monkeypatch.setattr(main, "_idempotency_ledger", ledger) + monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) + monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment) + transport = httpx.ASGITransport(app=main.app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/repos/stackchain/api/issues/7/comments", + json={"body": "Verify first"}, + headers={"Idempotency-Key": "stale-orphan-319"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == { + "code": "delivery_uncertain", + "message": "Delivery could not be confirmed. Verify it was not posted before retrying.", + } + assert "Retry-After" not in response.headers + assert calls == 0 + + @pytest.mark.anyio async def test_slow_ledger_reservation_does_not_block_health_requests(monkeypatch): async def assigned(_repository, _number): diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index c430bb3..6b825e5 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -113,6 +113,37 @@ outbox.flush('timmy').then(async first => {{ assert output["remaining"][1]["status"] == "queued" +def test_authored_outbox_requires_deliberate_rekey_after_uncertain_delivery(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); let sequence=0; const calls=[]; +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox = createAuthoredOutbox({{ + storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'verified-' + (++sequence), + fetchJson:async (_url, options) => {{ + const key=options.headers['Idempotency-Key']; calls.push(key); + if (key === 'uncertain-key') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }} + return {{id:calls.length}}; + }}, +}}); +const uncertain=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Check',operationId:'uncertain-key'}}); +outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Continue',operationId:'other-key'}}); +(async()=>{{ + const first=await outbox.flush('timmy'); + const attention=outbox.list()[0]; + const retried=await outbox.retry(uncertain.id,'timmy'); + process.stdout.write(JSON.stringify({{first,attention,retried,calls,remaining:outbox.list()}})); +}})(); +""" + output = run_node(script) + + assert output["calls"] == ["uncertain-key", "other-key", "verified-1"] + assert len(output["first"]["confirmed"]) == 1 + assert output["attention"]["status"] == "attention" + assert output["attention"]["deliveryState"] == "uncertain" + assert output["remaining"] == [] + + def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity(): script = f""" const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); @@ -182,6 +213,21 @@ setTimeout(() => {{ ] +def test_authored_outbox_reconciles_uncertain_worker_delivery_state(): + 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,getOwnerLogin:()=>'timmy'}}); +const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:7,body:'Maybe',operationId:'maybe'}}); +const reconciled=outbox.reconcileBackground([{{...item,status:'attention',deliveryState:'uncertain',error:'Verify first'}}]); +process.stdout.write(JSON.stringify(reconciled[0])); +""" + output = run_node(script) + + assert output["status"] == "attention" + assert output["deliveryState"] == "uncertain" + + def test_authored_outbox_waits_for_durable_background_admission(): script = f""" const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 5fb3e16..ef08f22 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -585,6 +585,31 @@ const fetchJson = async url => {{ assert output["result"]["attention"] == 1 +def test_uncertain_background_delivery_preserves_verification_state(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let claimed=false; const state={{failed:[]}}; +const store={{ + claimNext:async()=>claimed?null:(claimed=true,{{id:'maybe',operationId:'maybe',ownerLogin:'timmy',repository:'o/r',title:'Maybe',labelIds:[]}}), + fail:async(id,message,deliveryState)=>state.failed.push({{id,message,deliveryState}}), + release:async()=>{{}}, countBlocked:async()=>0, +}}; +const fetchJson=async url=>{{ + if(url==='api/v1/background-identity') return {{login:'timmy'}}; + const error=new Error('Verify first'); error.status=422; error.code='delivery_uncertain'; throw error; +}}; +(async()=>{{ + await createBackgroundIssueSync({{store,fetchJson}}).flush(); + process.stdout.write(JSON.stringify(state)); +}})(); +""" + output = run_node(script) + + assert output["failed"] == [ + {"id": "maybe", "message": "Verify first", "deliveryState": "uncertain"} + ] + + def test_issue_store_atomically_grants_one_delivery_claim(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 4552295..e46c2aa 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -121,6 +121,22 @@ process.stdout.write(JSON.stringify(drafts)); assert output[1]["label"] == "Queued issue" +def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification(): + script = f""" +const createDraftInbox = require({json.dumps(str(DRAFTS))}); +const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[ + {{id:'uncertain',kind:'issue-comment',repository:'o/r',number:7,body:'Possibly posted',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',error:'Verify it was not posted before retrying.',queuedAt:200}} +]}})]]); +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 drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list(); +process.stdout.write(JSON.stringify(drafts[0])); +""" + output = run_node(script) + + assert output["delivery_state"] == "uncertain" + assert output["label"] == "Verify delivery" + + def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only(): script = f""" const createDraftInbox = require({json.dumps(str(DRAFTS))}); @@ -160,6 +176,9 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): assert '.draft-actions button { min-height:44px;' in html assert 'createDraftInbox({ storage: localStorage' in html assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html + assert "Verified not posted — retry" in html + assert "payload.detail?.message" in html + assert "error.code = payload.detail?.code" in html @pytest.mark.anyio diff --git a/tests/test_idempotency_ledger.py b/tests/test_idempotency_ledger.py index 0763e05..62589c8 100644 --- a/tests/test_idempotency_ledger.py +++ b/tests/test_idempotency_ledger.py @@ -71,3 +71,21 @@ def test_independent_connections_atomically_reserve_one_operation(tmp_path): states = list(executor.map(reserve, ledgers)) assert sorted(states) == ["pending", "reserved"] + + +def test_stale_pending_reservation_becomes_uncertain_without_consuming_capacity(tmp_path): + now = [1_000.0] + ledger = IdempotencyLedger( + tmp_path / "orphaned.sqlite3", + ttl_seconds=10, + max_entries=1, + clock=lambda: now[0], + ) + fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first") + + assert ledger.reserve("orphan", fingerprint).state == "reserved" + now[0] += 11 + + assert ledger.reserve("orphan", fingerprint).state == "uncertain" + assert ledger.reserve("unrelated", ("issue-comment", "stackchain/api", 8, "Continue")).state == "reserved" + assert ledger.reserve("orphan", ("issue-comment", "stackchain/api", 7, "Changed")).state == "conflict" diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 41c72dc..8736ccb 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -139,6 +139,34 @@ outbox.flush('timmy').then(async () => {{ assert output["remaining"] == [] +def test_issue_outbox_rekeys_only_after_user_retries_uncertain_delivery(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); let sequence=0; const calls=[]; +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox = createIssueOutbox({{ + storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'operation-' + (++sequence), + fetchJson:async (_url, options) => {{ + const key=options.headers['Idempotency-Key']; calls.push(key); + if (key === 'operation-1') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }} + return {{number:319}}; + }}, +}}); +const item=outbox.enqueue({{repository:'o/r',title:'Possibly sent',body:'Check first'}}); +(async()=>{{ + await outbox.flush('timmy'); const attention=outbox.list()[0]; + await outbox.retry(item.id,'timmy'); + process.stdout.write(JSON.stringify({{attention,calls,remaining:outbox.list()}})); +}})(); +""" + output = run_node(script) + + assert output["attention"]["status"] == "attention" + assert output["attention"]["deliveryState"] == "uncertain" + assert output["calls"] == ["operation-1", "operation-2"] + assert output["remaining"] == [] + + def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index bf04c41..ac7287f 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v33" in source + assert "stackchain-dashboard-shell-v34" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -106,7 +106,7 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v33" in source + assert "stackchain-dashboard-shell-v34" in source assert "BASE + 'static/mobile-search-viewport.js'" in source @@ -122,6 +122,23 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag( assert result["backgroundFlushes"] == 1 +def test_background_delivery_preserves_structured_uncertain_error(): + result = run_worker_scenario( + """ + context.fetch=async()=>new Response(JSON.stringify({detail:{code:'delivery_uncertain',message:'Verify it was not posted before retrying.'}}),{status:422,headers:{'Content-Type':'application/json'}}); + const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'}) + .then(()=>({ok:true}),error=>({status:error.status,code:error.code,message:error.message})); + process.stdout.write(JSON.stringify(outcome)); +""" + ) + + assert result == { + "status": 422, + "code": "delivery_uncertain", + "message": "Verify it was not posted before retrying.", + } + + def test_authenticated_page_message_resumes_queued_background_delivery(): result = run_worker_scenario( """ -- 2.43.0