From e02115bd7162ad5b33355e544d12c0a49e48770b Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 26 Aug 2026 00:04:23 +0000 Subject: [PATCH] Add durable Human Gates inbox --- README.md | 13 ++ docs/human-gates.md | 39 ++++ frontend/dashboard.css | 4 + frontend/dashboard.js | 38 ++++ frontend/human-gates.js | 166 ++++++++++++++++ frontend/index.html | 10 + frontend/service-worker.js | 1 + src/human_gate_store.py | 307 +++++++++++++++++++++++++++++ src/main.py | 128 +++++++++++- tests/test_human_gate_api.py | 106 ++++++++++ tests/test_human_gate_store.py | 110 +++++++++++ tests/test_human_gates_frontend.py | 80 ++++++++ tests/test_human_gates_readme.py | 22 +++ tests/test_service_worker.py | 1 + 14 files changed, 1024 insertions(+), 1 deletion(-) create mode 100644 docs/human-gates.md create mode 100644 frontend/human-gates.js create mode 100644 src/human_gate_store.py create mode 100644 tests/test_human_gate_api.py create mode 100644 tests/test_human_gate_store.py create mode 100644 tests/test_human_gates_frontend.py create mode 100644 tests/test_human_gates_readme.py diff --git a/README.md b/README.md index ae3f807..57ee604 100644 --- a/README.md +++ b/README.md @@ -851,3 +851,16 @@ 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`. Review decisions +carry `expected_revision`, a new idempotency key, and return durable receipts. +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. diff --git a/docs/human-gates.md b/docs/human-gates.md new file mode 100644 index 0000000..65abc70 --- /dev/null +++ b/docs/human-gates.md @@ -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. diff --git a/frontend/dashboard.css b/frontend/dashboard.css index d80e4b2..fdd0985 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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)}} \ No newline at end of file diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 5e10907..7f3b62e 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -634,6 +634,44 @@ return payload; } + const humanGates = createHumanGates({ + storage:localStorage, + getLogin:()=>planningOwnerLogin, + 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 => { + if (!event.target.closest('[data-human-gate-id]')) return; + humanGates.reviewNext(); + }); + 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; diff --git a/frontend/human-gates.js b/frontend/human-gates.js new file mode 100644 index 0000000..1ea29f7 --- /dev/null +++ b/frontend/human-gates.js @@ -0,0 +1,166 @@ +function createHumanGates(options = {}) { + const storage = options.storage || window.localStorage; + const getLogin = options.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; + + const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]); + const cacheKey = () => 'stackchain.human-gates.v1:' + String(getLogin() || '').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, '
Inbox zeroNo release candidates need your decision.
'); + 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 => + '' + ).join('')); + } + + function renderDetail(item) { + if (!item) { + setHtml(nodes.detail, '
Inbox zeroFixed review snapshot complete.
'); + return; + } + const checks = (item.checks || []).map(check => + '
  • ' + escape(check.name) + ' · ' + escape(check.state) + (check.required ? ' · required' : '') + '
  • ' + ).join(''); + const artifacts = (item.artifacts || []).map(artifact => '
  • ' + escape(artifact.name) + '
  • ').join(''); + const links = (item.links || []).map(link => '
  • ' + escape(link.label) + '
  • ').join(''); + const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '
  • ' + escape(key) + ' · ' + escape(value) + '
  • ').join(''); + const history = (item.history || []).map(event => '
  • ' + escape(event.action) + ' · ' + escape(event.at) + '
  • ').join(''); + setHtml(nodes.detail, + '

    ' + escape(item.title) + '

    ' + + '

    Exact candidate ' + escape(item.candidate_hash) + '

    ' + + '

    Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '

    ' + + '

    Artifacts

    Links

    ' + + '

    Checks

    Provenance

    ' + + '

    History

    ' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '
    ' + ); + } + + async function load() { + const cached = restore(); + if (cached) { queue = cached; render(); } + try { + const live = validSnapshot(await fetchJson('api/v1/human-gates')); + 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 (!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 current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); } + function idempotencyKey(item, decision) { + const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)); + return 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce; + } + + 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, + }; + const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey(item, decision) }, + body: JSON.stringify(payload), + }); + 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 }; + } + + function open() { + location.hash = '#/my-work/human-gates'; + if (nodes.panel) nodes.panel.hidden = false; + return load().then(() => reviewNext()); + } + + return { + load, open, reviewNext, decideAndNext, current, + snapshot: () => JSON.parse(JSON.stringify(queue)), + route: () => location.hash, + }; +} + +if (typeof module !== 'undefined') module.exports = createHumanGates; +if (typeof window !== 'undefined') window.createHumanGates = createHumanGates; diff --git a/frontend/index.html b/frontend/index.html index 1ad952b..bd96993 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -187,7 +187,16 @@ + +
    Queue & settings · All
    @@ -2401,6 +2410,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 154450e..4f8cfc9 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -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', diff --git a/src/human_gate_store.py b/src/human_gate_store.py new file mode 100644 index 0000000..496cda0 --- /dev/null +++ b/src/human_gate_store.py @@ -0,0 +1,307 @@ +"""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: + if _fingerprint(json.loads(existing["payload_json"])) != fingerprint: + raise GateConflict("Candidate hash is already bound to different facts") + 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]) diff --git a/src/main.py b/src/main.py index 204ccff..8c49264 100644 --- a/src/main.py +++ b/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: + return session.principal_login + _principal_id, login = await _upstream_identity() + return 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" diff --git a/tests/test_human_gate_api.py b/tests/test_human_gate_api.py new file mode 100644 index 0000000..eb93594 --- /dev/null +++ b/tests/test_human_gate_api.py @@ -0,0 +1,106 @@ +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("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_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("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"] diff --git a/tests/test_human_gate_store.py b/tests/test_human_gate_store.py new file mode 100644 index 0000000..e54415a --- /dev/null +++ b/tests/test_human_gate_store.py @@ -0,0 +1,110 @@ +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_cannot_be_redefined_by_a_new_idempotency_key(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") diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py new file mode 100644 index 0000000..76eb525 --- /dev/null +++ b/tests/test_human_gates_frontend.py @@ -0,0 +1,80 @@ +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',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 "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_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 'static/human-gates.js' in index + assert "#/my-work/human-gates" in dashboard + assert "createHumanGates" in dashboard diff --git a/tests/test_human_gates_readme.py b/tests/test_human_gates_readme.py new file mode 100644 index 0000000..e3feaf4 --- /dev/null +++ b/tests/test_human_gates_readme.py @@ -0,0 +1,22 @@ +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 + + +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 diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index bffa217..fa050b9 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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",