From 0f1cac0e25ea5b144eb6cdd5925e678ae739387c Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 21:10:10 +0000 Subject: [PATCH] feat: paginate mobile Human Gate history (Closes #1444) --- frontend/dashboard.css | 2 +- frontend/human-gates.js | 55 +++++++++-- frontend/service-worker.js | 2 +- src/human_gate_store.py | 61 +++++++++++- src/main.py | 5 +- tests/e2e/test_human_gates_reopen_release.py | 24 ++++- tests/test_comment_next.py | 2 +- tests/test_following_frontend.py | 2 +- tests/test_human_gate_api.py | 34 +++++++ tests/test_human_gate_store.py | 38 ++++++++ tests/test_human_gates_frontend.py | 99 ++++++++++++++++++-- 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 +- 21 files changed, 325 insertions(+), 51 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 263f227..4128a8a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1625,6 +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} +.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} @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/human-gates.js b/frontend/human-gates.js index 45a6b07..638b52d 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -16,6 +16,9 @@ function createHumanGates(options = {}) { let openFlight = null; let onChange = options.onChange; let historyItems = []; + let historyNextCursor = null; + let historyLoadedMore = false; + let historyLoadError = ''; const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', @@ -106,9 +109,15 @@ function createHumanGates(options = {}) { nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false'); } - function renderHistory() { + function renderHistory(preserveDetail = false) { setView('history'); - setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.')); + if (historyNextCursor) { + setText(nodes.status, historyItems.length + ' Human Gate decisions loaded. Older decisions are available.'); + } else if (historyLoadedMore) { + setText(nodes.status, 'All ' + historyItems.length + ' Human Gate decisions loaded.'); + } else { + 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, ''); @@ -119,25 +128,57 @@ function createHumanGates(options = {}) { '' + escape(item.title) + '' + escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '' + '' + escape(item.candidate_hash) + '' - ).join('')); + ).join('') + (historyNextCursor ? '' : '')); 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.
'); + nodes.list?.querySelector?.('[data-human-gate-history-more]')?.addEventListener?.('click', () => { + loadMoreHistory().catch(error => setText(nodes.status, error.message || 'Older Human Gate decisions are unavailable.')); + }); + if (!preserveDetail) 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')); + const result = validSnapshot(await fetchJson('api/v1/human-gates?state=history&limit=20')); if (!result) throw new Error('Human Gate history response is invalid.'); - historyItems = result.items.filter(item => item.state !== 'pending'); + historyItems = result.items; + historyNextCursor = result.next_cursor || null; + historyLoadedMore = false; + historyLoadError = ''; renderHistory(); return JSON.parse(JSON.stringify(historyItems)); } + async function loadMoreHistory() { + if (!historyNextCursor) return JSON.parse(JSON.stringify(historyItems)); + if (!isOnline()) throw new Error('Human Gate history requires an online connection.'); + const cursor = historyNextCursor; + let result; + try { + result = validSnapshot(await fetchJson( + 'api/v1/human-gates?state=history&limit=20&cursor=' + encodeURIComponent(cursor) + )); + if (!result) throw new Error('Human Gate history response is invalid.'); + } catch (error) { + historyLoadError = error?.message || 'Older Human Gate decisions are unavailable.'; + renderHistory(true); + setText(nodes.status, historyLoadError + ' Loaded decisions are still available.'); + throw error; + } + const known = new Set(historyItems.map(item => item.id)); + historyItems.push(...result.items.filter(item => !known.has(item.id))); + historyNextCursor = result.next_cursor || null; + historyLoadedMore = true; + historyLoadError = ''; + renderHistory(true); + 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.'); @@ -383,7 +424,7 @@ function createHumanGates(options = {}) { } return { - load, open, reviewNext, select, showHistory, selectHistory, showPending, + load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending, decideAndNext, submitDecision, current, saveProgress, setOnChange(callback) { onChange = callback; }, restoreCached: restore, diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 0ce708b..86cf8a1 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-v148'; +const CACHE = 'stackchain-dashboard-shell-v149'; 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 6df825e..ae9761c 100644 --- a/src/human_gate_store.py +++ b/src/human_gate_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import hashlib import json import re @@ -69,6 +70,9 @@ class HumanGateStore: ); CREATE INDEX IF NOT EXISTS human_gates_queue ON human_gates(login, state, priority DESC, created_at, id); + CREATE INDEX IF NOT EXISTS human_gates_decision_history + ON human_gates(login, updated_at DESC, id DESC) + WHERE state IN ('released','held','superseded'); CREATE TABLE IF NOT EXISTS human_gate_intake_keys ( login TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, gate_id TEXT NOT NULL, @@ -78,6 +82,8 @@ class HumanGateStore: sequence INTEGER PRIMARY KEY AUTOINCREMENT, gate_id TEXT NOT NULL, action TEXT NOT NULL, at REAL NOT NULL, details_json TEXT NOT NULL ); + CREATE INDEX IF NOT EXISTS human_gate_history_gate_sequence + ON human_gate_history(gate_id, sequence); CREATE TABLE IF NOT EXISTS human_gate_receipts ( receipt_id TEXT PRIMARY KEY, login TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, @@ -262,13 +268,64 @@ class HumanGateStore: row = connection.execute("SELECT * FROM human_gates WHERE id=?", (gate_id,)).fetchone() return self._present(connection, row, history=True) - def list(self, login: str, *, state: str = "pending", limit: int = 100) -> dict: + @staticmethod + def _history_cursor(login: str, updated_at: float, gate_id: str) -> str: + principal = hashlib.sha256(login.encode()).hexdigest()[:16] + raw = _canonical([principal, updated_at, gate_id]).encode() + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + @staticmethod + def _parse_history_cursor(login: str, cursor: str) -> tuple[float, str]: + try: + raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) + principal, updated_at, gate_id = json.loads(raw) + expected = hashlib.sha256(login.encode()).hexdigest()[:16] + if ( + principal != expected + or not isinstance(updated_at, (int, float)) + or not isinstance(gate_id, str) + or not gate_id + ): + raise ValueError + return float(updated_at), gate_id + except (ValueError, TypeError, json.JSONDecodeError, UnicodeDecodeError) as error: + raise GateValidationError("history cursor is invalid") from error + + def list( + self, login: str, *, state: str = "pending", limit: int = 100, + cursor: str | None = None, + ) -> dict: login = self._login(login) - if state not in _STATES and state != "all": + if state not in _STATES and state not in {"all", "history"}: raise GateValidationError("state is invalid") limit = min(max(int(limit), 1), 100) with self._connect() as connection: pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0] + if state == "history": + args: list[object] = [login] + cursor_clause = "" + if cursor: + updated_at, gate_id = self._parse_history_cursor(login, cursor) + cursor_clause = " AND (updated_at < ? OR (updated_at = ? AND id < ?))" + args.extend([updated_at, updated_at, gate_id]) + rows = connection.execute( + "SELECT * FROM human_gates WHERE login=? " + "AND state IN ('released','held','superseded')" + cursor_clause + + " ORDER BY updated_at DESC, id DESC LIMIT ?", + (*args, limit + 1), + ).fetchall() + visible = rows[:limit] + next_cursor = None + if len(rows) > limit: + last = visible[-1] + next_cursor = self._history_cursor(login, last["updated_at"], last["id"]) + return { + "pending_count": pending_count, + "items": [self._present(connection, row) for row in visible], + "next_cursor": next_cursor, + } + if cursor: + raise GateValidationError("history cursor is invalid") where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state]) rows = connection.execute( f"SELECT * FROM human_gates WHERE {where} " diff --git a/src/main.py b/src/main.py index 6eab560..aecea62 100644 --- a/src/main.py +++ b/src/main.py @@ -1890,10 +1890,13 @@ async def list_human_gates( request: Request, state: str = Query(default="pending"), limit: int = Query(default=100, ge=1, le=100), + cursor: str | None = Query(default=None, min_length=1, max_length=512), ): login = await _human_gate_login(request) try: - result = await asyncio.to_thread(_human_gate_store().list, login, state=state, limit=limit) + result = await asyncio.to_thread( + _human_gate_store().list, login, state=state, limit=limit, cursor=cursor + ) except (GateValidationError, sqlite3.Error) as error: raise _gate_error(error) from error return JSONResponse(result, headers={"Cache-Control": "no-store"}) diff --git a/tests/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index b63ebc2..385c73c 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -176,6 +176,11 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t **held, "id": "released", "title": "Released candidate", "candidate_hash": "aaa111", "state": "released", "updated_at": 150, "reason": "", "receipt_id": "receipt-1", } + superseded = { + **held, "id": "superseded", "title": "Superseded candidate", + "candidate_hash": "old000", "state": "superseded", "updated_at": 100, + "reason": "", "receipt_id": None, + } try: with release_server( archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}" @@ -189,8 +194,10 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t def gates_route(route): history_requests.append(route.request.url) - if route.request.url.endswith("state=all"): - payload = {"pending_count": 0, "items": [held, released]} + if "state=history" in route.request.url and "cursor=older-page" in route.request.url: + payload = {"pending_count": 0, "items": [superseded], "next_cursor": None} + elif "state=history" in route.request.url: + payload = {"pending_count": 0, "items": [held, released], "next_cursor": "older-page"} elif route.request.url.endswith("/held"): payload = held else: @@ -216,19 +223,28 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t 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("#human-gates-status")).to_contain_text("Older decisions are available") 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) + more = page.get_by_role("button", name="Load older decisions") + more_bounds = more.bounding_box() + assert more_bounds and more_bounds["height"] >= 44 + more.click() + expect(page.locator('[data-human-gate-history-id="superseded"]')).to_contain_text("Superseded") + expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2") + expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.") + expect(page.locator("[data-human-gate-history-more]")).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 any("state=history&limit=20" in url for url in history_requests) + assert any("cursor=older-page" in url for url in history_requests) assert not browser_errors browser.close() finally: diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index eea6a3c..d0ae9f8 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-v148" in worker + assert "stackchain-dashboard-shell-v149" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 0e20938..7a9eecb 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-v148" in service_worker + assert "stackchain-dashboard-shell-v149" 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 16bd639..251309e 100644 --- a/tests/test_human_gate_api.py +++ b/tests/test_human_gate_api.py @@ -101,6 +101,40 @@ async def test_decision_history_is_newest_first_receipt_linked_and_principal_bou gate_api.receipt("2:timmy", second_receipt["receipt_id"]) +@pytest.mark.anyio +async def test_decision_history_api_follows_an_opaque_continuation_cursor(gate_api): + for index in range(3): + candidate = { + **CANDIDATE, + "project": f"history/{index}", + "candidate_hash": f"history-{index}", + } + gate = gate_api.intake("1:timmy", candidate, idempotency_key=f"api-history-{index}") + gate_api.decide( + "1:timmy", gate["id"], expected_revision=1, decision="release", + reason="", override_reason="", checklist=CHECKLIST, + idempotency_key=f"api-decision-{index}", + ) + + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + first = await client.get("/api/v1/human-gates?state=history&limit=2") + second = await client.get( + "/api/v1/human-gates", + params={"state": "history", "limit": 2, "cursor": first.json()["next_cursor"]}, + ) + malformed = await client.get( + "/api/v1/human-gates?state=history&cursor=not-a-cursor" + ) + + assert first.status_code == 200 + assert len(first.json()["items"]) == 2 + assert second.status_code == 200 + assert len(second.json()["items"]) == 1 + assert second.json()["next_cursor"] is None + assert malformed.status_code == 422 + + @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_gate_store.py b/tests/test_human_gate_store.py index 1df8c55..09c9a8d 100644 --- a/tests/test_human_gate_store.py +++ b/tests/test_human_gate_store.py @@ -64,6 +64,44 @@ def test_pending_queue_orders_highest_priority_then_oldest(tmp_path): assert [item["id"] for item in store.list("timmy")["items"]] == [oldest_high["id"], newest_high["id"], low["id"]] +def test_decision_history_pages_are_bounded_stable_and_not_hidden_by_pending_work(tmp_path): + tick = iter(range(1000)).__next__ + store = HumanGateStore(tmp_path / "gates.sqlite3", clock=tick) + decided = [] + for index in range(3): + gate = store.intake( + "timmy", + {**candidate(f"decided-{index}"), "project": f"decided/{index}"}, + idempotency_key=f"decided-{index}", + ) + store.decide( + "timmy", gate["id"], expected_revision=1, decision="release", + reason="", override_reason="", checklist=checklist(), + idempotency_key=f"decision-{index}", + ) + decided.append(gate["id"]) + for index in range(105): + store.intake( + "timmy", + {**candidate(f"pending-{index}"), "project": f"pending/{index}"}, + idempotency_key=f"pending-{index}", + ) + + first = store.list("timmy", state="history", limit=2) + second = store.list("timmy", state="history", limit=2, cursor=first["next_cursor"]) + + assert first["pending_count"] == 105 + assert [item["id"] for item in first["items"]] == list(reversed(decided[1:])) + assert first["next_cursor"] + assert [item["id"] for item in second["items"]] == [decided[0]] + assert second["next_cursor"] is None + assert store.list("timmy", state="history", limit=2, cursor=first["next_cursor"]) == second + with pytest.raises(GateValidationError, match="cursor"): + store.list("alex", state="history", cursor=first["next_cursor"]) + with pytest.raises(GateValidationError, match="cursor"): + store.list("timmy", state="history", cursor="not-a-cursor") + + def test_decision_checks_revision_rules_and_returns_durable_idempotent_receipt(tmp_path): path = tmp_path / "gates.sqlite3" store = HumanGateStore(path, clock=iter([100, 101]).__next__) diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py index c21e68b..64efc06 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -145,10 +145,9 @@ const nodes={ }; 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'}, -]}; +],next_cursor:null}; 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({ @@ -156,7 +155,7 @@ const gates=createHumanGates({ 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?state=history&limit=20') return history; if(path==='api/v1/human-gates/held') return detail; if(path==='api/v1/human-gate-receipts/receipt-2') return receipt; return pending; @@ -176,7 +175,7 @@ const gates=createHumanGates({ assert output["writesAfterHistory"] == 1 assert output["requests"] == [ "api/v1/human-gates", - "api/v1/human-gates?state=all", + "api/v1/human-gates?state=history&limit=20", "api/v1/human-gates/held", "api/v1/human-gate-receipts/receipt-2", ] @@ -190,6 +189,91 @@ const gates=createHumanGates({ assert output["status"] == "2 past Human Gate decisions." +def test_mobile_history_loads_older_decisions_without_losing_the_open_receipt(): + output = run_node(r""" +const requests=[]; +const nodes={ + count:{},list:{innerHTML:'',querySelectorAll:()=>[]},status:{textContent:''},panel:{},detail:{innerHTML:''}, + pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}}, +}; +const first={pending_count:0,items:[ + {id:'new',title:'Newest decision',candidate_hash:'aaa',state:'released',updated_at:300,receipt_id:'receipt-new'}, + {id:'middle',title:'Middle decision',candidate_hash:'bbb',state:'held',updated_at:200,receipt_id:'receipt-middle'}, +],next_cursor:'page-two'}; +const second={pending_count:0,items:[ + {id:'old',title:'Oldest decision',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-old'}, +],next_cursor:null}; +const detail={id:'new',title:'Newest decision',project:'stackchain/dashboard',candidate_hash:'aaa',state:'released',receipt_id:'receipt-new'}; +const receipt={receipt_id:'receipt-new',gate_id:'new',candidate_hash:'aaa',state:'released',decided_at:300,checklist:{}}; +const gates=createHumanGates({ + storage:{getItem:()=>null,setItem(){throw new Error('history must not be cached')}}, + getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''}, + fetchJson:async path=>{ + requests.push(path); + if(path==='api/v1/human-gates?state=history&limit=20') return first; + if(path.includes('cursor=page-two')) return second; + if(path==='api/v1/human-gates/new') return detail; + if(path==='api/v1/human-gate-receipts/receipt-new') return receipt; + throw new Error('unexpected '+path); + }, +}); +(async()=>{ + await gates.showHistory(); + await gates.selectHistory('new'); + const selectedHtml=nodes.detail.innerHTML; + await gates.loadMoreHistory(); + process.stdout.write(JSON.stringify({requests,listHtml:nodes.list.innerHTML,selectedHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent})); +})(); +""") + assert output["requests"] == [ + "api/v1/human-gates?state=history&limit=20", + "api/v1/human-gates/new", + "api/v1/human-gate-receipts/receipt-new", + "api/v1/human-gates?state=history&limit=20&cursor=page-two", + ] + assert all(title in output["listHtml"] for title in ("Newest decision", "Middle decision", "Oldest decision")) + assert "Load older decisions" not in output["listHtml"] + assert output["detailHtml"] == output["selectedHtml"] + assert output["status"] == "All 3 Human Gate decisions loaded." + + +def test_failed_older_history_page_preserves_loaded_decisions_and_retries_in_place(): + output = run_node(r""" +let attempts=0; +const nodes={ + count:{},list:{innerHTML:'',querySelectorAll:()=>[],querySelector:()=>null},status:{textContent:''},panel:{},detail:{innerHTML:'receipt remains'}, + pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}}, +}; +const gates=createHumanGates({ + storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''}, + fetchJson:async path=>{ + if(!path.includes('cursor=')) return {pending_count:0,items:[{id:'new',title:'Newest',candidate_hash:'aaa',state:'released',updated_at:300}],next_cursor:'older'}; + attempts += 1; + if(attempts===1) throw new Error('History service timed out'); + return {pending_count:0,items:[{id:'old',title:'Oldest',candidate_hash:'bbb',state:'held',updated_at:100}],next_cursor:null}; + }, +}); +(async()=>{ + await gates.showHistory(); + nodes.detail.innerHTML='receipt remains'; + let message=''; + try{await gates.loadMoreHistory()}catch(error){message=error.message} + const failed={message,html:nodes.list.innerHTML,status:nodes.status.textContent,detail:nodes.detail.innerHTML}; + await gates.loadMoreHistory(); + process.stdout.write(JSON.stringify({failed,attempts,html:nodes.list.innerHTML,status:nodes.status.textContent})); +})(); +""") + assert output["failed"]["message"] == "History service timed out" + assert "Newest" in output["failed"]["html"] + assert "Oldest" not in output["failed"]["html"] + assert "Retry older decisions" in output["failed"]["html"] + assert output["failed"]["status"] == "History service timed out Loaded decisions are still available." + assert output["failed"]["detail"] == "receipt remains" + assert output["attempts"] == 2 + assert "Oldest" in output["html"] + assert output["status"] == "All 2 Human Gate decisions loaded." + + def test_queue_changes_publish_mobile_counts_and_authoritative_decision_completion(): output = run_node(r""" const changes=[]; @@ -386,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-v148" in worker + assert "stackchain-dashboard-shell-v149" in worker def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason(): @@ -623,7 +707,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-v148" in WORKER.read_text() + assert "stackchain-dashboard-shell-v149" in WORKER.read_text() def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(): @@ -643,7 +727,8 @@ def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell( 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() + assert ".human-gate-history-more{min-height:44px;width:100%" in css + assert "stackchain-dashboard-shell-v149" 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 b61f4c4..b1b79f9 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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 e974e4b..6318c62 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-v148" in worker + assert "stackchain-dashboard-shell-v149" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index fa57e35..5ddb01e 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-v148" in worker + assert "stackchain-dashboard-shell-v149" 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 5d25484..0197b0b 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-v148" in worker + assert "stackchain-dashboard-shell-v149" 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 3816479..17fd6a7 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-v148" in worker + assert "stackchain-dashboard-shell-v149" 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 7547d29..e2dae99 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-v148" in service_worker + assert "stackchain-dashboard-shell-v149" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 15a12ab..04f5ced 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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 9652147..38f3498 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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-v148" in source + assert "stackchain-dashboard-shell-v149" 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 e3c0455..7650e13 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-v148';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v149';" 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 291bfd3..b4d3983 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-v148" in source + assert "stackchain-dashboard-shell-v149" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0