From dbb8ff4d4e21522fa8f41653a74f4fa81f2cbe0f Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 00:45:40 +0000 Subject: [PATCH] fix: harden Human Gates review flow --- frontend/dashboard.js | 9 +++++-- frontend/human-gates.js | 27 +++++++++++++++++---- frontend/index.html | 2 +- src/human_gate_store.py | 20 +++++++++++++++- src/main.py | 8 +++---- tests/test_human_gate_api.py | 27 +++++++++++++++++++-- tests/test_human_gate_store.py | 15 +++++++++++- tests/test_human_gates_frontend.py | 38 ++++++++++++++++++++++++++++++ 8 files changed, 130 insertions(+), 16 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 7f3b62e..9ab6edb 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -392,6 +392,7 @@ let editingOutboxId = null; let confirmedOwnerLogin = ''; let planningOwnerLogin = ''; + let planningOwnerAccountKey = ''; let activeFlushLogin = ''; let rR = null; function rRC() { @@ -637,6 +638,7 @@ const humanGates = createHumanGates({ storage:localStorage, getLogin:()=>planningOwnerLogin, + getAccountKey:()=>planningOwnerAccountKey, isOnline:()=>navigator.onLine, location:window.location, fetchJson:fetchReviewJson, @@ -655,8 +657,9 @@ if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work'); }); qs('#human-gates-list').addEventListener('click', event => { - if (!event.target.closest('[data-human-gate-id]')) return; - humanGates.reviewNext(); + const card = event.target.closest('[data-human-gate-id]'); + if (!card) return; + humanGates.select(card.dataset.humanGateId); }); qs('#human-gate-detail').addEventListener('click', event => { const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision; @@ -5564,6 +5567,8 @@ const retainedPlanningLogin = !snapshot.context.error ? String(snapshot.context.user?.login || '').trim() : ''; planningOwnerLogin = retainedPlanningLogin; + planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ? + String(snapshot.context.user.id) + ':' + retainedPlanningLogin : ''; updatePlanningAvailability(); if (planningOwnerLogin) { syncPendingTomorrow(); diff --git a/frontend/human-gates.js b/frontend/human-gates.js index ad4a30e..052b668 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -1,6 +1,7 @@ function createHumanGates(options = {}) { const storage = options.storage || window.localStorage; const getLogin = options.getLogin || (() => ''); + const getAccountKey = options.getAccountKey || getLogin; const isOnline = options.isOnline || (() => navigator.onLine); const location = options.location || window.location; const fetchJson = options.fetchJson; @@ -8,11 +9,12 @@ function createHumanGates(options = {}) { let queue = { pending_count: 0, items: [] }; let reviewSnapshot = []; let reviewIndex = -1; + const decisionKeys = new Map(); const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[character]); - const cacheKey = () => 'stackchain.human-gates.v1:' + String(getLogin() || '').trim().toLowerCase(); + const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase(); const setText = (node, value) => { if (node) node.textContent = value; }; const setHtml = (node, value) => { if (node) node.innerHTML = value; }; @@ -111,10 +113,22 @@ function createHumanGates(options = {}) { return item; } + function select(gateId) { + if (reviewIndex < 0) reviewSnapshot = queue.items.slice(); + const index = reviewSnapshot.findIndex(item => item.id === gateId); + if (index < 0) throw new Error('Gate is not in the current review snapshot.'); + reviewIndex = index; + return reviewNext(); + } + function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); } - function idempotencyKey(item, decision) { + function idempotencyKey(item, decision, payload) { + const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload); + if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) }; const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)); - return 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce; + const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce; + decisionKeys.set(operation, key); + return { operation, key }; } async function decideAndNext(decision, values = {}) { @@ -135,11 +149,13 @@ function createHumanGates(options = {}) { reason: String(values.reason || '').trim(), override_reason: String(values.override_reason || '').trim(), checklist, }; + const decisionKey = idempotencyKey(item, decision, payload); const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey(item, decision) }, + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key }, body: JSON.stringify(payload), }); + decisionKeys.delete(decisionKey.operation); queue.items = queue.items.filter(candidate => candidate.id !== item.id); queue.pending_count = Math.max(0, queue.pending_count - 1); save(queue); render(); @@ -157,7 +173,8 @@ function createHumanGates(options = {}) { } return { - load, open, reviewNext, decideAndNext, current, + load, open, reviewNext, select, decideAndNext, current, + restoreCached: restore, snapshot: () => JSON.parse(JSON.stringify(queue)), route: () => location.hash, }; diff --git a/frontend/index.html b/frontend/index.html index bd96993..ee574a3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -187,7 +187,7 @@ - +