From a5d05d7590990aa96c18d582bda380e580d749f4 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 19:38:29 +0000 Subject: [PATCH] feat: review durable Human Gate decision history (Closes #1441) --- frontend/dashboard.css | 1 + frontend/dashboard.js | 5 ++ frontend/human-gates.js | 73 +++++++++++++++++- frontend/index.html | 4 + frontend/progressive-human-gates.js | 3 + frontend/service-worker.js | 2 +- src/human_gate_store.py | 15 +++- tests/e2e/test_human_gates_reopen_release.py | 81 ++++++++++++++++++++ tests/test_comment_next.py | 2 +- tests/test_following_frontend.py | 2 +- tests/test_human_gate_api.py | 32 ++++++++ tests/test_human_gates_frontend.py | 78 ++++++++++++++++++- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 34 ++++---- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 22 files changed, 316 insertions(+), 34 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 68c2be6..263f227 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1625,5 +1625,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-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} @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 9428373..8010bd4 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -680,6 +680,7 @@ count:qs('#human-gates-count'), list:qs('#human-gates-list'), status:qs('#human-gates-status'), panel:qs('#human-gates'), detail:qs('#human-gate-detail'), + pendingTab:qs('#human-gates-pending'), historyTab:qs('#human-gates-history'), }, }); progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey); @@ -698,6 +699,10 @@ if (!card) return; humanGates.select(card.dataset.humanGateId); }); + qs('#human-gates-pending').addEventListener('click', () => humanGates.showPending()); + qs('#human-gates-history').addEventListener('click', () => humanGates.showHistory().catch(error => { + qs('#human-gates-status').textContent = error.message; + })); qs('#human-gate-detail').addEventListener('click', event => { const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision; if (!decision) return; diff --git a/frontend/human-gates.js b/frontend/human-gates.js index f2c3539..45a6b07 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -15,6 +15,7 @@ function createHumanGates(options = {}) { let decisionFlight = null; let openFlight = null; let onChange = options.onChange; + let historyItems = []; const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', @@ -85,7 +86,6 @@ function createHumanGates(options = {}) { nodes.detail?.addEventListener?.('input', captureProgress); nodes.detail?.addEventListener?.('change', captureProgress); - function render() { setText(nodes.count, String(queue.pending_count)); if (!queue.pending_count) { @@ -101,6 +101,73 @@ function createHumanGates(options = {}) { ).join('')); } + function setView(view) { + nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false'); + nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false'); + } + + function renderHistory() { + setView('history'); + setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.')); + if (!historyItems.length) { + setHtml(nodes.list, '
No decision historyReleased and held candidates will appear here.
'); + setHtml(nodes.detail, ''); + return; + } + setHtml(nodes.list, historyItems.map(item => + '' + ).join('')); + Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => { + card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => { + setText(nodes.status, error.message || 'Human Gate history is unavailable.'); + })); + }); + setHtml(nodes.detail, '
Decision historyOpen a candidate to review its durable receipt.
'); + } + + async function showHistory() { + if (!isOnline()) throw new Error('Human Gate history requires an online connection.'); + if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.'); + const result = validSnapshot(await fetchJson('api/v1/human-gates?state=all')); + if (!result) throw new Error('Human Gate history response is invalid.'); + historyItems = result.items.filter(item => item.state !== 'pending'); + renderHistory(); + return JSON.parse(JSON.stringify(historyItems)); + } + + async function selectHistory(gateId) { + const summary = historyItems.find(item => item.id === gateId); + if (!summary) throw new Error('Gate is not in the current history.'); + const detail = await fetchJson('api/v1/human-gates/' + encodeURIComponent(gateId)); + if (!detail || detail.id !== gateId) throw new Error('Gate history detail is invalid.'); + let receipt = null; + if (detail.receipt_id) receipt = await fetchJson('api/v1/human-gate-receipts/' + encodeURIComponent(detail.receipt_id)); + const checklist = receipt?.checklist || {}; + const confirmations = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'] + .filter(key => checklist[key] === true).map(key => '
  • ' + escape(key.replaceAll('_', ' ')) + '
  • ').join(''); + setHtml(nodes.detail, + '

    ' + escape(detail.state.toUpperCase()) + '

    ' + + '

    ' + escape(detail.title) + '

    Project ' + escape(detail.project) + '

    ' + + '

    Exact candidate ' + escape(detail.candidate_hash) + '

    ' + + (receipt ? '

    Decision receipt ' + escape(receipt.receipt_id) + '

    ' + + '

    Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '

    ' + + (receipt.reason ? '

    Reason

    ' + escape(receipt.reason) + '

    ' : '') + + (receipt.override_reason ? '

    Override

    ' + escape(receipt.override_reason) + '

    ' : '') + + (confirmations ? '

    Confirmed

    ' : '') : + '

    No decision receipt exists because this candidate was superseded.

    ') + '
    '); + return {detail, receipt}; + } + + function showPending() { + setView('pending'); + render(); + renderDetail(current()); + return JSON.parse(JSON.stringify(queue)); + } + function renderDetail(item) { if (!item) { setHtml(nodes.detail, '
    Inbox zeroFixed review snapshot complete.
    '); @@ -301,6 +368,7 @@ function createHumanGates(options = {}) { if (nodes.panel) nodes.panel.hidden = false; if (openFlight) return openFlight; const operation = (async () => { + setView('pending'); await load(); reviewSnapshot = queue.items.slice(); reviewIndex = 0; @@ -315,7 +383,8 @@ function createHumanGates(options = {}) { } return { - load, open, reviewNext, select, decideAndNext, submitDecision, current, saveProgress, + load, open, reviewNext, select, showHistory, selectHistory, showPending, + decideAndNext, submitDecision, current, saveProgress, setOnChange(callback) { onChange = callback; }, restoreCached: restore, snapshot: () => JSON.parse(JSON.stringify(queue)), diff --git a/frontend/index.html b/frontend/index.html index d77ea2c..9fabd15 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -194,6 +194,10 @@

    Human Gates

    +
    + + +
    diff --git a/frontend/progressive-human-gates.js b/frontend/progressive-human-gates.js index 3789706..31e27ac 100644 --- a/frontend/progressive-human-gates.js +++ b/frontend/progressive-human-gates.js @@ -23,6 +23,7 @@ function createProgressiveHumanGates(options = {}) { count:query('#human-gates-count'), list:query('#human-gates-list'), status:query('#human-gates-status'), panel:query('#human-gates'), detail:query('#human-gate-detail'), + pendingTab:query('#human-gates-pending'), historyTab:query('#human-gates-history'), }; let login = ''; let accountKey = ''; @@ -54,6 +55,8 @@ function createProgressiveHumanGates(options = {}) { if (!error?.targetSelector) showError(error); }); }); + nodes.pendingTab?.addEventListener?.('click', () => controller.showPending()); + nodes.historyTab?.addEventListener?.('click', () => controller.showHistory().catch(showError)); async function start(force = false) { if (!force && location.hash !== '#/my-work/human-gates') return false; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index fca8dae..0ce708b 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-v147'; +const CACHE = 'stackchain-dashboard-shell-v148'; 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/src/human_gate_store.py b/src/human_gate_store.py index 6932284..6df825e 100644 --- a/src/human_gate_store.py +++ b/src/human_gate_store.py @@ -182,6 +182,12 @@ class HumanGateStore: "reason": row["decision_reason"], "override_reason": row["override_reason"], "checklist": json.loads(row["checklist_json"]), }) + receipt = connection.execute( + "SELECT receipt_json FROM human_gate_receipts WHERE login=? AND gate_id=? ORDER BY rowid DESC LIMIT 1", + (row["login"], row["id"]), + ).fetchone() + if receipt: + item["receipt_id"] = json.loads(receipt[0])["receipt_id"] if history: item["history"] = self._history(connection, row["id"]) return item @@ -264,7 +270,14 @@ class HumanGateStore: with self._connect() as connection: pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0] where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state]) - rows = connection.execute(f"SELECT * FROM human_gates WHERE {where} ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, priority DESC, created_at, id LIMIT ?", (*args, limit)).fetchall() + rows = connection.execute( + f"SELECT * FROM human_gates WHERE {where} " + "ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, " + "CASE WHEN state='pending' THEN priority END DESC, " + "CASE WHEN state='pending' THEN created_at END, " + "CASE WHEN state!='pending' THEN updated_at END DESC, id LIMIT ?", + (*args, limit), + ).fetchall() return {"pending_count": pending_count, "items": [self._present(connection, row) for row in rows]} def detail(self, login: str, gate_id: str) -> dict: diff --git a/tests/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index 3409dab..b63ebc2 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -154,3 +154,84 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( fake.shutdown() fake.server_close() fake_thread.join(timeout=5) + + +def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(tmp_path: Path): + archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) + assert len(archives) == 1 + fake = FakeGiteaServer(("127.0.0.1", 0)) + fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) + fake_thread.start() + browser_errors: list[str] = [] + history_requests: list[str] = [] + held = { + "id": "held", "title": "Held candidate", "project": "stackchain/stackchain-dashboard", + "candidate_hash": "bbb222", "state": "held", "revision": 2, "priority": 5, + "created_at": 100, "updated_at": 200, "reason": "Needs mobile evidence", + "override_reason": "", "checklist": {}, "receipt_id": "receipt-2", + "checks": [], "artifacts": [], "links": [], "provenance": {}, + "history": [{"action": "held", "at": 200, "receipt_id": "receipt-2"}], + } + released = { + **held, "id": "released", "title": "Released candidate", "candidate_hash": "aaa111", + "state": "released", "updated_at": 150, "reason": "", "receipt_id": "receipt-1", + } + try: + with release_server( + archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}" + ) as origin, sync_playwright() as playwright: + browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) + context = browser.new_context( + viewport={"width": 390, "height": 844}, ignore_https_errors=True + ) + page = context.new_page() + page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error))) + + def gates_route(route): + history_requests.append(route.request.url) + if route.request.url.endswith("state=all"): + payload = {"pending_count": 0, "items": [held, released]} + elif route.request.url.endswith("/held"): + payload = held + else: + payload = {"pending_count": 0, "items": []} + route.fulfill(status=200, content_type="application/json", body=json.dumps(payload)) + + page.route("**/api/v1/human-gates**", gates_route) + page.route("**/api/v1/human-gates/**", gates_route) + page.route("**/api/v1/human-gate-receipts/receipt-2", lambda route: route.fulfill( + status=200, content_type="application/json", body=json.dumps({ + "receipt_id": "receipt-2", "gate_id": "held", "candidate_hash": "bbb222", + "state": "held", "decided_at": 200, "reason": "Needs mobile evidence", + "override_reason": "", "checklist": {}, "unmet_required_checks": [], + }) + )) + page.goto(origin + "/", wait_until="networkidle") + page.locator('input[name="device_label"]').fill("Human Gate history phone") + page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) + page.locator("#submit-sign-in").click() + page.wait_for_url(origin + "/", wait_until="networkidle") + page.evaluate("document.querySelector('#open-human-gates').click()") + expect(page.locator("#human-gates")).to_be_visible() + cached_before = page.evaluate("Object.keys(localStorage).sort()") + + page.locator("#human-gates-history").click() + expect(page.locator("#human-gates-status")).to_have_text("2 past Human Gate decisions.") + expect(page.locator('[data-human-gate-history-id="held"]')).to_contain_text("Held") + expect(page.locator('[data-human-gate-history-id="released"]')).to_contain_text("Released") + page.locator('[data-human-gate-history-id="held"]').click() + expect(page.locator("#human-gate-detail")).to_contain_text("Needs mobile evidence") + expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2") + expect(page.locator("#human-gate-detail [data-gate-decision]")).to_have_count(0) + assert page.evaluate("Object.keys(localStorage).sort()") == cached_before + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + for selector in ("#human-gates-pending", "#human-gates-history"): + bounds = page.locator(selector).bounding_box() + assert bounds and bounds["height"] >= 44 + assert any(url.endswith("state=all") for url in history_requests) + assert not browser_errors + browser.close() + finally: + fake.shutdown() + fake.server_close() + fake_thread.join(timeout=5) diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 39ed95e..eea6a3c 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-v147" in worker + assert "stackchain-dashboard-shell-v148" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 3c7e8fe..0e20938 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-v147" in service_worker + assert "stackchain-dashboard-shell-v148" in service_worker def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): diff --git a/tests/test_human_gate_api.py b/tests/test_human_gate_api.py index dd3d909..16bd639 100644 --- a/tests/test_human_gate_api.py +++ b/tests/test_human_gate_api.py @@ -69,6 +69,38 @@ async def test_decision_requires_revision_and_returns_durable_receipt(gate_api): assert all(response.headers["cache-control"] == "no-store" for response in (decided, repeated, receipt, stale)) +@pytest.mark.anyio +async def test_decision_history_is_newest_first_receipt_linked_and_principal_bound(gate_api): + first = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="history-first") + gate_api.decide( + "1:timmy", first["id"], expected_revision=1, decision="release", reason="", + override_reason="", checklist=CHECKLIST, idempotency_key="history-release", + ) + second_candidate = {**CANDIDATE, "candidate_hash": "def456", "title": "New candidate"} + second = gate_api.intake("1:timmy", second_candidate, idempotency_key="history-second") + second_receipt = gate_api.decide( + "1:timmy", second["id"], expected_revision=1, decision="hold", + reason="Needs another mobile pass", override_reason="", checklist={}, + idempotency_key="history-hold", + ) + + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + listing = await client.get("/api/v1/human-gates?state=all") + receipt = await client.get( + f"/api/v1/human-gate-receipts/{second_receipt['receipt_id']}" + ) + + assert listing.status_code == 200 + assert listing.headers["cache-control"] == "no-store" + assert [item["state"] for item in listing.json()["items"]] == ["held", "released"] + assert listing.json()["items"][0]["receipt_id"] == second_receipt["receipt_id"] + assert receipt.json()["reason"] == "Needs another mobile pass" + assert gate_api.list("2:timmy", state="all")["items"] == [] + with pytest.raises(LookupError): + gate_api.receipt("2:timmy", second_receipt["receipt_id"]) + + @pytest.mark.anyio async def test_decision_requires_fresh_authorization_bound_to_the_exact_gate(monkeypatch, gate_api): gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-authorized") diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py index 3f1ec2c..c21e68b 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -136,6 +136,60 @@ const gates=createHumanGates({storage,getLogin:()=> 'timmy',isOnline:()=>true,no assert "Inbox zero" in output["zero"]["html"] +def test_live_history_lists_decisions_and_opens_the_durable_receipt_without_caching(): + output = run_node(r""" +const writes=[]; const requests=[]; +const nodes={ + count:{},list:{innerHTML:''},status:{textContent:''},panel:{},detail:{innerHTML:''}, + pendingTab:{setAttribute(){},disabled:false},historyTab:{setAttribute(){},disabled:false}, +}; +const pending={pending_count:1,items:[{id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1,checks:[]}]}; +const history={pending_count:1,items:[ + {id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1}, + {id:'held',title:'Held candidate',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2'}, + {id:'released',title:'Released candidate',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-1'}, +]}; +const detail={id:'held',title:'Held candidate',project:'stackchain/dashboard',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2',history:[{action:'held',at:200}]}; +const receipt={receipt_id:'receipt-2',gate_id:'held',candidate_hash:'bbb',state:'held',decided_at:200,reason:'Needs mobile evidence',override_reason:'',checklist:{}}; +const gates=createHumanGates({ + storage:{getItem:()=>null,setItem:(key,value)=>writes.push({key,value})}, + getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''}, + fetchJson:async path=>{ + requests.push(path); + if(path==='api/v1/human-gates?state=all') return history; + if(path==='api/v1/human-gates/held') return detail; + if(path==='api/v1/human-gate-receipts/receipt-2') return receipt; + return pending; + }, +}); +(async()=>{ + await gates.load(); const writesAfterPending=writes.length; + const decisions=await gates.showHistory(); const historyHtml=nodes.list.innerHTML; + await gates.selectHistory('held'); + process.stdout.write(JSON.stringify({ + writesAfterPending,writesAfterHistory:writes.length,requests,decisions, + historyHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent, + })); +})(); +""") + assert output["writesAfterPending"] == 1 + assert output["writesAfterHistory"] == 1 + assert output["requests"] == [ + "api/v1/human-gates", + "api/v1/human-gates?state=all", + "api/v1/human-gates/held", + "api/v1/human-gate-receipts/receipt-2", + ] + assert [item["id"] for item in output["decisions"]] == ["held", "released"] + assert "Waiting" not in output["historyHtml"] + assert "Held" in output["historyHtml"] + assert "Released" in output["historyHtml"] + assert "Needs mobile evidence" in output["detailHtml"] + assert "receipt-2" in output["detailHtml"] + assert "data-gate-decision" not in output["detailHtml"] + assert output["status"] == "2 past Human Gate decisions." + + def test_queue_changes_publish_mobile_counts_and_authoritative_decision_completion(): output = run_node(r""" const changes=[]; @@ -332,7 +386,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-v147" in worker + assert "stackchain-dashboard-shell-v148" in worker def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason(): @@ -569,7 +623,27 @@ 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-v147" in WORKER.read_text() + assert "stackchain-dashboard-shell-v148" in WORKER.read_text() + + +def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(): + index = INDEX.read_text() + dashboard = DASHBOARD.read_text() + progressive = PROGRESSIVE.read_text() + css = CSS.read_text() + + assert 'class="human-gate-views" role="group" aria-label="Human Gate view"' in index + assert 'id="human-gates-pending"' in index + assert 'id="human-gates-history"' in index + assert "pendingTab:qs('#human-gates-pending')" in dashboard + assert "historyTab:qs('#human-gates-history')" in dashboard + assert "humanGates.showHistory()" in dashboard + assert "card.addEventListener('click', () => selectHistory" in MODULE.read_text() + assert "pendingTab:query('#human-gates-pending')" in progressive + assert "historyTab:query('#human-gates-history')" in progressive + assert ".human-gate-views button{min-height:44px" in css + assert ".human-gate-history-card time" in css + assert "stackchain-dashboard-shell-v148" 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 5f8b9af..b61f4c4 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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 dc5994a..e974e4b 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-v147" in worker + assert "stackchain-dashboard-shell-v148" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 2e74685..fa57e35 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-v147" in worker + assert "stackchain-dashboard-shell-v148" 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 e25a48e..5d25484 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-v147" in worker + assert "stackchain-dashboard-shell-v148" 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 7395860..3816479 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-v147" in worker + assert "stackchain-dashboard-shell-v148" 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 b9d508b..7547d29 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-v147" in service_worker + assert "stackchain-dashboard-shell-v148" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 715c674b..15a12ab 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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 a7c5897..9652147 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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-v147" in source + assert "stackchain-dashboard-shell-v148" 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 42639b9..e3c0455 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-v147';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v148';" 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 50e66f9..291bfd3 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-v147" in source + assert "stackchain-dashboard-shell-v148" in source assert "BASE + 'static/today-sync.js'" in source