fix: make Draft admission retry-safe (Closes #631)
This commit is contained in:
parent
d23c0c894b
commit
b8befc2041
|
|
@ -4220,6 +4220,7 @@
|
||||||
const durableDraft = {
|
const durableDraft = {
|
||||||
...captureDraft,
|
...captureDraft,
|
||||||
attachment: await createIssueAttachmentController.serialize(),
|
attachment: await createIssueAttachmentController.serialize(),
|
||||||
|
...(rUC ? { sourceCaptureId: rUC } : {}),
|
||||||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
||||||
};
|
};
|
||||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
queuedAt: Number(now()),
|
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';
|
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||||
const attachment = captureAttachment(draft?.attachment);
|
const attachment = captureAttachment(draft?.attachment);
|
||||||
if (attachment) item.attachment = attachment;
|
if (attachment) item.attachment = attachment;
|
||||||
|
|
@ -76,6 +78,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
|
|
||||||
function enqueue(draft, mirror = true) {
|
function enqueue(draft, mirror = true) {
|
||||||
const items = read();
|
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);
|
const item = prepareItem(draft);
|
||||||
items.push(item);
|
items.push(item);
|
||||||
write(items, mirror);
|
write(items, mirror);
|
||||||
|
|
@ -83,6 +88,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enqueueDurably(draft) {
|
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) {
|
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||||
const item = enqueue(draft, false);
|
const item = enqueue(draft, false);
|
||||||
return { item, background: false, durability: 'foreground-only' };
|
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 'Needs attention' in html
|
||||||
assert "issueOutbox.discard(item.outbox_id)" 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
|
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 "createUnfiledAttachmentStore()" in html
|
||||||
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
|
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
|
||||||
assert "await unfiledCaptures.completeResume(rUC)" in html
|
assert "await unfiledCaptures.completeResume(rUC)" in html
|
||||||
|
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
|
||||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||||
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
||||||
assert "item.kind === 'unfiled-issue'" in html
|
assert "item.kind === 'unfiled-issue'" in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user