From b8befc2041622af69128b022d58875eeca39cd34 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 07:54:12 +0000 Subject: [PATCH] fix: make Draft admission retry-safe (Closes #631) --- frontend/dashboard.js | 1 + frontend/issue-outbox.js | 15 ++++++++++ tests/test_issue_outbox.py | 52 ++++++++++++++++++++++++++++++++++ tests/test_unfiled_captures.py | 1 + 4 files changed, 69 insertions(+) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 894be15..9c9c3c1 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4220,6 +4220,7 @@ const durableDraft = { ...captureDraft, attachment: await createIssueAttachmentController.serialize(), + ...(rUC ? { sourceCaptureId: rUC } : {}), ...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}), }; const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) : diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 9f09cdd..54782fb 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -51,6 +51,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge status: 'queued', queuedAt: Number(now()), }; + const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128); + if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId; if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start'; const attachment = captureAttachment(draft?.attachment); if (attachment) item.attachment = attachment; @@ -76,6 +78,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge function enqueue(draft, mirror = true) { const items = read(); + const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128); + const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId); + if (existing) return { ...existing }; const item = prepareItem(draft); items.push(item); write(items, mirror); @@ -83,6 +88,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge } async function enqueueDurably(draft) { + const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128); + const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId); + if (existing) { + if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { + return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true }; + } + await backgroundSync.reconcile(read()); + await backgroundSync.requestSync(); + return { item: { ...existing }, background: true, durability: 'background', reused: true }; + } if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { const item = enqueue(draft, false); return { item, background: false, durability: 'foreground-only' }; diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 7678a33..8787e44 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -744,3 +744,55 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio assert 'Needs attention' in html assert "issueOutbox.discard(item.outbox_id)" in html assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html + + + +def test_issue_outbox_reuses_the_original_operation_when_a_source_draft_is_retried(): + 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 sequence = 0; +const outbox = createIssueOutbox({{ + storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'draft-op-' + (++sequence), +}}); +const first = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}}); +const retried = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}}); +process.stdout.write(JSON.stringify({{first,retried,items:outbox.list(),sequence}})); +""" + output = run_node(script) + + assert output["sequence"] == 1 + assert len(output["items"]) == 1 + assert output["first"]["id"] == output["retried"]["id"] == "draft-op-1" + assert output["items"][0]["operationId"] == "draft-op-1" + assert output["items"][0]["sourceCaptureId"] == "capture-7" + + +def test_durable_source_draft_retry_reconciles_without_allocating_another_operation(): + 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 sequence = 0; const reconciliations = []; let syncRequests = 0; +const outbox = createIssueOutbox({{ + storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-' + (++sequence), + backgroundSync:{{ + reconcile:async items=>reconciliations.push(items.map(item=>item.operationId)), + requestSync:async()=>{{ syncRequests += 1; }}, + }}, +}}); +(async()=>{{ + const draft={{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-8'}}; + const first=await outbox.enqueueDurably(draft); + const retried=await outbox.enqueueDurably(draft); + process.stdout.write(JSON.stringify({{first,retried,reconciliations,syncRequests,items:outbox.list()}})); +}})(); +""" + output = run_node(script) + + assert "reused" not in output["first"] + assert output["retried"]["reused"] is True + assert output["reconciliations"] == [["durable-1"], ["durable-1"]] + assert output["syncRequests"] == 2 + assert len(output["items"]) == 1 diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index 8e8d8b7..0763660 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -363,6 +363,7 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume assert "createUnfiledAttachmentStore()" in html assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text() assert "await unfiledCaptures.completeResume(rUC)" in html + assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html assert "item.hasAttachment ? ' ยท Screenshot attached' : ''" in html assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text() assert "item.kind === 'unfiled-issue'" in html -- 2.43.0