Add durable Human Gates inbox
This commit is contained in:
1 parent
7ec78ac182
commit
e02115bd71
14 files changed
+1024
-1
No files matched your search
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user