feat: Human Gates inbox: one hash-bound review queue to zero #1416
17
README.md
17
README.md
|
|
@ -851,3 +851,20 @@ Checkpoint replacement uses writer-unique, flushed temporary files and an
|
|||
atomic rename, preventing concurrent writers from colliding or exposing partial
|
||||
JSON. Full behavior and safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
|
||||
## Human Gates inbox
|
||||
|
||||
Authenticated release producers use `POST /api/v1/human-gates/intake` with an
|
||||
`Idempotency-Key` and an immutable `"candidate_hash"`; durable account-bound
|
||||
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
|
||||
binds each queue to the upstream principal ID and login, including its offline
|
||||
browser cache. Review decisions carry `expected_revision`, a stable idempotency
|
||||
key across network retries, and return durable receipts. If producer evidence
|
||||
changes after a release or hold, the update reopens the exact hash for a new
|
||||
revision-checked decision instead of silently retaining the old outcome.
|
||||
New hashes mark older pending candidates `superseded` without removing their audit
|
||||
history. See [`docs/human-gates.md`](docs/human-gates.md) for the complete
|
||||
producer body, decision API, and **Telegram coalescing contract**. Lock-screen
|
||||
and Telegram notifications expose count and route only, using
|
||||
`#/my-work/human-gates`; they never expose project, title, candidate hash,
|
||||
artifacts, checks, provenance, reasons, or receipts.
|
||||
|
|
|
|||
39
docs/human-gates.md
Normal file
39
docs/human-gates.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Human Gates producer and notification contract
|
||||
|
||||
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
|
||||
|
||||
## Producer intake
|
||||
|
||||
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "release-bot",
|
||||
"project": "stackchain/dashboard",
|
||||
"candidate_hash": "abc123",
|
||||
"title": "Dashboard candidate",
|
||||
"priority": 7,
|
||||
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
|
||||
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
|
||||
"checks": [{"name": "browser", "state": "success", "required": true}],
|
||||
"score": {"value": 92, "provenance": "release-evaluator/v2"},
|
||||
"provenance": {"producer": "release-bot", "run_id": "run-9"}
|
||||
}
|
||||
```
|
||||
|
||||
The immutable identity is authenticated account + `source` + `project` + `candidate_hash`. Retrying the same key and body returns the same gate. Reusing a key for different facts, or redefining an existing candidate hash, returns 409. A newer hash from the same source/project atomically marks older pending candidates `superseded`; detail history remains available for audit. Configure durable storage with `STACKCHAIN_HUMAN_GATE_DB` (default: `$STACKCHAIN_STATE_DIR/human-gates.sqlite3`).
|
||||
|
||||
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
|
||||
|
||||
## Telegram coalescing contract
|
||||
|
||||
Telegram or lock-screen adapters MUST coalesce pending changes per authenticated account and expose **count and route only**:
|
||||
|
||||
```json
|
||||
{
|
||||
"pending_count": 3,
|
||||
"route": "#/my-work/human-gates"
|
||||
}
|
||||
```
|
||||
|
||||
The notification text may say “3 Human Gates pending” and provide the route. It MUST NOT include project names, candidate hash values, titles, artifact/link URLs, checks, scores, provenance, decision history, superseded candidate facts, reasons, or receipts. Multiple intake/supersession events before delivery replace the pending notification with the latest count rather than emitting one message per candidate. A transition to zero clears the outstanding notification; it does not send candidate detail. Producers and adapters must fetch current state after authenticated route open rather than treating notification delivery as an action authorization.
|
||||
|
|
@ -1612,3 +1612,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.create-pull-mode label{display:flex;align-items:center;gap:7px}
|
||||
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
|
||||
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
|
||||
|
||||
.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-detail>div{display:grid;grid-template-columns:1fr 1fr;gap:10px}.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}
|
||||
@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)}}
|
||||
|
|
@ -392,6 +392,7 @@
|
|||
let editingOutboxId = null;
|
||||
let confirmedOwnerLogin = '';
|
||||
let planningOwnerLogin = '';
|
||||
let planningOwnerAccountKey = '';
|
||||
let activeFlushLogin = '';
|
||||
let rR = null;
|
||||
function rRC() {
|
||||
|
|
@ -634,6 +635,46 @@
|
|||
return payload;
|
||||
}
|
||||
|
||||
const humanGates = createHumanGates({
|
||||
storage:localStorage,
|
||||
getLogin:()=>planningOwnerLogin,
|
||||
getAccountKey:()=>planningOwnerAccountKey,
|
||||
isOnline:()=>navigator.onLine,
|
||||
location:window.location,
|
||||
fetchJson:fetchReviewJson,
|
||||
nodes:{
|
||||
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
|
||||
status:qs('#human-gates-status'), panel:qs('#human-gates'),
|
||||
detail:qs('#human-gate-detail'),
|
||||
},
|
||||
});
|
||||
const openHumanGates = () => humanGates.open().catch(error => {
|
||||
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
|
||||
});
|
||||
qs('#open-human-gates').addEventListener('click', openHumanGates);
|
||||
qs('#close-human-gates').addEventListener('click', () => {
|
||||
qs('#human-gates').hidden = true;
|
||||
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
|
||||
});
|
||||
qs('#human-gates-list').addEventListener('click', event => {
|
||||
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;
|
||||
if (!decision) return;
|
||||
const detail = qs('#human-gate-detail');
|
||||
const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
|
||||
humanGates.decideAndNext(decision, {
|
||||
checklist,
|
||||
reason:detail.querySelector('[data-gate-reason]')?.value || '',
|
||||
override_reason:detail.querySelector('[data-gate-override]')?.value || '',
|
||||
}).catch(error => { qs('#human-gates-status').textContent = error.message; });
|
||||
});
|
||||
humanGates.load().catch(() => {});
|
||||
if (window.location.hash === '#/my-work/human-gates') openHumanGates();
|
||||
|
||||
function syncCompletedFiledReviews() {
|
||||
if (!planningOwnerLogin) return Promise.resolve(false);
|
||||
if (completedFiledSyncFlight) return completedFiledSyncFlight;
|
||||
|
|
@ -5526,6 +5567,9 @@
|
|||
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 : '';
|
||||
timerView.render();
|
||||
updatePlanningAvailability();
|
||||
if (planningOwnerLogin) {
|
||||
syncPendingTomorrow();
|
||||
|
|
@ -7843,6 +7887,8 @@
|
|||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
restoreReleaseReceipt();
|
||||
planningOwnerLogin = confirmedOwnerLogin;
|
||||
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
|
||||
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
|
||||
interruptionPrompt.restore();
|
||||
updatePlanningAvailability();
|
||||
syncPendingTomorrow();
|
||||
|
|
|
|||
207
frontend/human-gates.js
Normal file
207
frontend/human-gates.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
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;
|
||||
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[character]);
|
||||
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; };
|
||||
|
||||
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 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 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 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) + '" rel="noreferrer">' + escape(artifact.name) + '</a></li>').join('');
|
||||
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" rel="noreferrer">' + 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"> Exact hash reviewed</label>' +
|
||||
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"> Artifacts reviewed</label>' +
|
||||
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"> Provenance reviewed</label>' +
|
||||
'<label>Hold reason<textarea data-gate-reason></textarea></label>' +
|
||||
'<label>Override reason<textarea data-gate-override></textarea></label>' +
|
||||
'<div><button type="button" data-gate-decision="release">Release & next</button>' +
|
||||
'<button type="button" data-gate-decision="hold">Hold & next</button></div></article>'
|
||||
);
|
||||
}
|
||||
|
||||
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(); }
|
||||
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() };
|
||||
save(queue);
|
||||
render();
|
||||
return queue;
|
||||
} catch (error) {
|
||||
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
|
||||
if (!cached) 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);
|
||||
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 };
|
||||
}
|
||||
|
||||
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) throw new Error('Complete the release checklist before deciding.');
|
||||
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
|
||||
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
|
||||
throw new Error('An explicit override reason is required for unmet required checks.');
|
||||
}
|
||||
if (decision === 'hold' && !String(values.reason || '').trim()) throw new Error('A hold reason is required.');
|
||||
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 receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
|
||||
method: 'POST',
|
||||
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();
|
||||
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 };
|
||||
})();
|
||||
decisionFlight = operation;
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
if (decisionFlight === operation) decisionFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
location.hash = '#/my-work/human-gates';
|
||||
if (nodes.panel) nodes.panel.hidden = false;
|
||||
return load().then(() => reviewNext());
|
||||
}
|
||||
|
||||
return {
|
||||
load, open, reviewNext, select, decideAndNext, current,
|
||||
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;
|
||||
|
|
@ -187,7 +187,16 @@
|
|||
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
|
||||
<button class="find-work-action" id="find-work" type="button">Find work</button>
|
||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||
<button class="human-gates-launcher" id="open-human-gates" type="button">Review next <span id="human-gates-count">0</span></button>
|
||||
</div>
|
||||
<section id="human-gates" class="human-gates" aria-labelledby="human-gates-heading" hidden>
|
||||
<div class="human-gates-header">
|
||||
<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 id="human-gates-list" class="human-gates-list"></div>
|
||||
<div id="human-gate-detail" class="human-gate-detail-host"></div>
|
||||
</section>
|
||||
<details class="work-settings">
|
||||
<summary id="work-settings-toggle">Queue & settings · <span id="active-work-queue">All</span></summary>
|
||||
<div class="work-settings-panel">
|
||||
|
|
@ -2401,6 +2410,7 @@
|
|||
<script src="static/mobile-plan-today-nav.js"></script>
|
||||
<script src="static/mobile-find-work-nav.js"></script>
|
||||
<script src="static/workspace-bootstrap.js"></script>
|
||||
<script src="static/human-gates.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ const SHELL = [
|
|||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/dashboard.css',
|
||||
BASE + 'static/dashboard.js',
|
||||
BASE + 'static/human-gates.js',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
|
|
|
|||
330
src/human_gate_store.py
Normal file
330
src/human_gate_store.py
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"""Durable, account-bound human release gate inbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
|
||||
|
||||
_HASH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_PROJECT = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_STATES = {"pending", "released", "held", "superseded"}
|
||||
_CHECKLIST = {"exact_hash", "artifacts_reviewed", "provenance_reviewed"}
|
||||
|
||||
|
||||
class GateConflict(RuntimeError):
|
||||
"""The gate or idempotency revision no longer matches."""
|
||||
|
||||
|
||||
class GateValidationError(ValueError):
|
||||
"""The producer or reviewer payload is not safe to persist."""
|
||||
|
||||
|
||||
class GateNotFound(LookupError):
|
||||
"""No gate exists for this account."""
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _fingerprint(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode()).hexdigest()
|
||||
|
||||
|
||||
class HumanGateStore:
|
||||
def __init__(self, path: str | Path, *, clock: Callable[[], float]):
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = connect_private_sqlite(self.path, timeout=2.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS human_gates (
|
||||
id TEXT PRIMARY KEY, login TEXT NOT NULL, source TEXT NOT NULL,
|
||||
project TEXT NOT NULL, candidate_hash TEXT NOT NULL,
|
||||
title TEXT NOT NULL, priority INTEGER NOT NULL,
|
||||
payload_json TEXT NOT NULL, state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL, created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL, superseded_by TEXT,
|
||||
decision_reason TEXT NOT NULL DEFAULT '',
|
||||
override_reason TEXT NOT NULL DEFAULT '',
|
||||
checklist_json TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE(login, source, project, candidate_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS human_gates_queue
|
||||
ON human_gates(login, state, priority DESC, created_at, id);
|
||||
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,
|
||||
PRIMARY KEY(login, idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS human_gate_history (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, gate_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, at REAL NOT NULL, details_json TEXT NOT NULL
|
||||
);
|
||||
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,
|
||||
gate_id TEXT NOT NULL, receipt_json TEXT NOT NULL,
|
||||
UNIQUE(login, idempotency_key)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _login(value: str) -> str:
|
||||
value = str(value).strip().lower()
|
||||
if not value or len(value) > 255:
|
||||
raise GateValidationError("login is required")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _key(value: str) -> str:
|
||||
value = str(value).strip()
|
||||
if not value or len(value) > 128:
|
||||
raise GateValidationError("Idempotency key is required")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _candidate(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise GateValidationError("Candidate must be an object")
|
||||
source = str(raw.get("source", "")).strip()
|
||||
project = str(raw.get("project", "")).strip()
|
||||
candidate_hash = str(raw.get("candidate_hash", "")).strip()
|
||||
title = " ".join(str(raw.get("title", "")).split())
|
||||
priority = raw.get("priority", 0)
|
||||
if not source or len(source) > 128:
|
||||
raise GateValidationError("source is invalid")
|
||||
if not _PROJECT.fullmatch(project):
|
||||
raise GateValidationError("project is invalid")
|
||||
if not _HASH.fullmatch(candidate_hash):
|
||||
raise GateValidationError("candidate hash is invalid")
|
||||
if not title or len(title) > 300:
|
||||
raise GateValidationError("title is invalid")
|
||||
if isinstance(priority, bool) or not isinstance(priority, int) or not 0 <= priority <= 100:
|
||||
raise GateValidationError("priority is invalid")
|
||||
normalized = {
|
||||
"source": source, "project": project, "candidate_hash": candidate_hash,
|
||||
"title": title, "priority": priority,
|
||||
"artifacts": HumanGateStore._references(raw.get("artifacts", []), "name"),
|
||||
"links": HumanGateStore._references(raw.get("links", []), "label"),
|
||||
"checks": HumanGateStore._checks(raw.get("checks", [])),
|
||||
"score": raw.get("score") if isinstance(raw.get("score"), dict) else {},
|
||||
"provenance": raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {},
|
||||
}
|
||||
if len(_canonical(normalized)) > 100_000:
|
||||
raise GateValidationError("Candidate payload is too large")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _references(raw: object, label: str) -> list[dict]:
|
||||
if not isinstance(raw, list) or len(raw) > 50:
|
||||
raise GateValidationError("references are invalid")
|
||||
result = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise GateValidationError("reference is invalid")
|
||||
name, url = str(item.get(label, "")).strip(), str(item.get("url", "")).strip()
|
||||
if not name or len(name) > 200 or not url.startswith("https://") or len(url) > 2048:
|
||||
raise GateValidationError("reference is invalid")
|
||||
result.append({label: name, "url": url})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _checks(raw: object) -> list[dict]:
|
||||
if not isinstance(raw, list) or len(raw) > 100:
|
||||
raise GateValidationError("checks are invalid")
|
||||
result = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise GateValidationError("check is invalid")
|
||||
name, state = str(item.get("name", "")).strip(), item.get("state")
|
||||
if not name or len(name) > 200 or state not in {"success", "failure", "pending", "skipped"}:
|
||||
raise GateValidationError("check is invalid")
|
||||
result.append({"name": name, "state": state, "required": bool(item.get("required", True))})
|
||||
return result
|
||||
|
||||
def _history(self, connection: sqlite3.Connection, gate_id: str) -> list[dict]:
|
||||
return [
|
||||
{"sequence": row[0], "action": row[1], "at": row[2], **json.loads(row[3])}
|
||||
for row in connection.execute(
|
||||
"SELECT sequence, action, at, details_json FROM human_gate_history WHERE gate_id=? ORDER BY sequence",
|
||||
(gate_id,),
|
||||
)
|
||||
]
|
||||
|
||||
def _present(self, connection: sqlite3.Connection, row: sqlite3.Row, *, history: bool = False) -> dict:
|
||||
payload = json.loads(row["payload_json"])
|
||||
item = {
|
||||
"id": row["id"], **payload, "state": row["state"], "revision": row["revision"],
|
||||
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
||||
"superseded_by": row["superseded_by"],
|
||||
}
|
||||
if row["state"] in {"released", "held"}:
|
||||
item.update({
|
||||
"reason": row["decision_reason"], "override_reason": row["override_reason"],
|
||||
"checklist": json.loads(row["checklist_json"]),
|
||||
})
|
||||
if history:
|
||||
item["history"] = self._history(connection, row["id"])
|
||||
return item
|
||||
|
||||
def has_intake_key(self, login: str, idempotency_key: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
|
||||
(self._login(login), self._key(idempotency_key)),
|
||||
).fetchone() is not None
|
||||
|
||||
def intake(self, login: str, raw: dict, *, idempotency_key: str) -> dict:
|
||||
login, key, payload = self._login(login), self._key(idempotency_key), self._candidate(raw)
|
||||
fingerprint = _fingerprint(payload)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
prior = connection.execute(
|
||||
"SELECT fingerprint, gate_id FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
|
||||
(login, key),
|
||||
).fetchone()
|
||||
if prior:
|
||||
if prior["fingerprint"] != fingerprint:
|
||||
raise GateConflict("Idempotency key was already used for another candidate")
|
||||
row = connection.execute("SELECT * FROM human_gates WHERE id=? AND login=?", (prior["gate_id"], login)).fetchone()
|
||||
return self._present(connection, row, history=True)
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM human_gates WHERE login=? AND source=? AND project=? AND candidate_hash=?",
|
||||
(login, payload["source"], payload["project"], payload["candidate_hash"]),
|
||||
).fetchone()
|
||||
if existing:
|
||||
old_payload = json.loads(existing["payload_json"])
|
||||
immutable_old = {key: value for key, value in old_payload.items() if key != "checks"}
|
||||
immutable_new = {key: value for key, value in payload.items() if key != "checks"}
|
||||
if immutable_old != immutable_new:
|
||||
raise GateConflict("Candidate hash is already bound to different facts")
|
||||
if old_payload != payload:
|
||||
if existing["state"] == "superseded":
|
||||
raise GateConflict("Candidate hash was superseded by a newer candidate")
|
||||
now = float(self.clock())
|
||||
revision = existing["revision"] + 1
|
||||
reopened = existing["state"] in {"released", "held"}
|
||||
state = "pending" if reopened else existing["state"]
|
||||
action = "reopened" if reopened else "updated"
|
||||
connection.execute(
|
||||
"UPDATE human_gates SET payload_json=?, state=?, revision=?, updated_at=?, decision_reason='', override_reason='', checklist_json='{}' WHERE id=?",
|
||||
(_canonical(payload), state, revision, now, existing["id"]),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)",
|
||||
(existing["id"], action, now, _canonical({"candidate_hash": payload["candidate_hash"]})),
|
||||
)
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM human_gates WHERE id=? AND login=?",
|
||||
(existing["id"], login),
|
||||
).fetchone()
|
||||
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, existing["id"]))
|
||||
return self._present(connection, existing, history=True)
|
||||
now, gate_id = float(self.clock()), str(uuid.uuid4())
|
||||
old_rows = connection.execute(
|
||||
"SELECT id, revision FROM human_gates WHERE login=? AND source=? AND project=? AND state='pending'",
|
||||
(login, payload["source"], payload["project"]),
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"INSERT INTO human_gates(id,login,source,project,candidate_hash,title,priority,payload_json,state,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?, 'pending',1,?,?)",
|
||||
(gate_id, login, payload["source"], payload["project"], payload["candidate_hash"], payload["title"], payload["priority"], _canonical(payload), now, now),
|
||||
)
|
||||
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, "intake", now, _canonical({"candidate_hash": payload["candidate_hash"]})))
|
||||
for old in old_rows:
|
||||
connection.execute("UPDATE human_gates SET state='superseded', revision=?, updated_at=?, superseded_by=? WHERE id=? AND state='pending'", (old["revision"] + 1, now, gate_id, old["id"]))
|
||||
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (old["id"], "superseded", now, _canonical({"superseded_by": gate_id, "candidate_hash": payload["candidate_hash"]})))
|
||||
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, gate_id))
|
||||
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:
|
||||
login = self._login(login)
|
||||
if state not in _STATES and state != "all":
|
||||
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]
|
||||
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()
|
||||
return {"pending_count": pending_count, "items": [self._present(connection, row) for row in rows]}
|
||||
|
||||
def detail(self, login: str, gate_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (self._login(login), gate_id)).fetchone()
|
||||
if row is None:
|
||||
raise GateNotFound("Gate not found")
|
||||
return self._present(connection, row, history=True)
|
||||
|
||||
def decide(self, login: str, gate_id: str, *, expected_revision: int, decision: str, reason: str, override_reason: str, checklist: dict, idempotency_key: str) -> dict:
|
||||
login, key = self._login(login), self._key(idempotency_key)
|
||||
reason, override_reason = str(reason).strip(), str(override_reason).strip()
|
||||
if decision not in {"release", "hold"}:
|
||||
raise GateValidationError("decision is invalid")
|
||||
if decision == "hold" and not reason:
|
||||
raise GateValidationError("Hold reason is required")
|
||||
if decision == "release" and (
|
||||
not isinstance(checklist, dict)
|
||||
or set(checklist) != _CHECKLIST
|
||||
or not all(value is True for value in checklist.values())
|
||||
):
|
||||
raise GateValidationError("A complete release checklist is required")
|
||||
if decision == "hold":
|
||||
checklist = {}
|
||||
request = {"gate_id": gate_id, "expected_revision": expected_revision, "decision": decision, "reason": reason, "override_reason": override_reason, "checklist": checklist}
|
||||
fingerprint = _fingerprint(request)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
prior = connection.execute("SELECT fingerprint, receipt_json FROM human_gate_receipts WHERE login=? AND idempotency_key=?", (login, key)).fetchone()
|
||||
if prior:
|
||||
if prior["fingerprint"] != fingerprint:
|
||||
raise GateConflict("Idempotency key was already used for another decision")
|
||||
return json.loads(prior["receipt_json"])
|
||||
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (login, gate_id)).fetchone()
|
||||
if row is None:
|
||||
raise GateNotFound("Gate not found")
|
||||
if row["state"] != "pending" or row["revision"] != expected_revision:
|
||||
raise GateConflict("Gate revision is stale")
|
||||
payload = json.loads(row["payload_json"])
|
||||
unmet = [check["name"] for check in payload["checks"] if check["required"] and check["state"] != "success"]
|
||||
if decision == "release" and unmet and not override_reason:
|
||||
raise GateValidationError("An explicit override reason is required for unmet checks")
|
||||
now, receipt_id, revision = float(self.clock()), str(uuid.uuid4()), row["revision"] + 1
|
||||
state = "released" if decision == "release" else "held"
|
||||
connection.execute("UPDATE human_gates SET state=?,revision=?,updated_at=?,decision_reason=?,override_reason=?,checklist_json=? WHERE id=?", (state, revision, now, reason, override_reason, _canonical(checklist), gate_id))
|
||||
receipt = {"receipt_id": receipt_id, "gate_id": gate_id, "candidate_hash": row["candidate_hash"], "state": state, "revision": revision, "decided_at": now, "reason": reason, "override_reason": override_reason, "checklist": checklist, "unmet_required_checks": unmet}
|
||||
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, state, now, _canonical({"receipt_id": receipt_id, "reason": reason, "override_reason": override_reason, "unmet_required_checks": unmet})))
|
||||
connection.execute("INSERT INTO human_gate_receipts VALUES (?,?,?,?,?,?)", (receipt_id, login, key, fingerprint, gate_id, _canonical(receipt)))
|
||||
return receipt
|
||||
|
||||
def has_receipt_key(self, login: str, idempotency_key: str) -> bool:
|
||||
with self._connect() as connection:
|
||||
return connection.execute(
|
||||
"SELECT 1 FROM human_gate_receipts WHERE login=? AND idempotency_key=?",
|
||||
(self._login(login), self._key(idempotency_key)),
|
||||
).fetchone() is not None
|
||||
|
||||
def receipt(self, login: str, receipt_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT receipt_json FROM human_gate_receipts WHERE login=? AND receipt_id=?", (self._login(login), receipt_id)).fetchone()
|
||||
if row is None:
|
||||
raise GateNotFound("Receipt not found")
|
||||
return json.loads(row[0])
|
||||
128
src/main.py
128
src/main.py
|
|
@ -42,6 +42,12 @@ from src.gitea_proxy import (
|
|||
repos,
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
|
||||
from src.human_gate_store import (
|
||||
GateConflict,
|
||||
GateNotFound,
|
||||
GateValidationError,
|
||||
HumanGateStore,
|
||||
)
|
||||
from src.image_sanitizer import sanitize_image
|
||||
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||
from src.live_snapshot_store import (
|
||||
|
|
@ -441,6 +447,14 @@ class ReadinessPayloadError(ValueError):
|
|||
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
||||
|
||||
|
||||
class HumanGateDecision(BaseModel):
|
||||
expected_revision: PositiveInt
|
||||
decision: Literal["release", "hold"]
|
||||
reason: str = Field(default="", max_length=2_000)
|
||||
override_reason: str = Field(default="", max_length=2_000)
|
||||
checklist: dict[str, bool]
|
||||
|
||||
|
||||
async def _upstream_identity() -> tuple[int, str]:
|
||||
upstream = await current_user()
|
||||
principal_id = upstream.get("id") if isinstance(upstream, dict) else None
|
||||
|
|
@ -589,6 +603,17 @@ class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget):
|
|||
pass
|
||||
|
||||
|
||||
def _human_gate_store() -> HumanGateStore:
|
||||
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
|
||||
return HumanGateStore(
|
||||
os.getenv(
|
||||
"STACKCHAIN_HUMAN_GATE_DB",
|
||||
os.path.join(state_dir, "human-gates.sqlite3"),
|
||||
),
|
||||
clock=time.time,
|
||||
)
|
||||
|
||||
|
||||
def _passkey_store() -> PasskeyStore:
|
||||
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
|
||||
database = os.getenv(
|
||||
|
|
@ -1719,7 +1744,7 @@ async def require_operator_session(request: Request, call_next):
|
|||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
|
|
@ -1791,6 +1816,107 @@ def health() -> dict[str, str]:
|
|||
return {"status": "ok", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
async def _human_gate_login(request: Request) -> str:
|
||||
session = getattr(request.state, "dashboard_session", None)
|
||||
if session is not None and session.principal_login and session.principal_id:
|
||||
return f"{session.principal_id}:{session.principal_login}"
|
||||
principal_id, login = await _upstream_identity()
|
||||
return f"{principal_id}:{login}"
|
||||
|
||||
|
||||
def _gate_error(error: Exception) -> HTTPException:
|
||||
if isinstance(error, sqlite3.Error):
|
||||
return HTTPException(status_code=503, detail="Human Gates are temporarily unavailable")
|
||||
if isinstance(error, GateNotFound):
|
||||
return HTTPException(status_code=404, detail=str(error))
|
||||
if isinstance(error, GateConflict):
|
||||
return HTTPException(status_code=409, detail=str(error))
|
||||
return HTTPException(status_code=422, detail=str(error))
|
||||
|
||||
|
||||
@app.post("/api/v1/human-gates/intake")
|
||||
async def intake_human_gate(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
|
||||
):
|
||||
login = await _human_gate_login(request)
|
||||
try:
|
||||
store = _human_gate_store()
|
||||
existed = await asyncio.to_thread(store.has_intake_key, login, idempotency_key)
|
||||
gate = await asyncio.to_thread(
|
||||
store.intake, login, payload, idempotency_key=idempotency_key
|
||||
)
|
||||
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
|
||||
raise _gate_error(error) from error
|
||||
return JSONResponse(
|
||||
gate, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/human-gates")
|
||||
async def list_human_gates(
|
||||
request: Request,
|
||||
state: str = Query(default="pending"),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
):
|
||||
login = await _human_gate_login(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(_human_gate_store().list, login, state=state, limit=limit)
|
||||
except (GateValidationError, sqlite3.Error) as error:
|
||||
raise _gate_error(error) from error
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.get("/api/v1/human-gates/{gate_id}")
|
||||
async def human_gate_detail(gate_id: str, request: Request):
|
||||
login = await _human_gate_login(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(_human_gate_store().detail, login, gate_id)
|
||||
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
|
||||
raise _gate_error(error) from error
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post("/api/v1/human-gates/{gate_id}/decision")
|
||||
async def decide_human_gate(
|
||||
gate_id: str,
|
||||
payload: HumanGateDecision,
|
||||
request: Request,
|
||||
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
|
||||
):
|
||||
login = await _human_gate_login(request)
|
||||
try:
|
||||
store = _human_gate_store()
|
||||
existed = await asyncio.to_thread(store.has_receipt_key, login, idempotency_key)
|
||||
receipt = await asyncio.to_thread(
|
||||
store.decide,
|
||||
login,
|
||||
gate_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
decision=payload.decision,
|
||||
reason=payload.reason,
|
||||
override_reason=payload.override_reason,
|
||||
checklist=payload.checklist,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
|
||||
raise _gate_error(error) from error
|
||||
return JSONResponse(
|
||||
receipt, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/human-gate-receipts/{receipt_id}")
|
||||
async def human_gate_receipt(receipt_id: str, request: Request):
|
||||
login = await _human_gate_login(request)
|
||||
try:
|
||||
result = await asyncio.to_thread(_human_gate_store().receipt, login, receipt_id)
|
||||
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
|
||||
raise _gate_error(error) from error
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post("/api/v1/session")
|
||||
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
|
|
|
|||
129
tests/test_human_gate_api.py
Normal file
129
tests/test_human_gate_api.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.human_gate_store import HumanGateStore
|
||||
|
||||
|
||||
CANDIDATE = {
|
||||
"source": "release-bot", "project": "stackchain/dashboard", "candidate_hash": "abc123",
|
||||
"title": "Release candidate", "priority": 7,
|
||||
"artifacts": [{"name": "manifest", "url": "https://forge.example/manifest"}],
|
||||
"links": [{"label": "pull", "url": "https://forge.example/pulls/1"}],
|
||||
"checks": [{"name": "unit", "state": "success", "required": True}],
|
||||
"score": {"value": 98, "provenance": "eval/v1"},
|
||||
"provenance": {"run": "9"},
|
||||
}
|
||||
CHECKLIST = {"exact_hash": True, "artifacts_reviewed": True, "provenance_reviewed": True}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gate_api(monkeypatch, tmp_path):
|
||||
ticks = iter(range(100, 120))
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: next(ticks))
|
||||
monkeypatch.setattr(main, "_human_gate_store", lambda: store, raising=False)
|
||||
|
||||
async def identity():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", identity)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_intake_list_and_detail_are_account_bound_and_no_store(gate_api):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post("/api/v1/human-gates/intake", json=CANDIDATE, headers={"Idempotency-Key": "run-9"})
|
||||
repeated = await client.post("/api/v1/human-gates/intake", json=CANDIDATE, headers={"Idempotency-Key": "run-9"})
|
||||
listing = await client.get("/api/v1/human-gates")
|
||||
detail = await client.get(f"/api/v1/human-gates/{created.json()['id']}")
|
||||
|
||||
assert created.status_code == 201
|
||||
assert repeated.status_code == 200
|
||||
assert repeated.json()["id"] == created.json()["id"]
|
||||
assert listing.json()["pending_count"] == 1
|
||||
assert detail.json()["candidate_hash"] == "abc123"
|
||||
assert detail.json()["history"][0]["action"] == "intake"
|
||||
assert all(response.headers["cache-control"] == "no-store" for response in (created, repeated, listing, detail))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decision_requires_revision_and_returns_durable_receipt(gate_api):
|
||||
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-9")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
payload = {"expected_revision": gate["revision"], "decision": "release", "reason": "", "override_reason": "", "checklist": CHECKLIST}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
decided = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-9"})
|
||||
repeated = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-9"})
|
||||
receipt = await client.get(f"/api/v1/human-gate-receipts/{decided.json()['receipt_id']}")
|
||||
stale = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-10"})
|
||||
|
||||
assert decided.status_code == 201
|
||||
assert repeated.status_code == 200
|
||||
assert receipt.json() == decided.json()
|
||||
assert stale.status_code == 409
|
||||
assert all(response.headers["cache-control"] == "no-store" for response in (decided, repeated, receipt, stale))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recycled_login_cannot_read_another_principal_gates(monkeypatch, tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
|
||||
monkeypatch.setattr(main, "_human_gate_store", lambda: store, raising=False)
|
||||
principal = {"id": 1, "login": "timmy"}
|
||||
|
||||
async def identity():
|
||||
return principal.copy()
|
||||
|
||||
monkeypatch.setattr(main, "current_user", identity)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/human-gates/intake", json=CANDIDATE,
|
||||
headers={"Idempotency-Key": "principal-1"},
|
||||
)
|
||||
principal["id"] = 2
|
||||
listing = await client.get("/api/v1/human-gates")
|
||||
|
||||
assert created.status_code == 201
|
||||
assert listing.json()["pending_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gate_store_failure_is_sanitized_no_store(monkeypatch):
|
||||
def unavailable():
|
||||
raise sqlite3.OperationalError("sensitive database path")
|
||||
|
||||
monkeypatch.setattr(main, "_human_gate_store", unavailable)
|
||||
|
||||
async def identity():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", identity)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/human-gates")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"detail": "Human Gates are temporarily unavailable"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gate_mutations_require_idempotency_key_and_validate_override(gate_api):
|
||||
failing = {**CANDIDATE, "candidate_hash": "fail123", "checks": [{"name": "browser", "state": "failure", "required": True}]}
|
||||
gate = gate_api.intake("1:timmy", failing, idempotency_key="run-fail")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
missing_key = await client.post("/api/v1/human-gates/intake", json=CANDIDATE)
|
||||
no_override = await client.post(
|
||||
f"/api/v1/human-gates/{gate['id']}/decision",
|
||||
json={"expected_revision": 1, "decision": "release", "reason": "", "override_reason": "", "checklist": CHECKLIST},
|
||||
headers={"Idempotency-Key": "decision-fail"},
|
||||
)
|
||||
|
||||
assert missing_key.status_code == 422
|
||||
assert no_override.status_code == 422
|
||||
assert "override reason" in no_override.json()["detail"]
|
||||
142
tests/test_human_gate_store.py
Normal file
142
tests/test_human_gate_store.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.human_gate_store import GateConflict, GateValidationError, HumanGateStore
|
||||
|
||||
|
||||
def candidate(hash_value="abc123", *, priority=2, check_state="success"):
|
||||
return {
|
||||
"source": "release-bot",
|
||||
"project": "stackchain/dashboard",
|
||||
"candidate_hash": hash_value,
|
||||
"title": "Dashboard candidate",
|
||||
"priority": priority,
|
||||
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
|
||||
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
|
||||
"checks": [{"name": "browser", "state": check_state, "required": True}],
|
||||
"score": {"value": 92, "provenance": "release-evaluator/v2"},
|
||||
"provenance": {"producer": "release-bot", "run_id": "run-9"},
|
||||
}
|
||||
|
||||
|
||||
def checklist():
|
||||
return {"exact_hash": True, "artifacts_reviewed": True, "provenance_reviewed": True}
|
||||
|
||||
|
||||
def test_intake_is_account_bound_idempotent_and_survives_restart(tmp_path):
|
||||
path = tmp_path / "gates.sqlite3"
|
||||
store = HumanGateStore(path, clock=lambda: 100)
|
||||
first = store.intake("timmy", candidate(), idempotency_key="producer-9")
|
||||
repeated = store.intake("timmy", candidate(), idempotency_key="producer-9")
|
||||
|
||||
restarted = HumanGateStore(path, clock=lambda: 101)
|
||||
|
||||
assert repeated == first
|
||||
assert restarted.detail("timmy", first["id"])["candidate_hash"] == "abc123"
|
||||
assert restarted.list("alex")["pending_count"] == 0
|
||||
with sqlite3.connect(path) as connection:
|
||||
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
||||
|
||||
|
||||
def test_new_hash_supersedes_only_older_pending_candidate_without_losing_audit(tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101]).__next__)
|
||||
old = store.intake("timmy", candidate("abc123"), idempotency_key="run-1")
|
||||
new = store.intake("timmy", candidate("def456"), idempotency_key="run-2")
|
||||
|
||||
old_detail = store.detail("timmy", old["id"])
|
||||
queue = store.list("timmy")
|
||||
|
||||
assert old_detail["state"] == "superseded"
|
||||
assert old_detail["superseded_by"] == new["id"]
|
||||
assert old_detail["history"][-1]["action"] == "superseded"
|
||||
assert queue["pending_count"] == 1
|
||||
assert queue["items"][0]["candidate_hash"] == "def456"
|
||||
|
||||
|
||||
def test_pending_queue_orders_highest_priority_then_oldest(tmp_path):
|
||||
clock = iter([100, 101, 102]).__next__
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=clock)
|
||||
low = store.intake("timmy", {**candidate("a1", priority=1), "project": "p/one"}, idempotency_key="1")
|
||||
oldest_high = store.intake("timmy", {**candidate("b2", priority=5), "project": "p/two"}, idempotency_key="2")
|
||||
newest_high = store.intake("timmy", {**candidate("c3", priority=5), "project": "p/three"}, idempotency_key="3")
|
||||
|
||||
assert [item["id"] for item in store.list("timmy")["items"]] == [oldest_high["id"], newest_high["id"], low["id"]]
|
||||
|
||||
|
||||
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__)
|
||||
gate = store.intake("timmy", candidate(), idempotency_key="run-1")
|
||||
|
||||
receipt = store.decide(
|
||||
"timmy", gate["id"], expected_revision=gate["revision"], decision="release",
|
||||
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
|
||||
)
|
||||
repeated = store.decide(
|
||||
"timmy", gate["id"], expected_revision=gate["revision"], decision="release",
|
||||
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
|
||||
)
|
||||
|
||||
assert repeated == receipt
|
||||
assert receipt["state"] == "released"
|
||||
assert receipt["candidate_hash"] == "abc123"
|
||||
assert HumanGateStore(path, clock=lambda: 999).receipt("timmy", receipt["receipt_id"]) == receipt
|
||||
with pytest.raises(GateConflict):
|
||||
store.decide("timmy", gate["id"], expected_revision=gate["revision"], decision="hold", reason="later", override_reason="", checklist=checklist(), idempotency_key="decision-2")
|
||||
|
||||
|
||||
def test_hold_requires_reason_and_release_requires_checklist_and_override_for_unmet_checks(tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
|
||||
gate = store.intake("timmy", candidate(check_state="failure"), idempotency_key="run-1")
|
||||
|
||||
with pytest.raises(GateValidationError, match="Hold reason"):
|
||||
store.decide("timmy", gate["id"], expected_revision=1, decision="hold", reason="", override_reason="", checklist={}, idempotency_key="d1")
|
||||
with pytest.raises(GateValidationError, match="checklist"):
|
||||
store.decide("timmy", gate["id"], expected_revision=1, decision="release", reason="", override_reason="needed", checklist={"exact_hash": True}, idempotency_key="d2")
|
||||
with pytest.raises(GateValidationError, match="override reason"):
|
||||
store.decide("timmy", gate["id"], expected_revision=1, decision="release", reason="", override_reason="", checklist=checklist(), idempotency_key="d3")
|
||||
|
||||
receipt = store.decide("timmy", gate["id"], expected_revision=1, decision="hold", reason="Awaiting owner", override_reason="", checklist={}, idempotency_key="d4")
|
||||
assert receipt["state"] == "held"
|
||||
assert receipt["checklist"] == {}
|
||||
|
||||
|
||||
def test_same_hash_update_coalesces_one_card_and_preserves_history(tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101]).__next__)
|
||||
original = store.intake("timmy", candidate(check_state="pending"), idempotency_key="run-1")
|
||||
|
||||
updated = store.intake("timmy", candidate(check_state="success"), idempotency_key="run-2")
|
||||
|
||||
assert updated["id"] == original["id"]
|
||||
assert updated["revision"] == 2
|
||||
assert updated["checks"][0]["state"] == "success"
|
||||
assert updated["history"][-1]["action"] == "updated"
|
||||
assert store.list("timmy")["pending_count"] == 1
|
||||
|
||||
|
||||
def test_updated_checks_reopen_a_released_hash_for_review(tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101, 102]).__next__)
|
||||
gate = store.intake("timmy", candidate(), idempotency_key="run-1")
|
||||
store.decide(
|
||||
"timmy", gate["id"], expected_revision=1, decision="release",
|
||||
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
|
||||
)
|
||||
|
||||
reopened = store.intake(
|
||||
"timmy", candidate(check_state="failure"), idempotency_key="run-2",
|
||||
)
|
||||
|
||||
assert reopened["state"] == "pending"
|
||||
assert reopened["revision"] == 3
|
||||
assert reopened["checks"][0]["state"] == "failure"
|
||||
assert reopened["history"][-1]["action"] == "reopened"
|
||||
assert store.list("timmy")["pending_count"] == 1
|
||||
|
||||
|
||||
def test_same_hash_identity_facts_cannot_be_redefined(tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
|
||||
store.intake("timmy", candidate(), idempotency_key="run-1")
|
||||
|
||||
with pytest.raises(GateConflict, match="hash"):
|
||||
store.intake("timmy", {**candidate(), "title": "Changed facts"}, idempotency_key="run-2")
|
||||
157
tests/test_human_gates_frontend.py
Normal file
157
tests/test_human_gates_frontend.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "human-gates.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def run_node(body):
|
||||
script = f"const createHumanGates=require({json.dumps(str(MODULE))});\n" + body
|
||||
result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_queue_loads_pending_count_uses_account_cache_and_renders_inbox_zero():
|
||||
output = run_node(r"""
|
||||
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
|
||||
let response={pending_count:1,items:[{id:'g1',title:'Candidate',candidate_hash:'abc123',priority:4,state:'pending',revision:1,checks:[]}]};
|
||||
const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true}};
|
||||
const gates=createHumanGates({storage,getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>response});
|
||||
(async()=>{ await gates.load(); const first={snapshot:gates.snapshot(),count:nodes.count.textContent,html:nodes.list.innerHTML,keys:[...values.keys()]}; response={pending_count:0,items:[]}; await gates.load(); process.stdout.write(JSON.stringify({first,zero:{snapshot:gates.snapshot(),status:nodes.status.textContent,html:nodes.list.innerHTML}})); })();
|
||||
""")
|
||||
assert output["first"]["count"] == "1"
|
||||
assert "abc123" in output["first"]["html"]
|
||||
assert output["first"]["keys"] == ["stackchain.human-gates.v1:timmy"]
|
||||
assert output["zero"]["snapshot"]["pending_count"] == 0
|
||||
assert "Inbox zero" in output["zero"]["html"]
|
||||
|
||||
|
||||
def test_review_next_is_a_fixed_snapshot_and_decision_and_next_advances_without_new_arrivals():
|
||||
output = run_node(r"""
|
||||
const calls=[]; const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true},detail:{innerHTML:''}};
|
||||
const initial={pending_count:2,items:[{id:'old',title:'Old',candidate_hash:'a1',revision:1,checks:[]},{id:'next',title:'Next',candidate_hash:'b2',revision:1,checks:[]}]};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:'#/my-work/human-gates'},fetchJson:async(path,options={})=>{calls.push({path,options}); if(options.method==='POST') return {receipt_id:'r1',state:'released'}; return initial;}});
|
||||
(async()=>{await gates.load(); const reviewed=gates.reviewNext(); initial.items.unshift({id:'new',title:'New arrival',candidate_hash:'c3',revision:1,checks:[]}); const result=await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}}); process.stdout.write(JSON.stringify({reviewed,result,current:gates.current(),calls,hash:gates.route()}));})();
|
||||
""")
|
||||
assert output["reviewed"]["id"] == "old"
|
||||
assert output["result"]["receipt"]["receipt_id"] == "r1"
|
||||
assert output["current"]["id"] == "next"
|
||||
assert output["hash"] == "#/my-work/human-gates"
|
||||
post = next(call for call in output["calls"] if call["options"].get("method") == "POST")
|
||||
assert post["path"] == "api/v1/human-gates/old/decision"
|
||||
assert "Idempotency-Key" in post["options"]["headers"]
|
||||
|
||||
|
||||
def test_review_loads_exact_detail_with_links_provenance_and_history():
|
||||
output = run_node(r"""
|
||||
const detail={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:1,checks:[{name:'unit',state:'success',required:true}],artifacts:[{name:'manifest',url:'https://forge.example/manifest'}],links:[{label:'pull',url:'https://forge.example/pull/1'}],score:{value:98,provenance:'eval/v1'},provenance:{producer:'bot',run_id:'9'},history:[{action:'intake',at:100}]};
|
||||
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.endsWith('/g1')?detail:{pending_count:1,items:[detail]}});
|
||||
(async()=>{await gates.load();gates.reviewNext();await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({html:nodes.detail.innerHTML}));})();
|
||||
""")
|
||||
assert "stackchain/dashboard" in output["html"]
|
||||
assert "https://forge.example/pull/1" in output["html"]
|
||||
assert "bot" in output["html"]
|
||||
assert "intake" in output["html"]
|
||||
|
||||
|
||||
def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity():
|
||||
output = run_node(r"""
|
||||
let online=true, login='timmy', posts=0;
|
||||
const item={id:'g1',title:'Failing',candidate_hash:'a1',revision:1,checks:[{name:'browser',state:'failure',required:true}]};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=>login,isOnline:()=>online,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}});
|
||||
(async()=>{await gates.load();gates.reviewNext();let override,offline,identity;try{await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){override=e.message} online=false;try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){offline=e.message} online=true;login='';try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){identity=e.message}process.stdout.write(JSON.stringify({override,offline,identity,posts}));})();
|
||||
""")
|
||||
assert "override reason" in output["override"]
|
||||
assert "online" in output["offline"]
|
||||
assert "identity" in output["identity"]
|
||||
assert output["posts"] == 0
|
||||
|
||||
|
||||
def test_offline_cache_is_scoped_to_immutable_account_identity():
|
||||
output = run_node(r"""
|
||||
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
|
||||
let account='1:timmy'; const nodes={count:{},list:{},status:{},panel:{}};
|
||||
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>({pending_count:1,items:[{id:'g1',title:'Private',candidate_hash:'a1'}]})});
|
||||
(async()=>{await gates.load();account='2:timmy';process.stdout.write(JSON.stringify({keys:[...values.keys()],restored:gates.restoreCached()}));})();
|
||||
""")
|
||||
assert output["keys"] == ["stackchain.human-gates.v1:1:timmy"]
|
||||
assert output["restored"] is None
|
||||
|
||||
|
||||
def test_account_switch_clears_in_memory_gate_data_before_failed_load():
|
||||
output = run_node(r"""
|
||||
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
|
||||
let account='1:timmy', fail=false; const nodes={count:{},list:{innerHTML:''},status:{},panel:{}};
|
||||
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>{if(fail)throw new Error('offline');return {pending_count:1,items:[{id:'private-1',title:'Principal 1 private',candidate_hash:'secret'}]}}});
|
||||
(async()=>{await gates.load();account='2:timmy';fail=true;try{await gates.load()}catch(_){}process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),html:nodes.list.innerHTML}));})();
|
||||
""")
|
||||
assert output["snapshot"] == {"pending_count": 0, "items": []}
|
||||
assert "Principal 1 private" not in output["html"]
|
||||
assert "secret" not in output["html"]
|
||||
|
||||
|
||||
def test_stale_account_load_cannot_overwrite_new_account_queue_or_cache():
|
||||
output = run_node(r"""
|
||||
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
|
||||
let account='1:timmy', resolveA, resolveB;
|
||||
const responseA=new Promise(resolve=>resolveA=resolve), responseB=new Promise(resolve=>resolveB=resolve);
|
||||
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes:{count:{},list:{innerHTML:''},status:{},panel:{}},location:{hash:''},fetchJson:()=>account.startsWith('1:')?responseA:responseB});
|
||||
(async()=>{const loadA=gates.load();account='2:timmy';const loadB=gates.load();resolveB({pending_count:1,items:[{id:'b',title:'B gate',candidate_hash:'bhash'}]});await loadB;resolveA({pending_count:1,items:[{id:'a-secret',title:'A secret',candidate_hash:'asecret'}]});await loadA;process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),cached:JSON.parse(values.get('stackchain.human-gates.v1:2:timmy'))}));})();
|
||||
""")
|
||||
assert [item["id"] for item in output["snapshot"]["items"]] == ["b"]
|
||||
assert [item["id"] for item in output["cached"]["items"]] == ["b"]
|
||||
|
||||
|
||||
def test_decision_retry_reuses_the_same_idempotency_key():
|
||||
output = run_node(r"""
|
||||
let attempts=0; const keys=[];
|
||||
const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){keys.push(options.headers['Idempotency-Key']);attempts++;if(attempts===1)throw new Error('network');return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}});
|
||||
(async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};try{await gates.decideAndNext('hold',values)}catch(_){}await gates.decideAndNext('hold',values);process.stdout.write(JSON.stringify({keys}));})();
|
||||
""")
|
||||
assert len(output["keys"]) == 2
|
||||
assert output["keys"][0] == output["keys"][1]
|
||||
|
||||
|
||||
def test_concurrent_decision_taps_submit_once_and_advance_once():
|
||||
output = run_node(r"""
|
||||
let posts=0, releasePost; const posted=new Promise(resolve=>releasePost=resolve);
|
||||
const items=[{id:'g1',title:'One',candidate_hash:'a1',revision:1,checks:[]},{id:'g2',title:'Two',candidate_hash:'b2',revision:1,checks:[]}];
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;await posted;return {receipt_id:'r1'}};return {pending_count:2,items};}});
|
||||
(async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};const first=gates.decideAndNext('hold',values);const second=gates.decideAndNext('hold',values);releasePost();await Promise.all([first,second]);process.stdout.write(JSON.stringify({posts,current:gates.current()?.id,snapshot:gates.snapshot()}));})();
|
||||
""")
|
||||
assert output["posts"] == 1
|
||||
assert output["current"] == "g2"
|
||||
assert output["snapshot"]["pending_count"] == 1
|
||||
assert [item["id"] for item in output["snapshot"]["items"]] == ["g2"]
|
||||
|
||||
|
||||
def test_selecting_a_queue_card_opens_that_exact_gate():
|
||||
output = run_node(r"""
|
||||
const details={
|
||||
g1:{id:'g1',title:'First',project:'p/one',candidate_hash:'a1',revision:1,checks:[]},
|
||||
g2:{id:'g2',title:'Second',project:'p/two',candidate_hash:'b2',revision:1,checks:[]},
|
||||
};
|
||||
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.includes('/g')?details[path.split('/').pop()]:{pending_count:2,items:Object.values(details)}});
|
||||
(async()=>{await gates.load();gates.reviewNext();gates.select('g2');await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({current:gates.current().id,html:nodes.detail.innerHTML}));})();
|
||||
""")
|
||||
assert output["current"] == "g2"
|
||||
assert "Second" in output["html"]
|
||||
assert "p/two" in output["html"]
|
||||
|
||||
|
||||
def test_human_gate_mobile_shell_and_deep_route_are_wired():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
assert 'id="human-gates"' in index
|
||||
assert 'id="human-gates-count"' in index
|
||||
assert 'Review next <span id="human-gates-count"' in index
|
||||
assert 'static/human-gates.js' in index
|
||||
assert "#/my-work/human-gates" in dashboard
|
||||
assert "createHumanGates" in dashboard
|
||||
assert "planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id" in dashboard
|
||||
24
tests/test_human_gates_readme.py
Normal file
24
tests/test_human_gates_readme.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from pathlib import Path
|
||||
|
||||
|
||||
README = Path(__file__).parents[1] / "README.md"
|
||||
|
||||
|
||||
def test_readme_documents_hash_bound_producer_intake_and_revision_decisions():
|
||||
text = README.read_text()
|
||||
assert "POST /api/v1/human-gates/intake" in text
|
||||
assert "Idempotency-Key" in text
|
||||
assert '"candidate_hash"' in text
|
||||
assert "expected_revision" in text
|
||||
assert "STACKCHAIN_HUMAN_GATE_DB" in text
|
||||
assert "principal ID and login" in text
|
||||
assert "reopens the exact hash" in text
|
||||
|
||||
|
||||
def test_readme_defines_privacy_safe_telegram_coalescing_contract():
|
||||
text = README.read_text()
|
||||
assert "Telegram coalescing contract" in text
|
||||
assert "#/my-work/human-gates" in text
|
||||
assert "count and route only" in text
|
||||
assert "candidate hash" in text
|
||||
assert "superseded" in text
|
||||
|
|
@ -1373,6 +1373,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/manifest.webmanifest",
|
||||
"/dashboard/static/dashboard.css",
|
||||
"/dashboard/static/dashboard.js",
|
||||
"/dashboard/static/human-gates.js",
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user