feat: review durable Human Gate decision history (Closes #1441)
CI / lint (pull_request) Successful in 4m43s
CI / build-release (pull_request) Successful in 13s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-26 19:43:50 +00:00
1 parent efa01cf47f
commit a5d05d7590
22 files changed
+316 -34

No files matched your search

+1
View File
@@ -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)}}
+5
View File
@@ -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;
+71 -2
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -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)),
+4
View File
@@ -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>
+3
View File
@@ -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 -1
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-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;