fix: authorize queued issue closures explicitly (Closes #483)
CI / lint (pull_request) Successful in 1m1s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 12:29:02 +00:00
1 parent cc63fc6499
commit 62a0841eb5
16 files changed
+165 -67

No files matched your search

+52 -5
View File
@@ -150,7 +150,7 @@ if (queued) {{
assert output["progress"] is None
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
def test_authored_outbox_requires_explicit_retry_to_authorize_issue_closure():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
@@ -162,13 +162,20 @@ const outbox = createAuthoredOutbox({{
const queued = outbox.enqueue({{
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued,calls,result,remaining:outbox.list()
}})));
(async()=>{{
const automatic = await outbox.flush('timmy');
const awaitingAuthorization = outbox.list();
const explicit = await outbox.retry(queued.id, 'timmy');
process.stdout.write(JSON.stringify({{
queued,automatic,awaitingAuthorization,explicit,calls,remaining:outbox.list()
}}));
}})();
"""
output = run_node(script)
assert output["queued"]["kind"] == "issue-close"
assert output["automatic"]["confirmed"] == []
assert output["awaitingAuthorization"][0]["status"] == "queued"
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
"options": {
@@ -176,10 +183,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
},
}]
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["remaining"] == []
def test_queued_closure_stays_durable_through_background_skip_then_closes_from_explicit_retry():
sync = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
script = f"""
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
const createBackgroundIssueSync=require({json.dumps(str(sync))});
const values=new Map();const records=new Map();let tail=Promise.resolve();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const transaction=work=>{{const run=tail.then(()=>work({{
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
const fetchJson=async(url,options={{}})=>{{
calls.push({{url,key:options.headers?.['Idempotency-Key'] || ''}});
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};
}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
const background=createBackgroundIssueSync({{store,fetchJson}});background.requestSync=async()=>{{}};
const outbox=createAuthoredOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',backgroundSync:background}});
const admitted=await outbox.enqueueDurably({{kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'}});
const automatic=await background.flush();
outbox.reconcileBackground(await background.snapshot());
const foregroundFlush=await outbox.flush('timmy');
const explicit=await outbox.retry(admitted.item.id,'timmy');
process.stdout.write(JSON.stringify({{automatic,foregroundFlush,explicit,calls,remaining:outbox.list(),snapshot:await background.snapshot()}}));
}})();
"""
output = run_node(script)
assert output["automatic"]["confirmed"] == []
assert output["foregroundFlush"]["confirmed"] == []
assert output["calls"] == [
{"url": "api/v1/background-identity", "key": ""},
{"url": "api/v1/repos/stackchain/dashboard/issues/27/close", "key": "close-op"},
]
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["remaining"] == []
assert output["snapshot"][0]["status"] == "sent"
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
+32 -22
View File
@@ -372,34 +372,44 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
}]
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
authored = {
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
}
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
records = [
{
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
},
{
"id": "comment-op", "operationId": "comment-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-comment", "repository": "stackchain/dashboard", "number": 27, "body": "Still deliver",
},
]
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)}; const calls=[];
const store = {{
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
const records=new Map({json.dumps([[item["id"], item] for item in records])});let tail=Promise.resolve();const calls=[];
const transaction=work=>{{const run=tail.then(()=>work({{
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
const fetchJson=async(url,options={{}})=>{{
calls.push({{url,options}});
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};
}};
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{calls,result,snapshot:await store.snapshot()}}));
}})();
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
assert mutation["options"]["method"] == "PATCH"
assert mutation["options"]["headers"] == {
"Accept": "application/json", "Idempotency-Key": "close-op"
}
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["result"]["receipts"] == [{
"id": "close-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/issue/stackchain/dashboard/27",
}]
mutations = output["calls"][1:]
assert [call["url"] for call in mutations] == [
"api/v1/repos/stackchain/dashboard/issues/27/comments"
]
assert output["result"]["confirmed"] == [{"id": 45}]
by_id = {item["id"]: item for item in output["snapshot"]}
assert by_id["close-op"]["status"] == "queued"
assert by_id["comment-op"]["status"] == "sent"
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
+1 -1
View File
@@ -305,4 +305,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v81" in worker
assert "stackchain-dashboard-shell-v82" in worker
+31 -12
View File
@@ -142,25 +142,32 @@ process.stdout.write(JSON.stringify(item));
assert output["quarantined"] is False
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200
ownerLogin:'timmy',status:'queued',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 item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
const item=inbox.list()[0];
process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
"""
output = run_node(script)
item = output["item"]
assert output["label"] == "Issue closure needs attention"
assert output["outbox_kind"] == "issue-close"
assert output["title"] == "stackchain/dashboard#27"
assert output["preview"] == "Closure rejected"
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
assert output["quarantined"] is False
assert item["label"] == "Awaiting authorization"
assert item["status"] == "authorization"
assert item["authorization_required"] is True
assert item["outbox_kind"] == "issue-close"
assert item["title"] == "stackchain/dashboard#27"
assert item["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
assert item["quarantined"] is False
assert output["partition"]["counts"] == {
"waiting": 0, "sending": 0, "attention": 0, "authorization": 1
}
assert output["partition"]["retryable"] == []
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
@@ -248,7 +255,7 @@ process.stdout.write(JSON.stringify(inbox.partition()));
assert len(output["drafts"]) == 1
assert len(output["deliveries"]) == 5
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2}
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
assert waiting["last_attempt_at"] == 90
@@ -270,7 +277,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
assert "item.kind === 'authored-outbox' && closureOutbox" in html
assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" in html
@@ -284,6 +291,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert 'Waiting <strong data-delivery-count="waiting">' in html
assert 'Sending <strong data-delivery-count="sending">' in html
assert 'Needs attention <strong data-delivery-count="attention">' in html
assert 'Authorize <strong data-delivery-count="authorization">' in html
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
assert "deliveryCenter.retryable.length" in html
@@ -293,6 +301,17 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert '.delivery-center button { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture():
html = await dashboard()
assert 'class="draft-authorize"' in html
assert '>Authorize &amp; close</button>' in html
assert "list.querySelectorAll('.draft-authorize')" in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
@pytest.mark.anyio
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
html = await dashboard()
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v81" in worker
assert "stackchain-dashboard-shell-v82" in worker
+1 -1
View File
@@ -35,7 +35,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v81" in worker
assert "stackchain-dashboard-shell-v82" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+10 -10
View File
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -385,7 +385,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/queue-today.js'" in source
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v81';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v82';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "stackchain-dashboard-shell-v82" in source
assert "BASE + 'static/today-sync.js'" in source