From f4c5c6455622439e41fa9acfe15e8022b91326ef Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:32:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20vouch=20review-ui=20=E2=80=94?= =?UTF-8?q?=20browser-based=20review=20console=20(mvp=20slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mvp slice of #194: a fastapi + jinja viewport over the existing proposals + audit surface. every approve / reject goes through proposals.approve / proposals.reject so the audit-log entry is identical to the cli code path. localhost-only; bearer-auth + websocket sync land alongside the http-transport feature. ships behind the [web] optional extra so the base install stays lean. wheel includes the templates and static directory. --- pyproject.toml | 15 ++ src/vouch/cli.py | 76 +++++++++ src/vouch/web/__init__.py | 42 +++++ src/vouch/web/server.py | 174 +++++++++++++++++++ src/vouch/web/static/app.css | 242 +++++++++++++++++++++++++++ src/vouch/web/templates/audit.html | 36 ++++ src/vouch/web/templates/base.html | 27 +++ src/vouch/web/templates/claim.html | 45 +++++ src/vouch/web/templates/queue.html | 39 +++++ tests/test_web.py | 260 +++++++++++++++++++++++++++++ 10 files changed, 956 insertions(+) create mode 100644 src/vouch/web/__init__.py create mode 100644 src/vouch/web/server.py create mode 100644 src/vouch/web/static/app.css create mode 100644 src/vouch/web/templates/audit.html create mode 100644 src/vouch/web/templates/base.html create mode 100644 src/vouch/web/templates/claim.html create mode 100644 src/vouch/web/templates/queue.html create mode 100644 tests/test_web.py diff --git a/pyproject.toml b/pyproject.toml index 3c358527..dc788d60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,19 @@ embeddings = [ "sentence-transformers>=3,<4", "numpy>=1.26,<2", ] +web = [ + "fastapi>=0.115,<1", + "jinja2>=3,<4", + "python-multipart>=0.0.9", + "uvicorn>=0.30,<1", +] dev = [ "pytest>=9.0.3,<10", "pytest-cov>=5,<6", "mypy>=2.1.0", "ruff>=0.15.13", "types-pyyaml", + "httpx>=0.28,<1", ] embeddings-fast = [ "fastembed>=0.3,<1", @@ -94,3 +101,11 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = ["fastembed", "fastembed.*"] ignore_missing_imports = true + +# fastapi + jinja2 + uvicorn live behind the [web] extra; the base CI install +# only has [dev], so mypy can't resolve them when scanning src/vouch/web/. +# The web module guards its own imports with a clean ImportError message so +# the runtime story stays clean even without the extra. +[[tool.mypy.overrides]] +module = ["fastapi", "fastapi.*", "uvicorn", "uvicorn.*", "starlette", "starlette.*"] +ignore_missing_imports = true diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 100dcdce..987cb959 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -784,5 +784,81 @@ def serve(transport: str) -> None: run_jsonl() +# --- review-ui: browser-based review console ----------------------------- + + +@cli.command(name="review-ui") +@click.option("--bind", "bind", default="127.0.0.1:7780", show_default=True, + help="host:port to bind. Refuses 0.0.0.0 in this MVP slice " + "(auth lands with the HTTP-transport feature).") +@click.option("--kb", "kb_root", default=None, + type=click.Path(exists=True, file_okay=False), + help="KB root (defaults to the nearest .vouch/ above cwd).") +@click.option("--open-browser/--no-open-browser", default=True, show_default=True, + help="Open the browser to the queue on startup.") +def review_ui(bind: str, kb_root: str | None, open_browser: bool) -> None: + """Run the browser-based review console. + + \b + Examples: + vouch review-ui # bind 127.0.0.1:7780, open browser + vouch review-ui --bind 127.0.0.1:8000 + vouch review-ui --no-open-browser # ssh / headless friendly + """ + if ":" not in bind: + raise click.ClickException( + f"--bind must be host:port (got {bind!r})" + ) + host, _, port_str = bind.rpartition(":") + try: + port = int(port_str) + except ValueError as e: + raise click.ClickException(f"invalid port in --bind: {port_str!r}") from e + + # The full design (issue #194) refuses 0.0.0.0 without --auth bearer. + # The MVP slice does not ship the Bearer-auth layer yet (depends on + # the HTTP-transport feature), so we refuse non-localhost binds + # outright — a clearer error than silently exposing an unauthenticated + # approve surface on the network. + if host not in ("127.0.0.1", "localhost", "::1"): + raise click.ClickException( + f"--bind {bind!r}: review-ui is localhost-only in the MVP slice. " + "Bearer-auth + non-loopback binding land alongside the HTTP " + "transport feature." + ) + + try: + from . import web as web_pkg + except ImportError as e: + raise click.ClickException(str(e)) from e + + try: + app = web_pkg.create_app(kb_root) + except (FileNotFoundError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + + try: + import uvicorn + except ImportError as e: + raise click.ClickException( + "vouch review-ui needs the [web] extra. " + "Install with: pip install 'vouch-kb[web]'" + ) from e + + if open_browser: + # Lazy-import webbrowser; some CI envs (headless) don't have a default + # browser configured and webbrowser.open() returns False rather than + # raising — that's fine, the URL is also printed to stdout. + import threading + import webbrowser + url = f"http://{host}:{port}/" + click.echo(f"vouch review-ui running at {url}") + threading.Timer(0.5, lambda: webbrowser.open(url)).start() + else: + click.echo(f"vouch review-ui running at http://{host}:{port}/") + + uvicorn.run(app, host=host, port=port, log_level="info") + + if __name__ == "__main__": cli() diff --git a/src/vouch/web/__init__.py b/src/vouch/web/__init__.py new file mode 100644 index 00000000..51222ecf --- /dev/null +++ b/src/vouch/web/__init__.py @@ -0,0 +1,42 @@ +"""Browser-based review console for vouch. + +The web layer is a *viewport* over the existing kb.* surface — every action +(approve, reject) goes through the same ``vouch.proposals`` / ``vouch.storage`` +code path as the CLI, so the audit log is identical regardless of surface. + +The dependencies (fastapi, jinja2) live behind the ``[web]`` extra so the +base install stays light. ``vouch review-ui`` produces an actionable +``ImportError`` line if the extra is missing. +""" + +from __future__ import annotations + + +def _require_web_extra() -> None: + """Fail with a clean message if fastapi/jinja2 aren't installed.""" + missing: list[str] = [] + try: + import fastapi # noqa: F401 + except ImportError: + missing.append("fastapi") + try: + import jinja2 # noqa: F401 + except ImportError: + missing.append("jinja2") + if missing: + raise ImportError( + "vouch review-ui needs the [web] extra. " + "Install with: pip install 'vouch-kb[web]' " + f"(missing: {', '.join(missing)})" + ) + + +def create_app(kb_root: str | None = None): # type: ignore[no-untyped-def] + """Build the FastAPI app for a given KB root. Lazy-imports the web stack.""" + _require_web_extra() + from .server import build_app + + return build_app(kb_root) + + +__all__ = ["create_app"] diff --git a/src/vouch/web/server.py b/src/vouch/web/server.py new file mode 100644 index 00000000..2f56a33b --- /dev/null +++ b/src/vouch/web/server.py @@ -0,0 +1,174 @@ +"""FastAPI app for the review console. + +MVP slice: queue + claim detail + approve/reject + audit timeline. No +WebSocket, no Bearer auth — those land alongside the HTTP transport (#1) +and multi-dim scopes (#2). Localhost-only by default. + +The web layer is intentionally thin: every approve/reject goes through +``vouch.proposals.approve`` / ``vouch.proposals.reject`` so the audit log +is identical regardless of whether the action came from the CLI or the UI. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from .. import audit as audit_mod +from .. import proposals as proposals_mod +from ..models import ProposalStatus +from ..storage import ArtifactNotFoundError, KBStore, discover_root + +_MODULE_DIR = Path(__file__).resolve().parent +_TEMPLATES_DIR = _MODULE_DIR / "templates" +_STATIC_DIR = _MODULE_DIR / "static" + + +def _is_review_event(name: str) -> bool: + """The audit log carries every mutation; the timeline only shows + review-gate decisions (approve / reject) and claim lifecycle moves.""" + if name.startswith("proposal.") and name.endswith((".approve", ".reject")): + return True + return name in {"claim.supersede", "claim.contradict", + "claim.archive", "claim.confirm"} + + +def _whoami() -> str: + """Reviewer identity. Without the Bearer-auth layer from #1 this falls + back to the same env var the CLI uses, so audit-log attribution stays + consistent across surfaces.""" + return os.environ.get("VOUCH_AGENT", "web-reviewer") + + +def _proposal_preview(payload: dict[str, Any]) -> str: + """One-line preview shown in the queue. Mirrors the CLI's `pending` output.""" + for key in ("text", "title", "name"): + value = payload.get(key) + if value: + return str(value).strip().splitlines()[0][:160] + return "—" + + +def build_app(kb_root: str | None = None) -> FastAPI: + """FastAPI app bound to a KB root. ``kb_root`` defaults to the nearest + ``.vouch/`` discovered by walking up from ``cwd``.""" + start = Path(kb_root).resolve() if kb_root else None + # Resolve once at construction so every request hits the same store. + # discover_root walks up looking for .vouch/ — the same behavior as + # every other vouch CLI command. Failing here means a clearer error + # than a 500 on the first request. + root = discover_root(start) + store = KBStore(root) + + templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + app = FastAPI(title="vouch review-ui", docs_url=None, redoc_url=None) + app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") + + @app.get("/healthz") + def healthz() -> dict[str, Any]: + return { + "ok": True, + "kb": str(store.root), + "pending": len(store.list_proposals(ProposalStatus.PENDING)), + } + + @app.get("/", response_class=HTMLResponse) + def queue(request: Request) -> Any: + pending = store.list_proposals(ProposalStatus.PENDING) + items = [ + { + "id": p.id, + "kind": p.kind.value, + "proposed_by": p.proposed_by, + "proposed_at": p.proposed_at.isoformat(timespec="seconds"), + "preview": _proposal_preview(p.payload), + } + for p in pending + ] + return templates.TemplateResponse( + request, + "queue.html", + {"items": items, "count": len(items)}, + ) + + @app.get("/claim/{proposal_id}", response_class=HTMLResponse) + def claim_detail(request: Request, proposal_id: str) -> Any: + try: + pr = store.get_proposal(proposal_id) + except ArtifactNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + return templates.TemplateResponse( + request, + "claim.html", + { + "proposal": pr.model_dump(mode="json"), + "preview": _proposal_preview(pr.payload), + }, + ) + + @app.post("/approve/{proposal_id}") + def approve(proposal_id: str, reason: str | None = Form(default=None)) -> Any: + try: + proposals_mod.approve( + store, proposal_id, approved_by=_whoami(), reason=reason + ) + except (proposals_mod.ProposalError, ArtifactNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return RedirectResponse(url="/", status_code=303) + + @app.post("/reject/{proposal_id}") + def reject(proposal_id: str, reason: str = Form(...)) -> Any: + if not reason.strip(): + raise HTTPException(status_code=400, detail="reason is required") + try: + proposals_mod.reject( + store, proposal_id, rejected_by=_whoami(), reason=reason + ) + except (proposals_mod.ProposalError, ArtifactNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return RedirectResponse(url="/", status_code=303) + + @app.get("/audit", response_class=HTMLResponse) + def audit(request: Request, limit: int = 100) -> Any: + events = list(audit_mod.read_events(store.kb_dir)) + events.reverse() # newest first + filtered = [e for e in events if _is_review_event(e.event)][:limit] + rows = [ + { + "id": e.id, + "event": e.event, + "actor": e.actor, + "object_ids": e.object_ids, + "at": e.created_at.isoformat(timespec="seconds"), + "reason": e.data.get("reason"), + } + for e in filtered + ] + return templates.TemplateResponse( + request, + "audit.html", + {"rows": rows, "count": len(rows)}, + ) + + @app.get("/api/pending") + def api_pending() -> JSONResponse: + pending = store.list_proposals(ProposalStatus.PENDING) + return JSONResponse( + [ + { + "id": p.id, + "kind": p.kind.value, + "proposed_by": p.proposed_by, + "preview": _proposal_preview(p.payload), + } + for p in pending + ] + ) + + return app diff --git a/src/vouch/web/static/app.css b/src/vouch/web/static/app.css new file mode 100644 index 00000000..d957dc6a --- /dev/null +++ b/src/vouch/web/static/app.css @@ -0,0 +1,242 @@ +/* vouch review-ui — minimal, monograph-adjacent. No framework. */ + +:root { + --paper: #f4eedb; + --ink: #1a1208; + --rule: #1a120833; + --muted: #6b5d4a; + --vermillion: #a82c1c; + --moss: #4a5d3a; + --mono: "SFMono-Regular", "JetBrains Mono", "Menlo", monospace; + --serif: "EB Garamond", "Iowan Old Style", "Palatino", "Georgia", serif; + --sans: "Inter", system-ui, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; padding: 0; } +body { + background: var(--paper); + color: var(--ink); + font-family: var(--serif); + font-size: 18px; + line-height: 1.5; +} + +.masthead { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 1.25rem 2rem 0.75rem; + border-bottom: 1px solid var(--rule); +} +.brand { display: flex; align-items: baseline; gap: 0.5rem; } +.brand-mark { color: var(--vermillion); font-size: 1.4rem; } +.brand-text { font-variant: small-caps; letter-spacing: 0.06em; } +.nav { display: flex; gap: 1.25rem; } +.nav a { + color: var(--ink); + text-decoration: none; + font-family: var(--sans); + font-size: 0.875rem; + text-transform: lowercase; +} +.nav a[aria-current="page"] { + border-bottom: 2px solid var(--vermillion); +} + +.content { + max-width: 56rem; + margin: 0 auto; + padding: 2rem; +} + +.section-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 1.5rem; +} +.section-head h1 { + font-size: 2rem; + font-weight: 500; + margin: 0; + letter-spacing: -0.01em; +} +.count { + color: var(--muted); + font-family: var(--mono); + font-size: 0.875rem; +} + +.empty { + color: var(--muted); + font-style: italic; + padding: 2rem 0; + text-align: center; +} + +.queue-list, .audit-list { + list-style: none; + padding: 0; + margin: 0; +} + +.queue-row { + border-top: 1px solid var(--rule); + padding: 1rem 0; +} +.queue-row:last-child { border-bottom: 1px solid var(--rule); } +.queue-meta { + display: flex; + align-items: baseline; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; +} +.kind { + font-family: var(--mono); + font-size: 0.7rem; + text-transform: uppercase; + padding: 0.1rem 0.4rem; + border: 1px solid var(--rule); + border-radius: 2px; +} +.kind-claim { color: var(--vermillion); border-color: var(--vermillion); } +.kind-page { color: var(--moss); border-color: var(--moss); } +.kind-entity { color: var(--ink); } +.kind-relation { color: var(--muted); } + +.proposal-link { + font-family: var(--mono); + font-size: 0.875rem; + color: var(--ink); +} +.by { color: var(--muted); font-size: 0.875rem; } +time { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--muted); + margin-left: auto; +} + +.preview { margin: 0.25rem 0 0.75rem; } + +.actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; +} +.actions-large { margin-top: 2rem; } +.inline { display: inline-flex; gap: 0.5rem; margin: 0; } + +button { + font-family: var(--sans); + font-size: 0.875rem; + padding: 0.4rem 1rem; + border: 1px solid var(--ink); + background: var(--paper); + color: var(--ink); + cursor: pointer; + border-radius: 2px; +} +button.approve { border-color: var(--moss); color: var(--moss); } +button.approve:hover { background: var(--moss); color: var(--paper); } +button.reject { border-color: var(--vermillion); color: var(--vermillion); } +button.reject:hover { background: var(--vermillion); color: var(--paper); } + +input[type="text"] { + font-family: var(--sans); + font-size: 0.875rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--rule); + background: var(--paper); + color: var(--ink); + border-radius: 2px; + min-width: 14rem; +} + +.claim-detail .back { + font-family: var(--sans); + font-size: 0.875rem; + color: var(--muted); + text-decoration: none; +} +.meta-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.4rem 1.5rem; + margin: 1.5rem 0; + font-size: 0.95rem; +} +.meta-grid dt { + font-variant: small-caps; + letter-spacing: 0.05em; + color: var(--muted); +} +.meta-grid dd { margin: 0; font-family: var(--mono); font-size: 0.875rem; } + +.payload h2 { + font-size: 1.25rem; + font-weight: 500; + margin: 2rem 0 0.75rem; + border-bottom: 1px solid var(--rule); + padding-bottom: 0.4rem; +} +.preview-large { font-size: 1.05rem; line-height: 1.6; } +details summary { + cursor: pointer; + color: var(--muted); + font-family: var(--sans); + font-size: 0.875rem; + margin-top: 0.75rem; +} +pre { + background: #ebe3ca; + padding: 1rem; + border-radius: 2px; + font-family: var(--mono); + font-size: 0.8rem; + overflow-x: auto; +} + +.audit-row { + border-top: 1px solid var(--rule); + padding: 0.75rem 0; +} +.audit-row:last-child { border-bottom: 1px solid var(--rule); } +.audit-head { + display: flex; + gap: 0.75rem; + align-items: baseline; + flex-wrap: wrap; +} +.event { + font-family: var(--mono); + font-size: 0.875rem; + color: var(--ink); +} +.objects { margin-top: 0.25rem; } +.objects code { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--muted); + margin-right: 0.5rem; +} +.reason { + margin: 0.25rem 0 0; + font-style: italic; + color: var(--muted); + font-size: 0.95rem; +} + +.colophon { + text-align: center; + padding: 2rem; + color: var(--muted); + font-family: var(--mono); + font-size: 0.75rem; + border-top: 1px solid var(--rule); + margin-top: 4rem; +} diff --git a/src/vouch/web/templates/audit.html b/src/vouch/web/templates/audit.html new file mode 100644 index 00000000..6f9907d9 --- /dev/null +++ b/src/vouch/web/templates/audit.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% set active = "audit" %} +{% block title %}audit · vouch review{% endblock %} + +{% block content %} +
+
+

audit timeline

+ {{ count }} review decisions +
+ + {% if not rows %} +

no review decisions yet — the timeline lights up as proposals are approved or rejected.

+ {% else %} +
    + {% for row in rows %} +
  1. +
    + {{ row.event }} + by {{ row.actor }} + +
    + {% if row.object_ids %} +
    + {% for oid in row.object_ids %}{{ oid }}{% endfor %} +
    + {% endif %} + {% if row.reason %} +

    {{ row.reason }}

    + {% endif %} +
  2. + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/src/vouch/web/templates/base.html b/src/vouch/web/templates/base.html new file mode 100644 index 00000000..dbd5465b --- /dev/null +++ b/src/vouch/web/templates/base.html @@ -0,0 +1,27 @@ + + + + + + {% block title %}vouch review{% endblock %} + + + +
+
+ + vouch · review console +
+ +
+
+ {% block content %}{% endblock %} +
+ + + diff --git a/src/vouch/web/templates/claim.html b/src/vouch/web/templates/claim.html new file mode 100644 index 00000000..52bb58ea --- /dev/null +++ b/src/vouch/web/templates/claim.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}{{ proposal.id }} · vouch review{% endblock %} + +{% block content %} +
+
+

+ {{ proposal.kind }} + {{ proposal.id }} +

+ ← queue +
+ +
+
proposed by
{{ proposal.proposed_by }}
+
proposed at
{{ proposal.proposed_at }}
+
status
{{ proposal.status }}
+ {% if proposal.rationale %} +
rationale
{{ proposal.rationale }}
+ {% endif %} +
+ +
+

payload

+

{{ preview }}

+
+ full payload +
{{ proposal.payload | tojson(indent=2) }}
+
+
+ + {% if proposal.status == "pending" %} +
+
+ + +
+
+ + +
+
+ {% endif %} +
+{% endblock %} diff --git a/src/vouch/web/templates/queue.html b/src/vouch/web/templates/queue.html new file mode 100644 index 00000000..c624024a --- /dev/null +++ b/src/vouch/web/templates/queue.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% set active = "queue" %} +{% block title %}queue · vouch review{% endblock %} + +{% block content %} +
+
+

pending

+ {{ count }} {% if count == 1 %}proposal{% else %}proposals{% endif %} +
+ + {% if not items %} +

no pending proposals — every agent write has been reviewed.

+ {% else %} + + {% endif %} +
+{% endblock %} diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 00000000..d9a24afe --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,260 @@ +"""Tests for the browser review console (`vouch review-ui`). + +Covers the MVP-slice acceptance criteria from issue #194: + +* queue renders an HTML response listing pending proposals +* approve POST routes through ``proposals.approve`` and lands the durable + artifact + the audit-log entry (same code path as the CLI) +* reject POST requires a reason and lands the rejection in the audit log +* the audit timeline view surfaces those decisions +* missing-reason on reject returns 400 (no silent half-state) +* a non-existent KB root raises a clean error rather than 500-ing per + request +* `vouch review-ui --bind 0.0.0.0:...` refuses to start in the MVP slice +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner +from fastapi.testclient import TestClient + +from vouch import audit as audit_mod +from vouch.cli import cli +from vouch.models import ProposalStatus +from vouch.proposals import propose_claim +from vouch.storage import KBStore +from vouch.web import create_app + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +@pytest.fixture +def app(store: KBStore): + return create_app(str(store.root)) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def _seed_proposal(store: KBStore, text: str = "the sky is blue") -> str: + """Helper: register a tiny source, then file a claim proposal that + cites it. Returns the proposal id.""" + src = store.put_source(b"some evidence") + pr = propose_claim( + store, text=text, evidence=[src.id], proposed_by="agent-A", + ) + return pr.id + + +# --- queue --------------------------------------------------------------- + + +def test_queue_renders_html_with_pending(client: TestClient, store: KBStore) -> None: + pid = _seed_proposal(store, "the queue should render this claim") + r = client.get("/") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert pid in r.text + assert "the queue should render this claim" in r.text + assert "agent-A" in r.text + + +def test_queue_empty_state(client: TestClient) -> None: + r = client.get("/") + assert r.status_code == 200 + assert "no pending proposals" in r.text + + +def test_api_pending_json(client: TestClient, store: KBStore) -> None: + pid = _seed_proposal(store) + r = client.get("/api/pending") + assert r.status_code == 200 + body = r.json() + assert any(item["id"] == pid for item in body) + assert body[0]["proposed_by"] == "agent-A" + + +# --- claim detail -------------------------------------------------------- + + +def test_claim_detail_renders(client: TestClient, store: KBStore) -> None: + pid = _seed_proposal(store, "detail view shows the full payload") + r = client.get(f"/claim/{pid}") + assert r.status_code == 200 + assert pid in r.text + assert "detail view shows the full payload" in r.text + assert "rationale" not in r.text or "agent-A" in r.text + + +def test_claim_detail_404_for_unknown_id(client: TestClient) -> None: + r = client.get("/claim/proposal-does-not-exist") + assert r.status_code == 404 + + +# --- approve / reject ---------------------------------------------------- + + +def test_approve_routes_through_proposals_module( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The approve POST must hit the same code path as `vouch approve`, + which means the proposal moves to `decided/` and a durable claim + lands in `claims/` + an audit entry in `audit.log.jsonl`.""" + # The MVP web layer uses VOUCH_AGENT as the reviewer identity; set it + # to something other than the proposer so we don't trip self-approval. + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + + pid = _seed_proposal(store, "approve me") + r = client.post(f"/approve/{pid}", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + + # The proposal moved out of pending. + assert not store.list_proposals(ProposalStatus.PENDING) + # And a durable claim now exists with the same payload text. + assert any(c.text == "approve me" for c in store.list_claims()) + # And an audit event was logged with the right shape. + events = [ + e for e in audit_mod.read_events(store.kb_dir) + if e.event == "proposal.claim.approve" + ] + assert len(events) == 1 + assert events[0].actor == "human-reviewer" + assert pid in events[0].object_ids + + +def test_reject_requires_reason( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "reject me") + + # Missing form field → 422 (FastAPI's validation surface). + r = client.post(f"/reject/{pid}", follow_redirects=False) + assert r.status_code in (400, 422) + + # Whitespace-only reason → 400 from our explicit guard. + r = client.post( + f"/reject/{pid}", data={"reason": " "}, follow_redirects=False, + ) + assert r.status_code == 400 + + +def test_reject_lands_audit_event( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "wrong claim") + + r = client.post( + f"/reject/{pid}", + data={"reason": "not a fact, an opinion"}, + follow_redirects=False, + ) + assert r.status_code == 303 + + events = [ + e for e in audit_mod.read_events(store.kb_dir) + if e.event == "proposal.claim.reject" + ] + assert len(events) == 1 + assert events[0].data.get("reason") == "not a fact, an opinion" + + +def test_approve_unknown_proposal_400( + client: TestClient, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + r = client.post("/approve/nope", follow_redirects=False) + assert r.status_code == 400 + + +# --- audit timeline ------------------------------------------------------ + + +def test_audit_view_shows_recent_decisions( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "audit me") + client.post(f"/approve/{pid}") + + r = client.get("/audit") + assert r.status_code == 200 + assert "proposal.claim.approve" in r.text + assert "human-reviewer" in r.text + + +def test_audit_view_empty_state(client: TestClient) -> None: + r = client.get("/audit") + assert r.status_code == 200 + assert "no review decisions" in r.text + + +# --- progressive enhancement: form posts work without JS ----------------- + + +def test_form_post_redirects_and_renders_updated_queue( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The approve flow must work as a plain browser form: submit the + POST, follow the 303 redirect, see the queue rendered without the + just-approved proposal. No JS in the loop.""" + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "form-flow claim") + + r = client.post(f"/approve/{pid}", follow_redirects=True) + assert r.status_code == 200 + assert pid not in r.text # the approved row is gone from the queue + assert "no pending proposals" in r.text + + +# --- healthz -------------------------------------------------------------- + + +def test_healthz(client: TestClient, store: KBStore) -> None: + _seed_proposal(store) + r = client.get("/healthz") + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["pending"] == 1 + + +# --- bring-up errors ------------------------------------------------------ + + +def test_create_app_errors_without_vouch_dir(tmp_path: Path) -> None: + """Building the app against a directory with no `.vouch/` should fail + at bring-up time, not 500 on the first request.""" + from vouch.storage import KBNotFoundError + + bare = tmp_path / "empty" + bare.mkdir() + with pytest.raises(KBNotFoundError): + create_app(str(bare)) + + +# --- CLI: `review-ui --bind 0.0.0.0:...` is refused in the MVP ----------- + + +def test_cli_review_ui_refuses_non_localhost_bind(tmp_path: Path) -> None: + runner = CliRunner() + KBStore.init(tmp_path) + result = runner.invoke( + cli, + ["review-ui", "--bind", "0.0.0.0:7780", "--no-open-browser", + "--kb", str(tmp_path)], + ) + assert result.exit_code != 0 + assert "localhost" in result.output.lower() From 21432e89c83f538fe31a414b6a71a8bc233d3840 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:50:13 +0900 Subject: [PATCH 2/2] fix(web): add jinja2 to mypy ignore-missing-imports overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci installs the [dev] extra only (no [web]), so jinja2 isn't on the python path when mypy scans src/vouch/web/__init__.py — the guard import inside _require_web_extra() trips import-not-found even though the runtime story (raise ImportError if missing) is correct. local mypy missed it because my venv has jinja2 installed for the test suite; ci doesn't. add jinja2 to the same override block that already covers fastapi/uvicorn/starlette. --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dc788d60..b15e9bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,5 +107,10 @@ ignore_missing_imports = true # The web module guards its own imports with a clean ImportError message so # the runtime story stays clean even without the extra. [[tool.mypy.overrides]] -module = ["fastapi", "fastapi.*", "uvicorn", "uvicorn.*", "starlette", "starlette.*"] +module = [ + "fastapi", "fastapi.*", + "jinja2", "jinja2.*", + "uvicorn", "uvicorn.*", + "starlette", "starlette.*", +] ignore_missing_imports = true