diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 73c36b6..862c7d9 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -35,6 +35,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted')); Promise.resolve(work({ + get: id => requested(objectStore.get(id)), getAll: () => requested(objectStore.getAll()), put: value => requested(objectStore.put(value)), delete: id => requested(objectStore.delete(id)), @@ -187,6 +188,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n } return { + get: id => transact(records => records.get(id)), reconcile, upsert, update, @@ -529,6 +531,7 @@ function createBackgroundIssueSync({ return { flush, send, purge, resume, + get: id => store.get(id), reconcile: (items, outboxLane) => store.reconcile(items, outboxLane), snapshot: () => store.snapshot(), setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled), diff --git a/frontend/dashboard.js b/frontend/dashboard.js index eef37ae..c632083 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1710,16 +1710,26 @@ }); }); list.querySelectorAll('.draft-edit').forEach(button => { - button.addEventListener('click', () => { + button.addEventListener('click', async () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id); if (!queued) return; - editingOutboxId = queued.id; - issueCapture.saveDraft(queued); - openCreateIssueSheet(); - if (queued.attachment) createIssueAttachmentController.restore(queued.attachment); - else createIssueAttachmentController.clear(); - qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; + button.disabled = true; + try { + const hydrated = await issueOutbox.hydrateForEdit(queued.id); + if (!hydrated) return; + editingOutboxId = hydrated.id; + issueCapture.saveDraft(hydrated); + openCreateIssueSheet(); + if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment); + else createIssueAttachmentController.clear(); + qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; + } catch (error) { + qs('#my-work-action-status').textContent = String(error?.message || + 'The saved screenshot could not be loaded. Retry before editing this issue.'); + } finally { + button.disabled = false; + } }); }); list.querySelectorAll('.draft-send').forEach(button => { diff --git a/frontend/issue-attachment.js b/frontend/issue-attachment.js index 2837edd..0aa3ea7 100644 --- a/frontend/issue-attachment.js +++ b/frontend/issue-attachment.js @@ -47,6 +47,10 @@ const contentType = String(value?.contentType || ''); const filename = String(value?.filename || ''); const data = String(value?.data || ''); + if (!data) { + clear(); + throw new Error('The saved screenshot is unavailable. Retry before editing this issue.'); + } const padding = (data.match(/=*$/) || [''])[0].length; const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding); select({ name: filename, type: contentType, size }); diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 4d1cb8c..daa7989 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -100,6 +100,21 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge } } + async function hydrateForEdit(id) { + const item = read().find(candidate => candidate.id === id); + if (!item) return null; + if (!item.attachment?.stored || item.attachment.data) return { ...item }; + if (!backgroundSync?.get) { + throw new Error('The saved screenshot is unavailable. Retry before editing this issue.'); + } + const durable = await backgroundSync.get(id); + const attachment = captureAttachment(durable?.attachment); + if (!attachment?.data || durable?.operationId !== item.operationId) { + throw new Error('The saved screenshot is unavailable. Retry before editing this issue.'); + } + return { ...item, attachment }; + } + function prepareUpdate(id, draft) { let updated = null; const items = read().map(item => { @@ -388,7 +403,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge } return { - enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, + enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground, pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })), }; } diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 215071d..b5a73de 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -383,6 +383,31 @@ const transaction=work=>{{const run=tail.then(()=>work({{ assert output[1]["attachment"]["data"] == "second-image-bytes" +def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const records=new Map([ + ['first',{{id:'first',attachment:{{filename:'one.png',contentType:'image/png',data:'first-bytes'}}}}], + ['second',{{id:'second',attachment:{{filename:'two.png',contentType:'image/png',data:'second-bytes'}}}}], +]); +const calls=[]; +const transaction=work=>work({{ + get:async id=>{{calls.push(['get',id]);return records.get(id);}}, + getAll:async()=>{{calls.push(['getAll']);return [...records.values()];}}, + put:async()=>{{}},delete:async()=>{{}}, +}}); +(async()=>{{ + const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}}); + const hydrated=await store.get('second'); + process.stdout.write(JSON.stringify({{hydrated,calls}})); +}})(); +""" + output = run_node(script) + + assert output["hydrated"]["attachment"]["data"] == "second-bytes" + assert output["calls"] == [["get", "second"]] + + def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages(): script = f""" const createBackgroundIssueSync=require({json.dumps(str(SYNC))}); diff --git a/tests/test_issue_attachment_ui.py b/tests/test_issue_attachment_ui.py index b28bbc4..f92c97f 100644 --- a/tests/test_issue_attachment_ui.py +++ b/tests/test_issue_attachment_ui.py @@ -119,6 +119,24 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K assert output["removed"] is None +def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot(): + script = f""" +const attachment=require({json.dumps(str(ATTACHMENT))}); +const controller=attachment.create({{readDataUrl:async()=>'',upload:async()=>{{}}}}); +try {{ + controller.restore({{filename:'saved.png',contentType:'image/png',stored:true}}); + process.stdout.write(JSON.stringify({{restored:true,state:controller.state()}})); +}} catch (error) {{ + process.stdout.write(JSON.stringify({{restored:false,message:error.message,state:controller.state()}})); +}} +""" + output = json.loads(run_node(script)) + + assert output["restored"] is False + assert "saved screenshot" in output["message"].lower() + assert output["state"] is None + + def test_issue_composer_renders_thumb_reachable_screenshot_preview(): html = INDEX.read_text() css = CSS.read_text() @@ -145,12 +163,29 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox(): assert 'id="remove-create-issue-attachment"' in html assert "const createIssueAttachmentController = issueAttachment.mount({" in source assert "attachment: await createIssueAttachmentController.serialize()" in source - assert "createIssueAttachmentController.restore(queued.attachment);" in source + assert "createIssueAttachmentController.restore(hydrated.attachment);" in source assert "createIssueAttachmentController.clear();" in source assert ".create-issue-attachment" in css assert "overflow-x:hidden" in css +def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable(): + source = DASHBOARD.read_text() + handler = re.search( + r"list\.querySelectorAll\('\.draft-edit'\).*?addEventListener\('click', async \(\) => \{(?P.*?)\n \}\);", + source, + re.DOTALL, + ) + assert handler is not None + body = handler.group("body") + assert "await issueOutbox.hydrateForEdit(queued.id)" in body + assert body.index("await issueOutbox.hydrateForEdit(queued.id)") < body.index( + "openCreateIssueSheet()" + ) + assert "catch (error)" in body + assert "saved screenshot" in body.lower() + + def test_attachment_view_keeps_invalid_draft_and_removes_preview(): script = f""" const attachment = require({json.dumps(str(ATTACHMENT))}); diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index c9cec38..0a15964 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -94,6 +94,31 @@ process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0])); } +def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage(): + 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)}}; +values.set('stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{ + id:'capture-1',operationId:'capture-1',repository:'o/r',title:'Before',body:'', + ownerLogin:'timmy',status:'queued',attachment:{{filename:'phone.png',contentType:'image/png',stored:true}}, +}}]}})); +const gets=[]; +const outbox=createIssueOutbox({{storage,backgroundSync:{{ + get:async id=>{{gets.push(id);return {{id,operationId:'capture-1',attachment:{{filename:'phone.png',contentType:'image/png',data:'durable-image-bytes'}}}};}}, +}}}}); +(async()=>{{ + const hydrated=await outbox.hydrateForEdit('capture-1'); + process.stdout.write(JSON.stringify({{hydrated,gets,stored:values.get('stackchain.issue-outbox.v1')}})); +}})(); +""" + output = run_node(script) + + assert output["hydrated"]["attachment"]["data"] == "durable-image-bytes" + assert output["gets"] == ["capture-1"] + assert "durable-image-bytes" not in output["stored"] + + def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))});