From 62a0841eb5821c12ed29a435556aaa0290db6342 Mon Sep 17 00:00:00 2001
From: timmy
Date: Mon, 10 Aug 2026 12:29:02 +0000
Subject: [PATCH] fix: authorize queued issue closures explicitly (Closes #483)
---
frontend/authored-outbox.js | 2 +-
frontend/background-issue-sync.js | 2 +
frontend/dashboard.js | 25 ++++++++--
frontend/drafts.js | 13 ++++--
frontend/service-worker.js | 2 +-
tests/test_authored_outbox.py | 57 +++++++++++++++++++++--
tests/test_background_issue_sync.py | 54 ++++++++++++---------
tests/test_comment_next.py | 2 +-
tests/test_drafts.py | 43 ++++++++++++-----
tests/test_later_sync.py | 2 +-
tests/test_markdown_renderer.py | 2 +-
tests/test_mobile_composer_integration.py | 2 +-
tests/test_plan_today.py | 2 +-
tests/test_service_worker.py | 20 ++++----
tests/test_today_readiness.py | 2 +-
tests/test_today_sync.py | 2 +-
16 files changed, 165 insertions(+), 67 deletions(-)
diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js
index 7f38cd4..65ed834 100644
--- a/frontend/authored-outbox.js
+++ b/frontend/authored-outbox.js
@@ -241,7 +241,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
- if (item.status === 'attention') continue;
+ if (item.status === 'attention' || item.kind === 'issue-close') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const outcome = await sendItem(item, currentLogin);
if (outcome.result) confirmed.push(outcome.result);
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 5b8e09b..039ce66 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -97,6 +97,7 @@ function createIssueSyncStore({
const timestamp = Number(now());
const items = await records.getAll();
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
+ candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
if (!item) return null;
@@ -114,6 +115,7 @@ function createIssueSyncStore({
const eligible = (await records.getAll()).filter(candidate =>
candidate.recordType !== 'receipt-preference' &&
candidate.ownerLogin === ownerLogin &&
+ candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
const lanes = {
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index dba01e9..9a65365 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1662,9 +1662,12 @@
'' +
'' +
'' :
+ item.kind === 'authored-outbox' && closureOutbox ?
+ '' +
+ '' +
+ '' :
item.kind === 'authored-outbox' && !reviewOutbox ?
- '' +
+ '' +
'' +
'' :
reviewOutbox ?
@@ -1676,7 +1679,8 @@
const state = (isOutbox || isUnfiled) ?
'' + (item.quarantined ? 'Identity protected' :
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
- item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' : 'Queued for sync')) + '' +
+ item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' :
+ item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '' +
(item.ownership ? '' + escapeHtml(item.ownership) + '
' : '') : '';
const attempt = item.last_attempt_error ? 'Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '' : '';
@@ -1691,7 +1695,8 @@
'Delivery center
' +
'
Waiting ' + deliveryCenter.counts.waiting + ' · ' +
'Sending ' + deliveryCenter.counts.sending + ' · ' +
- 'Needs attention ' + deliveryCenter.counts.attention + '
' +
+ 'Needs attention ' + deliveryCenter.counts.attention + ' · ' +
+ 'Authorize ' + deliveryCenter.counts.authorization + '
' +
'';
const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') :
@@ -1769,6 +1774,18 @@
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
});
});
+ list.querySelectorAll('.draft-authorize').forEach(button => {
+ button.addEventListener('click', async () => {
+ const item = lastDrafts[Number(button.dataset.draftIndex)];
+ if (!item?.outbox_id || !activeFlushLogin) return;
+ button.disabled = true;
+ qs('#my-work-action-status').textContent = 'Fresh authorization required for this exact issue.';
+ const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
+ applyAuthoredOutboxResult(result);
+ qs('#my-work-action-status').textContent = result.confirmed?.length ?
+ 'Issue closed and queued intent cleared.' : 'Issue closure was not confirmed. The queued intent is still safe.';
+ });
+ });
list.querySelectorAll('.draft-copy').forEach(button => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
diff --git a/frontend/drafts.js b/frontend/drafts.js
index a84bd8c..643512e 100644
--- a/frontend/drafts.js
+++ b/frontend/drafts.js
@@ -160,10 +160,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
outbox_id: item.id,
outbox_kind: item.kind,
kind: 'authored-outbox',
- status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued'),
+ status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
+ (isClosure ? 'authorization' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
- (isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
+ (isReview ? 'Queued review' : (isClosure ? 'Awaiting authorization' : 'Queued message'))),
+ authorization_required: isClosure,
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
@@ -237,12 +239,13 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
const counts = deliveries.reduce((summary, item) => {
- const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' : 'waiting');
+ const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
+ (item.status === 'authorization' ? 'authorization' : 'waiting'));
summary[state] += 1;
return summary;
- }, { waiting:0, sending:0, attention:0 });
+ }, { waiting:0, sending:0, attention:0, authorization:0 });
const retryable = deliveries.filter(item =>
- item.status === 'queued' && !item.quarantined && item.delivery_state !== 'uncertain'
+ item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
);
return { drafts, deliveries, counts, retryable };
}
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index e64eeb9..b4c4ae7 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v81';
+const CACHE = 'stackchain-dashboard-shell-v82';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py
index d9f780e..8ac2581 100644
--- a/tests/test_authored_outbox.py
+++ b/tests/test_authored_outbox.py
@@ -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))});
diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py
index 5ccbd6b..db7d51f 100644
--- a/tests/test_background_issue_sync.py
+++ b/tests/test_background_issue_sync.py
@@ -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():
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index ddcbf5d..cea3bac 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -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
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
index 950bd9e..8d8f744 100644
--- a/tests/test_drafts.py
+++ b/tests/test_drafts.py
@@ -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 ' in html
assert 'Sending ' in html
assert 'Needs attention ' in html
+ assert 'Authorize ' 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 & close' 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()
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index ea2b107..9195cc1 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -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
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 56348f9..c9952c1 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -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
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index e369697..84866eb 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -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():
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index ca6294f..a4a738c 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -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
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 09aa29e..d9958f8 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -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
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index d904cdd..13d56d6 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -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
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 106c78d..432643c 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -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