diff --git a/README.md b/README.md index f64be36..db86887 100644 --- a/README.md +++ b/README.md @@ -859,7 +859,10 @@ Authenticated release producers use `POST /api/v1/human-gates/intake` with an SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation binds each queue to the upstream principal ID and login, including its offline browser cache. Review decisions carry `expected_revision`, a stable idempotency -key across network retries, and return durable receipts. If producer evidence +key across network retries and mobile app restarts, and return durable receipts. +An interrupted decision response is restored as **Decision outcome unknown**; +the operator verifies the exact persisted operation instead of creating a second +decision. The client clears that operation only after recovering a receipt. If producer evidence changes after a release or hold, the update reopens the exact hash for a new revision-checked decision instead of silently retaining the old outcome. New hashes mark older pending candidates `superseded` without removing their audit diff --git a/docs/human-gates.md b/docs/human-gates.md index bc7f24b..0ad5fa6 100644 --- a/docs/human-gates.md +++ b/docs/human-gates.md @@ -27,6 +27,8 @@ The immutable identity is authenticated account + `source` + `project` + `candid Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`. +Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary. + ## Privacy-safe notification contract Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`. diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 4128a8a..a91de02 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1626,5 +1626,6 @@ textarea { resize: vertical; min-height: 120px; } .human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))} .human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f} .human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px} +.human-gate-decision-recovery{border-color:#f59e0b}.human-gate-decision-recovery .human-gate-decision-state span{color:var(--muted)}.human-gate-decision-recovery button{min-height:44px;min-width:140px} @media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}} @media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}} \ No newline at end of file diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 8010bd4..4aa412b 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -704,6 +704,15 @@ qs('#human-gates-status').textContent = error.message; })); qs('#human-gate-detail').addEventListener('click', event => { + const recovery = event.target.closest('[data-gate-recover]'); + if (recovery) { + recovery.disabled = true; + humanGates.recoverDecision().catch(error => { + qs('#human-gates-status').textContent = error.message || 'The decision outcome could not be verified.'; + recovery.disabled = false; + }); + return; + } const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision; if (!decision) return; humanGates.submitDecision(decision).catch(error => { diff --git a/frontend/human-gates.js b/frontend/human-gates.js index 638b52d..5d29587 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -26,6 +26,8 @@ function createHumanGates(options = {}) { const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase(); const progressKey = item => 'stackchain.human-gate-review.v1:' + String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision; + const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' + + String(getAccountKey() || '').trim().toLowerCase(); const setText = (node, value) => { if (node) node.textContent = value; }; const setHtml = (node, value) => { if (node) node.innerHTML = value; }; const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state); @@ -65,6 +67,26 @@ function createHumanGates(options = {}) { try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; } } + function restorePendingDecision(item = null) { + if (!getLogin()) return null; + try { + const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null'); + if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation || + !value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null; + if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null; + return value; + } catch (_) { return null; } + } + + function savePendingDecision(value) { + storage.setItem(decisionStorageKey(), JSON.stringify(value)); + } + + function clearPendingDecision(item) { + if (!restorePendingDecision(item)) return; + try { storage.removeItem?.(decisionStorageKey()); } catch (_) {} + } + function valuesFromDetail() { const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked])); return { @@ -215,6 +237,7 @@ function createHumanGates(options = {}) { return; } const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''}; + const pendingDecision = restorePendingDecision(item); const checked = key => progress.checklist?.[key] === true ? ' checked' : ''; const checks = (item.checks || []).map(check => '
  • ' + escape(check.name) + ' · ' + escape(check.state) + (check.required ? ' · required' : '') + '
  • ' @@ -236,11 +259,14 @@ function createHumanGates(options = {}) { '' + '' + '' + - '
    ' + - '0 of 3 confirmations complete' + - '
    ' + - '
    ' + - '
    ' + '
    ' + + (pendingDecision ? 'Decision outcome unknownThe previous ' + escape(pendingDecision.decision) + + ' response was interrupted. Verify it before making another decision.' : + '0 of 3 confirmations complete') + '
    ' + + '
    ' + (pendingDecision ? + '' : + '') + + '
    ' ); updateReadiness(); } @@ -266,6 +292,16 @@ function createHumanGates(options = {}) { if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue; if (!live) throw new Error('Human Gates response is invalid.'); queue = { pending_count: live.pending_count, items: live.items.slice() }; + const interrupted = restorePendingDecision(); + if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) { + const recoveryItem = {...interrupted.item, decision_recovery:true}; + const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id); + if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem; + else { + queue.items.unshift(recoveryItem); + queue.pending_count += 1; + } + } save(queue); render(); publish({available:true, authoritative:true}); @@ -311,6 +347,8 @@ function createHumanGates(options = {}) { function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); } function idempotencyKey(item, decision, payload) { const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload); + const pending = restorePendingDecision(item); + if (pending?.operation === operation) return { operation, key: pending.idempotency_key }; if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) }; const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)); const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce; @@ -318,6 +356,30 @@ function createHumanGates(options = {}) { return { operation, key }; } + function completeDecision(item, decision, operation, receipt) { + decisionKeys.delete(operation); + clearPendingDecision(item); + try { storage.removeItem?.(progressKey(item)); } catch (_) {} + queue.items = queue.items.filter(candidate => candidate.id !== item.id); + queue.pending_count = Math.max(0, queue.pending_count - 1); + save(queue); render(); + publish({available:true, authoritative:true, decision:true}); + reviewIndex += 1; + const next = current(); + if (next) reviewNext(); else renderDetail(null); + setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.'); + return { receipt, next }; + } + + async function postDecision(item, pending) { + const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key }, + body: JSON.stringify(pending.payload), + }); + return completeDecision(item, pending.decision, pending.operation, receipt); + } + function decisionError(message, selector) { const error = new Error(message); error.targetSelector = selector; @@ -351,22 +413,19 @@ function createHumanGates(options = {}) { if (decisionFlight) return decisionFlight; const operation = (async () => { const decisionKey = idempotencyKey(item, decision, payload); - const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key }, - body: JSON.stringify(payload), - }); - decisionKeys.delete(decisionKey.operation); - try { storage.removeItem?.(progressKey(item)); } catch (_) {} - queue.items = queue.items.filter(candidate => candidate.id !== item.id); - queue.pending_count = Math.max(0, queue.pending_count - 1); - save(queue); render(); - publish({available:true, authoritative:true, decision:true}); - reviewIndex += 1; - const next = current(); - if (next) reviewNext(); else renderDetail(null); - setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.'); - return { receipt, next }; + const pending = { + gate_id:item.id, revision:item.revision, decision, payload, + operation:decisionKey.operation, idempotency_key:decisionKey.key, + item:JSON.parse(JSON.stringify(item)), + }; + savePendingDecision(pending); + try { + return await postDecision(item, pending); + } catch (error) { + renderDetail(item); + setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.'); + throw error; + } })(); decisionFlight = operation; try { @@ -376,6 +435,20 @@ function createHumanGates(options = {}) { } } + async function recoverDecision() { + const item = current(); + if (!item) throw new Error('No gate is selected.'); + if (!isOnline()) throw new Error('Decision verification requires an online connection.'); + if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.'); + const pending = restorePendingDecision(item); + if (!pending) throw new Error('No interrupted decision exists for this gate revision.'); + if (decisionFlight) return decisionFlight; + const operation = postDecision(item, pending); + decisionFlight = operation; + try { return await operation; } + finally { if (decisionFlight === operation) decisionFlight = null; } + } + async function submitDecision(decision) { const values = valuesFromDetail(); updateReadiness(values); @@ -425,7 +498,7 @@ function createHumanGates(options = {}) { return { load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending, - decideAndNext, submitDecision, current, saveProgress, + decideAndNext, recoverDecision, submitDecision, current, saveProgress, setOnChange(callback) { onChange = callback; }, restoreCached: restore, snapshot: () => JSON.parse(JSON.stringify(queue)), diff --git a/frontend/progressive-human-gates.js b/frontend/progressive-human-gates.js index 31e27ac..b330767 100644 --- a/frontend/progressive-human-gates.js +++ b/frontend/progressive-human-gates.js @@ -49,6 +49,15 @@ function createProgressiveHumanGates(options = {}) { if (card) controller.select(card.dataset.humanGateId); }); nodes.detail?.addEventListener?.('click', event => { + const recovery = event.target?.closest?.('[data-gate-recover]'); + if (recovery) { + recovery.disabled = true; + controller.recoverDecision().catch(error => { + showError(error); + recovery.disabled = false; + }); + return; + } const decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision; if (!decision) return; controller.submitDecision(decision).catch(error => { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 86cf8a1..e014eec 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v149'; +const CACHE = 'stackchain-dashboard-shell-v150'; 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/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index 385c73c..b5dc2a6 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -29,7 +29,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( current = {"gate": "g1"} evidence_url = {"value": "/evidence/manifest"} list_requests: list[str] = [] - decision_requests: list[str] = [] + decision_requests: list[dict] = [] browser_errors: list[str] = [] def gate(gate_id: str) -> dict: @@ -62,7 +62,14 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( def human_gates_route(route): path = route.request.url.split("?", 1)[0] if route.request.method == "POST": - decision_requests.append(path) + decision_requests.append({ + "path": path, + "key": route.request.headers.get("idempotency-key"), + "body": route.request.post_data_json, + }) + if len(decision_requests) == 1: + route.abort("failed") + return payload = {"receipt_id": "receipt-1", "state": "released"} elif path.endswith("/api/v1/human-gates"): list_requests.append(current["gate"]) @@ -131,9 +138,28 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( assert progress_key and progress_key.endswith(":g1:1") assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key) page.locator('[data-gate-decision="release"]').click() + expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown") + expect(page.locator("[data-gate-recover]")).to_have_count(1) + decision_key = page.evaluate( + "Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-decision.v1:'))" + ) + assert decision_key + assert len(decision_requests) == 1 + + page.reload(wait_until="networkidle") + page.evaluate("document.querySelector('#open-human-gates').click()") + expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown") + recover = page.locator("[data-gate-recover]") + recover.scroll_into_view_if_needed() + expect(recover).to_be_visible() + recover_bounds = recover.bounding_box() + assert recover_bounds and recover_bounds["height"] >= 44 + recover.click() expect(page.locator("#human-gates-status")).to_contain_text("Decision saved") expect(page.locator(".human-gate-decision-tray")).to_have_count(0) - assert len(decision_requests) == 1 + assert len(decision_requests) == 2 + assert decision_requests[0] == decision_requests[1] + assert not page.evaluate("key => localStorage.getItem(key) !== null", decision_key) assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key) page.evaluate("document.querySelector('#close-human-gates').click()") diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index d0ae9f8..38d5844 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda 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-v149" in worker + assert "stackchain-dashboard-shell-v150" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 7a9eecb..11f1b6e 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{ assert ".following-disposition-mode" in css assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard - assert "stackchain-dashboard-shell-v149" in service_worker + assert "stackchain-dashboard-shell-v150" in service_worker def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py index 64efc06..f49d10c 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -470,7 +470,7 @@ def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_ def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache(): worker = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in worker + assert "stackchain-dashboard-shell-v150" in worker def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason(): @@ -548,6 +548,76 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:() assert output["keys"][0] == output["keys"][1] +def test_interrupted_decision_is_recovered_after_reload_with_the_exact_operation(): + output = run_node(r""" +const stored=new Map(); const requests=[]; let failResponse=true, committed=false; +const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)}; +const item={id:'g1',title:'Release candidate',candidate_hash:'a1',revision:3,checks:[]}; +const createController=()=>{ + const detail={innerHTML:'',addEventListener(){},querySelectorAll:()=>[],querySelector:()=>null}; + const gates=createHumanGates({ + storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true, + nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''}, + fetchJson:async(path,options={})=>{ + if(options.method==='POST') { + requests.push({key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}); + if(failResponse) throw new Error('response interrupted'); + return {receipt_id:'r1',state:'held'}; + } + return committed ? {pending_count:0,items:[]} : {pending_count:1,items:[item]}; + }, + }); + return {gates,detail}; +}; +(async()=>{ + const first=createController(); await first.gates.load(); first.gates.reviewNext(); + first.gates.saveProgress({reason:'Awaiting approval',checklist:{}}); + try { await first.gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {} + const immediateHtml=first.detail.innerHTML; + const decisionStorageKey=[...stored.keys()].find(key=>key.startsWith('stackchain.human-gate-decision.v1:')); + const pendingBefore=JSON.parse(stored.get(decisionStorageKey)); + committed=true; failResponse=false; + const second=createController(); await second.gates.load(); second.gates.reviewNext(); + const recoveryHtml=second.detail.innerHTML; + const result=await second.gates.recoverDecision(); + process.stdout.write(JSON.stringify({ + decisionStorageKey,pendingBefore,immediateHtml,recoveryHtml,requests,result, + pendingCleared:!stored.has(decisionStorageKey), + progressCleared:![...stored.keys()].some(key=>key.startsWith('stackchain.human-gate-review.v1:')), + snapshot:second.gates.snapshot(), + })); +})().catch(error=>process.stdout.write(JSON.stringify({fatal:error.stack}))); +""") + assert "fatal" not in output, output.get("fatal") + assert output["decisionStorageKey"] == "stackchain.human-gate-decision.v1:7:timmy" + assert output["pendingBefore"]["gate_id"] == "g1" + assert output["pendingBefore"]["revision"] == 3 + assert output["pendingBefore"]["decision"] == "hold" + assert "Decision outcome unknown" in output["immediateHtml"] + assert "Decision outcome unknown" in output["recoveryHtml"] + assert "data-gate-recover" in output["recoveryHtml"] + assert len(output["requests"]) == 2 + assert output["requests"][0] == output["requests"][1] + assert output["pendingCleared"] is True + assert output["progressCleared"] is True + assert output["snapshot"] == {"pending_count": 0, "items": []} + assert output["result"]["receipt"]["receipt_id"] == "r1" + + +def test_interrupted_decision_recovery_is_wired_for_progressive_and_hydrated_mobile_shells(): + dashboard = DASHBOARD.read_text() + progressive = PROGRESSIVE.read_text() + css = CSS.read_text() + + assert "closest('[data-gate-recover]')" in dashboard + assert "humanGates.recoverDecision()" in dashboard + assert "closest?.('[data-gate-recover]')" in progressive + assert "controller.recoverDecision()" in progressive + assert ".human-gate-decision-recovery" in css + assert ".human-gate-decision-recovery button{min-height:44px" in css + assert "stackchain-dashboard-shell-v150" in WORKER.read_text() + + def test_failed_decision_keeps_review_progress_and_successful_retry_clears_it(): output = run_node(r""" const stored=new Map(); let attempts=0; @@ -707,7 +777,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired(): assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard assert "counts.gate = queueCounts.gate" in dashboard assert "gate:preparationItems.gate || []" in dashboard - assert "stackchain-dashboard-shell-v149" in WORKER.read_text() + assert "stackchain-dashboard-shell-v150" in WORKER.read_text() def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(): @@ -728,7 +798,7 @@ def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell( assert ".human-gate-views button{min-height:44px" in css assert ".human-gate-history-card time" in css assert ".human-gate-history-more{min-height:44px;width:100%" in css - assert "stackchain-dashboard-shell-v149" in WORKER.read_text() + assert "stackchain-dashboard-shell-v150" in WORKER.read_text() def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace(): diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index b1b79f9..db5ba0e 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,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-v149" in source + assert "stackchain-dashboard-shell-v150" 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 6318c62..ee78c7c 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,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-v149" in worker + assert "stackchain-dashboard-shell-v150" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 5ddb01e..7292cec 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,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-v149" in worker + assert "stackchain-dashboard-shell-v150" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 0197b0b..2843bd0 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v149" in worker + assert "stackchain-dashboard-shell-v150" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 17fd6a7..7e01220 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v149" in worker + assert "stackchain-dashboard-shell-v150" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index e2dae99..965c1db 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v149" in service_worker + assert "stackchain-dashboard-shell-v150" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 04f5ced..a9b64ea 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.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 38f3498..9009e3e 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -189,14 +189,14 @@ async function dispatchPush(payload) {{ def test_shared_progressive_snapshot_broker_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/progressive-live-snapshot.js'" in source def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/week-plan.js'" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" 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-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -258,7 +258,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-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v149" in source + assert "stackchain-dashboard-shell-v150" 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-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -292,21 +292,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-v149" in source + assert "stackchain-dashboard-shell-v150" 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-v149" in source + assert "stackchain-dashboard-shell-v150" 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-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1390,7 +1390,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-v149" in source + assert "stackchain-dashboard-shell-v150" 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 7650e13..e38c648 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-v149';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v150';" 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 b4d3983..596374a 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); 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-v149" in source + assert "stackchain-dashboard-shell-v150" in source assert "BASE + 'static/today-sync.js'" in source