511 lines
25 KiB
JavaScript
511 lines
25 KiB
JavaScript
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;
|
|
const nodes = options.nodes || {};
|
|
let queue = { pending_count: 0, items: [] };
|
|
let reviewSnapshot = [];
|
|
let reviewIndex = -1;
|
|
let loadedAccountKey = '';
|
|
let loadEpoch = 0;
|
|
const decisionKeys = new Map();
|
|
let decisionFlight = null;
|
|
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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
})[character]);
|
|
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
|
|
const progressKey = item => 'stackchain.human-gate-review.v1:' +
|
|
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
|
|
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
|
|
String(getAccountKey() || '').trim().toLowerCase();
|
|
const setText = (node, value) => { if (node) node.textContent = value; };
|
|
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
|
|
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
|
|
|
|
function validSnapshot(value) {
|
|
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
|
|
}
|
|
|
|
function restore() {
|
|
if (!getLogin()) return null;
|
|
try { return validSnapshot(JSON.parse(storage.getItem(cacheKey()) || 'null')); } catch (_) { return null; }
|
|
}
|
|
|
|
function save(value) {
|
|
if (!getLogin()) return;
|
|
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
|
|
}
|
|
|
|
function restoreProgress(item) {
|
|
if (!getLogin() || !item) return null;
|
|
try {
|
|
const value = JSON.parse(storage.getItem(progressKey(item)) || 'null');
|
|
if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null;
|
|
return value;
|
|
} catch (_) { return null; }
|
|
}
|
|
|
|
function saveProgress(values = {}) {
|
|
const item = current();
|
|
if (!getLogin() || !item) return false;
|
|
const checklist = values.checklist || {};
|
|
const progress = {
|
|
gate_id:item.id, revision:item.revision,
|
|
checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])),
|
|
reason:String(values.reason || ''), override_reason:String(values.override_reason || ''),
|
|
};
|
|
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
|
|
}
|
|
|
|
function restorePendingDecision(item = null) {
|
|
if (!getLogin()) return null;
|
|
try {
|
|
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
|
|
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
|
|
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
|
|
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
|
|
return value;
|
|
} catch (_) { return null; }
|
|
}
|
|
|
|
function savePendingDecision(value) {
|
|
storage.setItem(decisionStorageKey(), JSON.stringify(value));
|
|
}
|
|
|
|
function clearPendingDecision(item) {
|
|
if (!restorePendingDecision(item)) return;
|
|
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
|
|
}
|
|
|
|
function valuesFromDetail() {
|
|
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
|
|
return {
|
|
checklist,
|
|
reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '',
|
|
override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '',
|
|
};
|
|
}
|
|
|
|
function updateReadiness(values = valuesFromDetail()) {
|
|
const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
|
|
.filter(key => values.checklist?.[key] === true).length;
|
|
setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete');
|
|
}
|
|
|
|
function captureProgress() {
|
|
if (!nodes.detail?.querySelectorAll) return false;
|
|
const values = valuesFromDetail();
|
|
updateReadiness(values);
|
|
return saveProgress(values);
|
|
}
|
|
|
|
nodes.detail?.addEventListener?.('input', captureProgress);
|
|
nodes.detail?.addEventListener?.('change', captureProgress);
|
|
function render() {
|
|
setText(nodes.count, String(queue.pending_count));
|
|
if (!queue.pending_count) {
|
|
setHtml(nodes.list, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>No release candidates need your decision.</span></div>');
|
|
setText(nodes.status, 'Human Gates inbox zero.');
|
|
return;
|
|
}
|
|
setText(nodes.status, queue.pending_count + (queue.pending_count === 1 ? ' gate pending.' : ' gates pending.'));
|
|
setHtml(nodes.list, queue.items.map(item =>
|
|
'<button class="human-gate-card" type="button" data-human-gate-id="' + escape(item.id) + '">' +
|
|
'<strong>' + escape(item.title) + '</strong><code>' + escape(item.candidate_hash) + '</code>' +
|
|
'<span>Priority ' + escape(item.priority ?? 0) + '</span></button>'
|
|
).join(''));
|
|
}
|
|
|
|
function setView(view) {
|
|
nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false');
|
|
nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
|
|
}
|
|
|
|
function renderHistory(preserveDetail = false) {
|
|
setView('history');
|
|
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, '');
|
|
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('') + (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.');
|
|
}));
|
|
});
|
|
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=history&limit=20'));
|
|
if (!result) throw new Error('Human Gate history response is invalid.');
|
|
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.');
|
|
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>');
|
|
return;
|
|
}
|
|
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
|
|
const pendingDecision = restorePendingDecision(item);
|
|
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
|
|
const checks = (item.checks || []).map(check =>
|
|
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
|
|
).join('');
|
|
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" target="_blank" rel="noreferrer noopener">' + escape(artifact.name) + '</a></li>').join('');
|
|
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" target="_blank" rel="noreferrer noopener">' + escape(link.label) + '</a></li>').join('');
|
|
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
|
|
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
|
|
setHtml(nodes.detail,
|
|
'<article class="human-gate-detail"><h3>' + escape(item.title) + '</h3>' +
|
|
'<p>Project <strong>' + escape(item.project) + '</strong></p>' +
|
|
'<p>Exact candidate <code>' + escape(item.candidate_hash) + '</code></p>' +
|
|
'<p>Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '</p>' +
|
|
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
|
|
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
|
|
'<h4>History</h4><ul>' + history + '</ul>' +
|
|
'<label><input type="checkbox" data-gate-checklist="exact_hash"' + checked('exact_hash') + '> Exact hash reviewed</label>' +
|
|
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
|
|
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
|
|
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
|
|
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
|
|
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
|
|
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
|
|
' response was interrupted. Verify it before making another decision.</span>' :
|
|
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
|
|
'<div class="human-gate-decision-actions">' + (pendingDecision ?
|
|
'<button type="button" data-gate-recover>Verify decision</button>' :
|
|
'<button type="button" data-gate-decision="hold">Hold & next</button><button type="button" data-gate-decision="release">Release & next</button>') +
|
|
'</div></div></article>'
|
|
);
|
|
updateReadiness();
|
|
}
|
|
|
|
async function load() {
|
|
const epoch = ++loadEpoch;
|
|
const accountKey = String(getAccountKey() || '').trim().toLowerCase();
|
|
if (accountKey !== loadedAccountKey) {
|
|
loadedAccountKey = accountKey;
|
|
queue = { pending_count: 0, items: [] };
|
|
reviewSnapshot = [];
|
|
reviewIndex = -1;
|
|
render();
|
|
}
|
|
const cached = restore();
|
|
if (cached) {
|
|
queue = cached;
|
|
render();
|
|
publish({available:true, authoritative:false, cached:true});
|
|
}
|
|
try {
|
|
const live = validSnapshot(await fetchJson('api/v1/human-gates'));
|
|
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
|
|
if (!live) throw new Error('Human Gates response is invalid.');
|
|
queue = { pending_count: live.pending_count, items: live.items.slice() };
|
|
const interrupted = restorePendingDecision();
|
|
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
|
|
const recoveryItem = {...interrupted.item, decision_recovery:true};
|
|
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
|
|
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
|
|
else {
|
|
queue.items.unshift(recoveryItem);
|
|
queue.pending_count += 1;
|
|
}
|
|
}
|
|
save(queue);
|
|
render();
|
|
publish({available:true, authoritative:true});
|
|
return queue;
|
|
} catch (error) {
|
|
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
|
|
if (!cached) {
|
|
publish({available:false, authoritative:false});
|
|
throw error;
|
|
}
|
|
setText(nodes.status, 'Offline cached gate list · reconnect before deciding.');
|
|
return queue;
|
|
}
|
|
}
|
|
|
|
function reviewNext() {
|
|
if (reviewIndex < 0) {
|
|
reviewSnapshot = queue.items.slice();
|
|
reviewIndex = 0;
|
|
}
|
|
const item = reviewSnapshot[reviewIndex] || null;
|
|
renderDetail(item);
|
|
if (item) {
|
|
const index = reviewIndex;
|
|
fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id)).then(detail => {
|
|
if (!detail || detail.id !== item.id) throw new Error('Gate detail is invalid.');
|
|
if (reviewIndex !== index || reviewSnapshot[index]?.id !== item.id) return;
|
|
reviewSnapshot[index] = detail;
|
|
renderDetail(detail);
|
|
}).catch(() => { setText(nodes.status, 'Gate detail is unavailable. Retry while online.'); });
|
|
}
|
|
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, payload) {
|
|
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
|
|
const pending = restorePendingDecision(item);
|
|
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
|
|
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));
|
|
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
|
|
decisionKeys.set(operation, key);
|
|
return { operation, key };
|
|
}
|
|
|
|
function completeDecision(item, decision, operation, receipt) {
|
|
decisionKeys.delete(operation);
|
|
clearPendingDecision(item);
|
|
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
|
|
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
|
|
queue.pending_count = Math.max(0, queue.pending_count - 1);
|
|
save(queue); render();
|
|
publish({available:true, authoritative:true, decision:true});
|
|
reviewIndex += 1;
|
|
const next = current();
|
|
if (next) reviewNext(); else renderDetail(null);
|
|
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
|
|
return { receipt, next };
|
|
}
|
|
|
|
async function postDecision(item, pending) {
|
|
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
|
|
body: JSON.stringify(pending.payload),
|
|
});
|
|
return completeDecision(item, pending.decision, pending.operation, receipt);
|
|
}
|
|
|
|
function decisionError(message, selector) {
|
|
const error = new Error(message);
|
|
error.targetSelector = selector;
|
|
return error;
|
|
}
|
|
|
|
async function decideAndNext(decision, values = {}) {
|
|
const item = current();
|
|
if (!item) throw new Error('No gate is selected.');
|
|
if (!isOnline()) throw new Error('Human Gate decisions require an online connection.');
|
|
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
|
const checklist = values.checklist || {};
|
|
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
|
|
if (decision === 'release' && !complete) {
|
|
const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true);
|
|
throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]');
|
|
}
|
|
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
|
|
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
|
|
const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', ');
|
|
throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]');
|
|
}
|
|
if (decision === 'hold' && !String(values.reason || '').trim()) {
|
|
throw decisionError('A hold reason is required.', '[data-gate-reason]');
|
|
}
|
|
const payload = {
|
|
expected_revision: item.revision, decision,
|
|
reason: String(values.reason || '').trim(),
|
|
override_reason: String(values.override_reason || '').trim(), checklist,
|
|
};
|
|
if (decisionFlight) return decisionFlight;
|
|
const operation = (async () => {
|
|
const decisionKey = idempotencyKey(item, decision, payload);
|
|
const pending = {
|
|
gate_id:item.id, revision:item.revision, decision, payload,
|
|
operation:decisionKey.operation, idempotency_key:decisionKey.key,
|
|
item:JSON.parse(JSON.stringify(item)),
|
|
};
|
|
savePendingDecision(pending);
|
|
try {
|
|
return await postDecision(item, pending);
|
|
} catch (error) {
|
|
renderDetail(item);
|
|
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
|
|
throw error;
|
|
}
|
|
})();
|
|
decisionFlight = operation;
|
|
try {
|
|
return await operation;
|
|
} finally {
|
|
if (decisionFlight === operation) decisionFlight = null;
|
|
}
|
|
}
|
|
|
|
async function recoverDecision() {
|
|
const item = current();
|
|
if (!item) throw new Error('No gate is selected.');
|
|
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
|
|
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
|
|
const pending = restorePendingDecision(item);
|
|
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
|
|
if (decisionFlight) return decisionFlight;
|
|
const operation = postDecision(item, pending);
|
|
decisionFlight = operation;
|
|
try { return await operation; }
|
|
finally { if (decisionFlight === operation) decisionFlight = null; }
|
|
}
|
|
|
|
async function submitDecision(decision) {
|
|
const values = valuesFromDetail();
|
|
updateReadiness(values);
|
|
const errorNode = nodes.detail?.querySelector?.('[data-gate-error]');
|
|
if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; }
|
|
const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []);
|
|
buttons.forEach(button => { button.disabled = true; });
|
|
try {
|
|
return await decideAndNext(decision, values);
|
|
} catch (error) {
|
|
if (errorNode) {
|
|
errorNode.textContent = error?.message || 'The decision could not be saved.';
|
|
errorNode.hidden = false;
|
|
}
|
|
let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector);
|
|
if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) {
|
|
const key = error.targetSelector.match(/"([^"]+)"/)?.[1];
|
|
target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || [])
|
|
.find(input => input.dataset.gateChecklist === key);
|
|
}
|
|
target?.scrollIntoView?.({block:'center', behavior:'smooth'});
|
|
target?.focus?.({preventScroll:true});
|
|
throw error;
|
|
} finally {
|
|
buttons.forEach(button => { button.disabled = false; });
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
location.hash = '#/my-work/human-gates';
|
|
if (nodes.panel) nodes.panel.hidden = false;
|
|
if (openFlight) return openFlight;
|
|
const operation = (async () => {
|
|
setView('pending');
|
|
await load();
|
|
reviewSnapshot = queue.items.slice();
|
|
reviewIndex = 0;
|
|
return reviewNext();
|
|
})();
|
|
openFlight = operation;
|
|
const clearFlight = () => {
|
|
if (openFlight === operation) openFlight = null;
|
|
};
|
|
operation.then(clearFlight, clearFlight);
|
|
return operation;
|
|
}
|
|
|
|
return {
|
|
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
|
|
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
|
|
setOnChange(callback) { onChange = callback; },
|
|
restoreCached: restore,
|
|
snapshot: () => JSON.parse(JSON.stringify(queue)),
|
|
route: () => location.hash,
|
|
};
|
|
}
|
|
|
|
if (typeof module !== 'undefined') module.exports = createHumanGates;
|
|
if (typeof window !== 'undefined') window.createHumanGates = createHumanGates;
|