Paginate durable Human Gate decision history on mobile #1445

Merged
rockachopa merged 1 commits from timmy/1444-paginated-human-gate-history into main 2026-08-26 21:23:06 +00:00
21 changed files with 325 additions and 51 deletions

View File

@ -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)}}

View File

@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@ -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, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
setHtml(nodes.detail, '');
@ -119,25 +128,57 @@ function createHumanGates(options = {}) {
'<strong>' + escape(item.title) + '</strong><span class="human-gate-state human-gate-state-' + escape(item.state) + '">' +
escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '</span>' +
'<code>' + escape(item.candidate_hash) + '</code><time>' + escape(new Date(Number(item.updated_at) * 1000).toLocaleString()) + '</time></button>'
).join(''));
).join('') + (historyNextCursor ? '<button class="human-gate-history-more" type="button" data-human-gate-history-more>' +
(historyLoadError ? 'Retry older decisions' : 'Load older decisions') + '</button>' : ''));
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, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
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, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
}
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,

View File

@ -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;

View File

@ -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} "

View File

@ -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"})

View File

@ -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:

View File

@ -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

View File

@ -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():

View File

@ -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")

View File

@ -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__)

View File

@ -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():

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v148';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v149';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -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