feat: review durable Human Gate decision history (Closes #1441)
This commit is contained in:
parent
efa01cf47f
commit
a5d05d7590
|
|
@ -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)}}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
|
||||
setHtml(nodes.detail, '');
|
||||
return;
|
||||
}
|
||||
setHtml(nodes.list, historyItems.map(item =>
|
||||
'<button class="human-gate-card human-gate-history-card" type="button" data-human-gate-history-id="' + escape(item.id) + '">' +
|
||||
'<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(''));
|
||||
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>');
|
||||
}
|
||||
|
||||
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 => '<li>' + escape(key.replaceAll('_', ' ')) + '</li>').join('');
|
||||
setHtml(nodes.detail,
|
||||
'<article class="human-gate-detail human-gate-receipt"><p class="small">' + escape(detail.state.toUpperCase()) + '</p>' +
|
||||
'<h3>' + escape(detail.title) + '</h3><p>Project <strong>' + escape(detail.project) + '</strong></p>' +
|
||||
'<p>Exact candidate <code>' + escape(detail.candidate_hash) + '</code></p>' +
|
||||
(receipt ? '<p>Decision receipt <code>' + escape(receipt.receipt_id) + '</code></p>' +
|
||||
'<p>Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '</p>' +
|
||||
(receipt.reason ? '<h4>Reason</h4><p>' + escape(receipt.reason) + '</p>' : '') +
|
||||
(receipt.override_reason ? '<h4>Override</h4><p>' + escape(receipt.override_reason) + '</p>' : '') +
|
||||
(confirmations ? '<h4>Confirmed</h4><ul>' + confirmations + '</ul>' : '') :
|
||||
'<p>No decision receipt exists because this candidate was superseded.</p>') + '</article>');
|
||||
return {detail, receipt};
|
||||
}
|
||||
|
||||
function showPending() {
|
||||
setView('pending');
|
||||
render();
|
||||
renderDetail(current());
|
||||
return JSON.parse(JSON.stringify(queue));
|
||||
}
|
||||
|
||||
function renderDetail(item) {
|
||||
if (!item) {
|
||||
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -194,6 +194,10 @@
|
|||
<div><h3 id="human-gates-heading">Human Gates</h3><p id="human-gates-status" class="small" role="status" aria-live="polite"></p></div>
|
||||
<button id="close-human-gates" type="button">Close</button>
|
||||
</div>
|
||||
<div class="human-gate-views" role="group" aria-label="Human Gate view">
|
||||
<button id="human-gates-pending" type="button" aria-pressed="true">Pending</button>
|
||||
<button id="human-gates-history" type="button" aria-pressed="false">History</button>
|
||||
</div>
|
||||
<div id="human-gates-list" class="human-gates-list"></div>
|
||||
<div id="human-gate-detail" class="human-gate-detail-host"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user