Merge pull request 'Make Draft filing admission atomic and retry-safe' (#632) from timmy/631-atomic-draft-admission into main
This commit is contained in:
commit
28a2c9faf1
|
|
@ -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) :
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user