diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 84901e74..e0aa896b 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -67,6 +67,7 @@ "kb.archive", "kb.confirm", "kb.clear_claims", + "kb.wipe_dead_refs", "kb.cite", "kb.source_verify", "kb.session_start", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index b6a5515a..e41451b8 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -73,6 +73,7 @@ ProposalError, check_approvable, expire_pending, + missing_claim_refs, propose_claim, propose_delete, propose_entity, @@ -1596,7 +1597,19 @@ def triage(proposal_ids: tuple[str, ...], as_json: bool, reverse: bool) -> None: help="Best-effort: approve every id that can be approved and report the " "rest, instead of the default all-or-nothing precheck.", ) -def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool) -> None: +@click.option( + "--drop-missing-claims", + is_flag=True, + help="Strip references to claims that no longer exist from page " + "proposals instead of refusing to approve them (dropped ids are " + "recorded in the audit event).", +) +def approve( + proposal_ids: tuple[str, ...], + reason: str | None, + keep_going: bool, + drop_missing_claims: bool, +) -> None: """Approve one or more proposals — converts each into a durable artifact. Pass several ids to approve a batch in one call (useful for CI and @@ -1611,16 +1624,46 @@ def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool) aborts the whole batch and nothing is approved. - --keep-going (best-effort): approve each id independently, report the failures, and exit non-zero if any failed. + + A page proposal citing claims that no longer exist blocks the batch; + interactively you are offered to strip the dead references, and + --drop-missing-claims does the same without the prompt. """ store = _load_store() approver = _whoami() if not keep_going: - blocked = [ - (pid, reason_blocked) - for pid in proposal_ids - if (reason_blocked := check_approvable(store, pid, approved_by=approver)) - ] + blocked: list[tuple[str, str]] = [] + dead_blocked: list[tuple[str, list[str]]] = [] + for pid in proposal_ids: + why = check_approvable(store, pid, approved_by=approver) + if not why: + continue + # A dead claim reference is a decision, not a defect: offer the + # strip-and-approve path instead of a hard abort. Any other block + # (typo, already decided, self-approval) still aborts the batch. + if "references unknown claim" in why: + dead = missing_claim_refs(store, store.get_proposal(pid)) + if dead: + dead_blocked.append((pid, dead)) + continue + blocked.append((pid, why)) + if dead_blocked and not drop_missing_claims: + for pid, dead in dead_blocked: + click.echo( + f"! {pid}: cites missing claim(s): {', '.join(dead)}", err=True + ) + if sys.stdin.isatty() and click.confirm( + f"strip the dead claim reference(s) from {len(dead_blocked)} " + "proposal(s) and approve?" + ): + drop_missing_claims = True + else: + blocked.extend( + (pid, f"cites missing claim(s): {', '.join(dead)} " + "(use --drop-missing-claims to strip them)") + for pid, dead in dead_blocked + ) if blocked: for pid, why in blocked: click.echo(f"✗ {pid}: {why}", err=True) @@ -1632,7 +1675,10 @@ def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool) failures = 0 for pid in proposal_ids: try: - artifact = do_approve(store, pid, approved_by=approver, reason=reason) + artifact = do_approve( + store, pid, approved_by=approver, reason=reason, + drop_missing_claims=drop_missing_claims, + ) except (ArtifactNotFoundError, ValueError, ProposalError, LifecycleError) as e: failures += 1 click.echo(f"✗ {pid}: {e}", err=True) @@ -2594,6 +2640,53 @@ def claims_clear(auto_only: bool, before: str | None, confirm: bool, dry_run: bo click.echo(f"cleared {len(to_clear)} claims") +@cli.command(name="wipe-dead-refs") +@click.option("--dry-run", is_flag=True, default=False, + help="Preview what would be stripped without making changes") +@click.option("--confirm", "skip_confirm", is_flag=True, default=False, + help="Skip confirmation prompt") +def wipe_dead_refs(dry_run: bool, skip_confirm: bool) -> None: + """Strip references to claims that no longer exist, KB-wide. + + Scans durable pages and pending page proposals for claim ids that + resolve to no claim file (archived claims still resolve) and removes + them — frontmatter list and inline [claim: …] markers both. One + audited bulk event records what was removed. Run after claims were + redacted or bulk-cleared and lint reports orphan_page_ref. + """ + store = _load_store() + with _cli_errors(): + preview = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=True) + + if not preview.pages and not preview.proposals: + click.echo("no dead claim references found") + return + + click.echo(f"found {preview.dropped} dead claim reference(s):") + for page_id, dead in preview.pages.items(): + click.echo(f" page {page_id}: {', '.join(dead)}") + for pid, dead in preview.proposals.items(): + click.echo(f" proposal {pid}: {', '.join(dead)}") + + if dry_run: + click.echo("(dry-run mode: no changes made)") + return + + if not skip_confirm and not click.confirm( + f"\nStrip {preview.dropped} dead reference(s)?" + ): + click.echo("cancelled") + return + + with _cli_errors(): + result = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=False) + click.echo( + f"stripped {result.dropped} dead reference(s) from " + f"{len(result.pages)} page(s) and {len(result.proposals)} " + "pending proposal(s)" + ) + + @cli.command() @click.argument("claim_id") def confirm(claim_id: str) -> None: diff --git a/src/vouch/hot_memory.py b/src/vouch/hot_memory.py index fea42ea1..f0b343be 100644 --- a/src/vouch/hot_memory.py +++ b/src/vouch/hot_memory.py @@ -164,6 +164,7 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non "kb.compile": "write path — review gate (files page proposals via wiki-compiler)", "kb.summarize_session": "write path — review gate (files session-summary page proposals)", "kb.clear_claims": "lifecycle — mutates durable state", + "kb.wipe_dead_refs": "lifecycle — mutates durable state", "kb.approve": "lifecycle — mutates durable state", "kb.reject": "lifecycle — mutates durable state", "kb.reject_extracted": "lifecycle — mutates durable state", diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index af82c618..f71ef3e4 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -46,6 +46,7 @@ from .page_filters import filter_pages from .proposals import ( EXPIRE_ACTOR, + DeadClaimRefsError, ProposalError, approve, expire_pending, @@ -508,7 +509,8 @@ def _h_propose_delete(p: dict) -> dict: def _h_approve(p: dict) -> dict: a = approve(_store(), p["proposal_id"], approved_by=_agent(), - reason=p.get("reason")) + reason=p.get("reason"), + drop_missing_claims=bool(p.get("drop_missing_claims", False))) return {"kind": type(a).__name__.lower(), "id": a.id} @@ -560,6 +562,18 @@ def _h_archive(p: dict) -> dict: return {"id": c.id, "status": c.status.value} +def _h_wipe_dead_refs(p: dict) -> dict: + r = life.wipe_dead_claim_refs( + _store(), actor=_agent(), dry_run=bool(p.get("dry_run", False)), + ) + return { + "pages": r.pages, + "proposals": r.proposals, + "dropped": r.dropped, + "dry_run": r.dry_run, + } + + def _h_confirm(p: dict) -> dict: c = life.confirm(_store(), claim_id=p["claim_id"], actor=_agent()) return {"id": c.id, "last_confirmed_at": c.last_confirmed_at.isoformat() @@ -885,6 +899,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.archive": _h_archive, "kb.confirm": _h_confirm, "kb.clear_claims": _h_clear_claims, + "kb.wipe_dead_refs": _h_wipe_dead_refs, "kb.cite": _h_cite, "kb.source_verify": _h_source_verify, "kb.session_start": _h_session_start, @@ -941,6 +956,14 @@ def handle_request(envelope: dict) -> dict: "id": req_id, "ok": False, "error": {"code": "missing_param", "message": str(e)}, } + except DeadClaimRefsError as e: + # Distinct code so interactive clients can offer "strip dead refs + # and approve" and retry with drop_missing_claims — a message-match + # on invalid_request would be a brittle contract. + return { + "id": req_id, "ok": False, + "error": {"code": "dead_claim_refs", "message": str(e)}, + } except (ValueError, ProposalError, ArtifactNotFoundError) as e: return { "id": req_id, "ok": False, diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py index 90a9cb2c..735076e2 100644 --- a/src/vouch/lifecycle.py +++ b/src/vouch/lifecycle.py @@ -11,10 +11,20 @@ from __future__ import annotations +from dataclasses import dataclass, field from datetime import UTC, datetime -from . import audit -from .models import Claim, ClaimStatus, Evidence, Relation, RelationType +from . import audit, index_db +from .models import ( + Claim, + ClaimStatus, + Evidence, + ProposalKind, + ProposalStatus, + Relation, + RelationType, +) +from .proposals import strip_claim_markers from .storage import ArtifactNotFoundError, KBStore @@ -219,6 +229,82 @@ def clear_claims( return to_clear +@dataclass +class DeadRefsWipeResult: + """Outcome of `wipe_dead_claim_refs` (dry-run or apply).""" + + pages: dict[str, list[str]] = field(default_factory=dict) + proposals: dict[str, list[str]] = field(default_factory=dict) + dry_run: bool = False + + @property + def dropped(self) -> int: + return sum(len(v) for v in self.pages.values()) + sum( + len(v) for v in self.proposals.values() + ) + + +def wipe_dead_claim_refs( + store: KBStore, + *, + actor: str, + dry_run: bool = False, +) -> DeadRefsWipeResult: + """Strip references to claims that no longer exist, KB-wide. + + Covers durable pages and pending PAGE proposals: the frontmatter claim + list and the inline `[claim: …]` body markers both lose the dead ids. + Claims themselves are untouched — an archived claim's file still exists, + so only ids that resolve to nothing count as dead. One audited bulk + event records exactly which ids were removed from where. + """ + result = DeadRefsWipeResult(dry_run=dry_run) + for page in store.list_pages(): + dead = [c for c in page.claims if not store._claim_path(c).exists()] + if not dead: + continue + result.pages[page.id] = dead + if dry_run: + continue + page.claims = [c for c in page.claims if c not in dead] + page.body = strip_claim_markers(page.body, dead) + page.updated_at = datetime.now(UTC) + store.update_page(page) + with index_db.open_db(store.kb_dir) as conn: + index_db.index_page( + conn, id=page.id, title=page.title, body=page.body, + type=page.type, tags=page.tags, + ) + for prop in store.list_proposals(ProposalStatus.PENDING): + if prop.kind != ProposalKind.PAGE: + continue + refs = prop.payload.get("claims") or [] + dead = [c for c in refs if not store._claim_path(c).exists()] + if not dead: + continue + result.proposals[prop.id] = dead + if dry_run: + continue + prop.payload["claims"] = [c for c in refs if c not in dead] + body = prop.payload.get("body") + if isinstance(body, str): + prop.payload["body"] = strip_claim_markers(body, dead) + store.update_proposal(prop) + if not dry_run and (result.pages or result.proposals): + audit.log_event( + store.kb_dir, + event="page.dead_refs_wipe", + actor=actor, + object_ids=[*result.pages, *result.proposals], + data={ + "pages": result.pages, + "proposals": result.proposals, + "dropped": result.dropped, + }, + ) + return result + + def cite(store: KBStore, claim_id: str) -> list[Evidence | dict]: """Return resolved citations for a claim. diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 34b9ba38..2c8feece 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -7,6 +7,7 @@ from __future__ import annotations import contextlib +import re import time import uuid from dataclasses import dataclass, field @@ -37,6 +38,42 @@ class ProposalError(RuntimeError): pass +class DeadClaimRefsError(ProposalError): + """Approval blocked: the page payload cites claim ids that no longer exist. + + Raised instead of a bare ProposalError so interactive surfaces (CLI + prompt, console dialog) can detect the case, show the missing ids, and + retry the approve with drop_missing_claims=True. Claims can disappear + between propose and approve — archived, redacted, or removed in a bulk + clear — so this is a normal reviewer decision, not a corrupt proposal. + """ + + def __init__(self, proposal_id: str, missing: list[str]) -> None: + self.proposal_id = proposal_id + self.missing = list(missing) + super().__init__( + f"page proposal {proposal_id} references missing claim(s): " + + ", ".join(self.missing) + + " — approve with drop_missing_claims to strip the dead " + "references, or reject the proposal" + ) + + +def strip_claim_markers(body: str, ids: list[str]) -> str: + """Remove inline `[claim: ]` markers for `ids` from a page body.""" + for cid in ids: + body = re.sub(rf"\s*\[claim:\s*{re.escape(cid)}\]", "", body) + return body + + +def missing_claim_refs(store: KBStore, proposal: Proposal) -> list[str]: + """Claim ids a PAGE proposal cites that don't resolve to a claim file.""" + if proposal.kind != ProposalKind.PAGE: + return [] + refs = proposal.payload.get("claims") or [] + return [cid for cid in refs if not store._claim_path(cid).exists()] + + EXPIRE_REASON = "expired" EXPIRE_ACTOR = "vouch-expire" ADMISSION_ACTOR = "vouch-admission" @@ -714,17 +751,25 @@ def approve( *, approved_by: str, reason: str | None = None, + drop_missing_claims: bool = False, ) -> Claim | Page | Entity | Relation: """Approve a pending proposal and write it as a durable artifact. Raises ProposalError if the proposal is not pending or if approved_by matches proposed_by (forbidden_self_approval). + + A PAGE proposal citing claim ids that no longer exist raises + DeadClaimRefsError so the reviewer can decide: retry with + drop_missing_claims=True to strip the dead references (frontmatter list + and inline `[claim: …]` markers) and approve what remains — the dropped + ids are recorded in the audit event. """ proposal = store.get_proposal(proposal_id) block = _approval_block_reason(store, proposal, approved_by) if block: raise ProposalError(block) payload = dict(proposal.payload) + dropped_claims: list[str] = [] # Refuse to overwrite an existing artifact. Without this guard a retry # after a crash between put_() and move_proposal_to_decided() would # silently rewrite the artifact with new approved_by / created_at metadata. @@ -750,6 +795,16 @@ def approve( ) result = claim elif proposal.kind == ProposalKind.PAGE: + missing = missing_claim_refs(store, proposal) + if missing: + if not drop_missing_claims: + raise DeadClaimRefsError(proposal.id, missing) + payload["claims"] = [ + c for c in (payload.get("claims") or []) if c not in missing + ] + if isinstance(payload.get("body"), str): + payload["body"] = strip_claim_markers(payload["body"], missing) + dropped_claims = missing page = Page(**payload) # Re-validate the kind at the gate: config may have tightened (or a # kind been removed) between propose and approve. Built-in kinds pass @@ -808,10 +863,13 @@ def approve( # a crash between the two must leave a pending proposal WITH its decision # event (recoverable; retry is blocked by _ensure_no_existing_artifact), # never a decided proposal without one. + decision_data: dict[str, Any] = {"reason": reason} + if dropped_claims: + decision_data["dropped_claims"] = dropped_claims audit.log_event( store.kb_dir, event=f"proposal.{proposal.kind.value}.approve", actor=approved_by, object_ids=[proposal.id, result.id], - data={"reason": reason}, + data=decision_data, ) store.move_proposal_to_decided(proposal) return result diff --git a/src/vouch/server.py b/src/vouch/server.py index 39818c4a..3f9246ca 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -756,11 +756,21 @@ def _proposal_response(result, dry_run: bool) -> dict[str, Any]: @mcp.tool() -def kb_approve(proposal_id: str, reason: str | None = None) -> dict[str, Any]: - """Approve a proposal → durable artifact. Use carefully.""" +def kb_approve( + proposal_id: str, + reason: str | None = None, + drop_missing_claims: bool = False, +) -> dict[str, Any]: + """Approve a proposal → durable artifact. Use carefully. + + A page proposal citing claims that no longer exist is refused; pass + drop_missing_claims=True to strip the dead references and approve what + remains (the dropped ids are recorded in the audit event). + """ try: artifact = approve(_store(), proposal_id, approved_by=_agent(), - reason=reason) + reason=reason, + drop_missing_claims=drop_missing_claims) except (ArtifactNotFoundError, ValueError, ProposalError) as e: raise ValueError(str(e)) from e return {"kind": type(artifact).__name__.lower(), "id": artifact.id} @@ -879,6 +889,30 @@ def kb_clear_claims( } +@mcp.tool() +def kb_wipe_dead_refs(dry_run: bool = False) -> dict[str, Any]: + """Strip references to claims that no longer exist, KB-wide. + + Covers durable pages and pending page proposals: the frontmatter claim + list and inline `[claim: …]` markers both lose ids that resolve to no + claim file. Audited as one bulk event. Use after claims were archived, + redacted, or bulk-cleared and pages still point at them. + + Args: + dry_run: If True, report what would be stripped without writing. + + Returns: + Per-page and per-proposal dead ids, total dropped count. + """ + r = life.wipe_dead_claim_refs(_store(), actor=_agent(), dry_run=dry_run) + return { + "pages": r.pages, + "proposals": r.proposals, + "dropped": r.dropped, + "dry_run": r.dry_run, + } + + @mcp.tool() def kb_cite(claim_id: str) -> list[dict[str, Any]]: """Return resolved citations (sources or evidence records) backing a claim.""" diff --git a/tests/test_dead_claim_refs.py b/tests/test_dead_claim_refs.py new file mode 100644 index 00000000..77410e9d --- /dev/null +++ b/tests/test_dead_claim_refs.py @@ -0,0 +1,160 @@ +"""Dead claim references: approve-time strip + KB-wide wipe. + +A claim can disappear between propose and approve (archived file removed, +redaction, bulk clear) while pages still point at it. Approval refuses the +dead reference by default, offers drop_missing_claims to strip it, and +lifecycle.wipe_dead_claim_refs clears every dead pointer KB-wide. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import lifecycle as life +from vouch.jsonl_server import handle_request +from vouch.proposals import ( + DeadClaimRefsError, + approve, + missing_claim_refs, + propose_claim, + propose_page, + strip_claim_markers, +) +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _approved_claim(store: KBStore, text: str) -> str: + src = store.put_source(text.encode()) + pr = propose_claim(store, text=text, evidence=[src.id], proposed_by="agent") + return approve(store, pr.id, approved_by="human").id + + +def _dead_ref_page_proposal(store: KBStore, *, title: str = "topic page"): + """A pending PAGE proposal whose only cited claim no longer resolves.""" + cid = _approved_claim(store, f"claim behind {title}") + pr = propose_page( + store, + title=title, + body=f"the fact. [claim: {cid}] more prose.", + claim_ids=[cid], + proposed_by="agent", + ) + store._claim_path(cid).unlink() + return pr, cid + + +def _audit_events(store: KBStore) -> list[dict]: + text = (store.kb_dir / "audit.log.jsonl").read_text(encoding="utf-8") + return [json.loads(line) for line in text.splitlines() if line.strip()] + + +def test_approve_refuses_dead_claim_refs(store: KBStore) -> None: + pr, cid = _dead_ref_page_proposal(store) + with pytest.raises(DeadClaimRefsError) as exc: + approve(store, pr.id, approved_by="human") + assert exc.value.missing == [cid] + assert exc.value.proposal_id == pr.id + assert store.get_proposal(pr.id).status.value == "pending" + + +def test_approve_drop_missing_claims_strips_and_audits(store: KBStore) -> None: + pr, cid = _dead_ref_page_proposal(store) + page = approve(store, pr.id, approved_by="human", drop_missing_claims=True) + assert cid not in page.claims + assert f"[claim: {cid}]" not in page.body + assert "the fact." in page.body # prose survives, only the marker goes + ev = [e for e in _audit_events(store) if e["event"] == "proposal.page.approve"][-1] + assert ev["data"]["dropped_claims"] == [cid] + + +def test_missing_claim_refs_ignores_live_and_archived(store: KBStore) -> None: + live = _approved_claim(store, "live claim") + archived = _approved_claim(store, "archived claim") + pr = propose_page( + store, title="healthy", body="b", claim_ids=[live, archived], + proposed_by="agent", + ) + life.archive(store, claim_id=archived, actor="human") + # An archived claim's file still exists — only unresolvable ids are dead. + assert missing_claim_refs(store, store.get_proposal(pr.id)) == [] + page = approve(store, pr.id, approved_by="human") + assert page.claims == [live, archived] + + +def test_wipe_dead_claim_refs_pages_and_pending(store: KBStore) -> None: + # A durable page whose cited claim dies after approval … + cid = _approved_claim(store, "will die") + ppr = propose_page( + store, title="durable", body=f"x [claim: {cid}]", claim_ids=[cid], + proposed_by="agent", + ) + approve(store, ppr.id, approved_by="human") + # … and a pending page proposal in the same dead-ref state. + pending, cid2 = _dead_ref_page_proposal(store, title="pending page") + store._claim_path(cid).unlink() + + preview = life.wipe_dead_claim_refs(store, actor="human", dry_run=True) + assert preview.dry_run + assert preview.dropped == 2 + assert cid in store.get_page("durable").claims # dry-run wrote nothing + + result = life.wipe_dead_claim_refs(store, actor="human") + assert result.pages == {"durable": [cid]} + assert result.proposals == {pending.id: [cid2]} + page = store.get_page("durable") + assert page.claims == [] + assert "[claim:" not in page.body + assert store.get_proposal(pending.id).payload["claims"] == [] + assert any(e["event"] == "page.dead_refs_wipe" for e in _audit_events(store)) + + again = life.wipe_dead_claim_refs(store, actor="human") + assert again.dropped == 0 + + +def test_wipe_stripped_pending_proposal_is_approvable(store: KBStore) -> None: + pending, _ = _dead_ref_page_proposal(store) + life.wipe_dead_claim_refs(store, actor="human") + page = approve(store, pending.id, approved_by="human") + assert page.claims == [] + + +def test_strip_claim_markers_targets_only_named_ids() -> None: + body = "a [claim: one] b [claim: two] c" + assert strip_claim_markers(body, ["one"]) == "a b [claim: two] c" + + +def test_jsonl_approve_dead_refs_error_code(store: KBStore, monkeypatch) -> None: + pr, _ = _dead_ref_page_proposal(store) + monkeypatch.chdir(store.root) + resp = handle_request({"id": "r1", "method": "kb.approve", + "params": {"proposal_id": pr.id}}) + assert not resp["ok"] + assert resp["error"]["code"] == "dead_claim_refs" + + resp = handle_request({"id": "r2", "method": "kb.approve", + "params": {"proposal_id": pr.id, + "drop_missing_claims": True}}) + assert resp["ok"] + assert resp["result"]["kind"] == "page" + + +def test_jsonl_wipe_dead_refs(store: KBStore, monkeypatch) -> None: + pr, cid = _dead_ref_page_proposal(store) + monkeypatch.chdir(store.root) + resp = handle_request({"id": "r1", "method": "kb.wipe_dead_refs", + "params": {"dry_run": True}}) + assert resp["ok"] + assert resp["result"]["dry_run"] is True + assert resp["result"]["proposals"] == {pr.id: [cid]} + + resp = handle_request({"id": "r2", "method": "kb.wipe_dead_refs", "params": {}}) + assert resp["ok"] + assert resp["result"]["dropped"] == 1 diff --git a/webapp/src/views/PendingView.deadrefs.test.tsx b/webapp/src/views/PendingView.deadrefs.test.tsx new file mode 100644 index 00000000..3ee7e036 --- /dev/null +++ b/webapp/src/views/PendingView.deadrefs.test.tsx @@ -0,0 +1,101 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('../lib/rpc', async () => { + const actual = await vi.importActual('../lib/rpc') + return { ...actual, rpc: vi.fn(), fetchHealth: vi.fn(), fetchCapabilities: vi.fn() } +}) +import { fetchCapabilities, fetchHealth, rpc, VouchRpcError } from '../lib/rpc' +import { renderWithProviders, seedConnection } from '../test/utils' +import { PendingView } from './PendingView' + +const CAPS = { + name: 'vouch', + level: 3, + methods: ['kb.list_pending', 'kb.approve', 'kb.reject', 'kb.wipe_dead_refs'], + review_gated: true, +} + +const PAGE_PROPOSAL = { + id: '20260724-160246-deadref1', + kind: 'page', + proposed_by: 'wiki-compiler', + session_id: null, + payload: { + title: 'Compiled topic page', + body: 'prose. [claim: gone-claim] more prose.', + claims: ['gone-claim'], + }, + status: 'pending', + proposed_at: '2026-07-24T16:02:46+00:00', +} + +const DEAD_REFS_ERROR = new VouchRpcError( + 'dead_claim_refs', + 'page proposal 20260724-160246-deadref1 references missing claim(s): gone-claim', +) + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + vi.mocked(fetchHealth).mockResolvedValue(true) + vi.mocked(fetchCapabilities).mockResolvedValue(CAPS) + seedConnection() +}) + +test('dead_claim_refs on approve offers strip-and-retry, retry sends the flag', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method, params) => { + if (method === 'kb.list_pending') return [PAGE_PROPOSAL] + if (method === 'kb.approve') { + if ((params as { drop_missing_claims?: boolean }).drop_missing_claims) + return { kind: 'page', id: 'compiled-topic-page' } + throw DEAD_REFS_ERROR + } + return [] + }) + renderWithProviders() + + await userEvent.click(await screen.findByText(PAGE_PROPOSAL.id)) + await userEvent.click(screen.getByRole('button', { name: /^approve$/i })) + + // The refusal renders as a decision card, not a bare error. + expect(await screen.findByText(/cites claim\(s\) that no longer exist/i)).toBeInTheDocument() + expect(screen.getByText(/references missing claim\(s\): gone-claim/)).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /strip dead refs & approve/i })) + await waitFor(() => { + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.approve', { + proposal_id: PAGE_PROPOSAL.id, + drop_missing_claims: true, + }) + }) +}) + +test('wipe dead refs button previews via dry-run, then applies', async () => { + const report = { + pages: { 'stale-page': ['gone-claim'] }, + proposals: { [PAGE_PROPOSAL.id]: ['gone-claim'] }, + dropped: 2, + } + vi.mocked(rpc).mockImplementation(async (_c, method, params) => { + if (method === 'kb.list_pending') return [PAGE_PROPOSAL] + if (method === 'kb.wipe_dead_refs') + return { ...report, dry_run: (params as { dry_run?: boolean }).dry_run === true } + return [] + }) + renderWithProviders() + + await userEvent.click(await screen.findByRole('button', { name: /wipe dead claim refs/i })) + await waitFor(() => { + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.wipe_dead_refs', { dry_run: true }) + }) + + // Dry-run preview: counts + explicit confirm before anything is written. + expect(await screen.findByText(/2 dead claim ref\(s\) in 1 page\(s\)/i)).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /wipe 2 dead ref\(s\)/i })) + await waitFor(() => { + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.wipe_dead_refs', { dry_run: false }) + }) + expect(await screen.findByText(/stripped 2 dead reference\(s\)/i)).toBeInTheDocument() +}) diff --git a/webapp/src/views/PendingView.tsx b/webapp/src/views/PendingView.tsx index 09a244d8..fe8ea1e1 100644 --- a/webapp/src/views/PendingView.tsx +++ b/webapp/src/views/PendingView.tsx @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { BookOpen, Check, LoaderCircle, Merge, X } from 'lucide-react' +import { BookOpen, Check, Eraser, LoaderCircle, Merge, X } from 'lucide-react' import { useState } from 'react' import { EmptyState } from '../components/EmptyState' import { ErrorCard } from '../components/ErrorCard' @@ -59,6 +59,14 @@ type CompileReport = { dry_run: boolean } +// Shape of the kb.wipe_dead_refs envelope (lifecycle.DeadRefsWipeResult). +type DeadRefsReport = { + pages: Record + proposals: Record + dropped: number + dry_run: boolean +} + /** One queue row: a pending proposal plus the project it belongs to. */ interface Row { project: ProjectState @@ -81,6 +89,8 @@ export function PendingView() { const [checked, setChecked] = useState>(new Set()) const [clearing, setClearing] = useState(false) const [clearReason, setClearReason] = useState('') + const [deadRefRows, setDeadRefRows] = useState([]) + const [wipePreview, setWipePreview] = useState(null) const pending = useFanout(['pending'], 'kb.list_pending', {}, { refetchInterval: 10_000 }) useErrorToast(pending.errors.length > 0, pending.errors[0]?.error) @@ -165,7 +175,9 @@ export function PendingView() { function decisionFailed(err: unknown) { const code = err instanceof VouchRpcError ? err.code : undefined const message = err instanceof Error ? err.message : String(err) - toast('error', code ? `${code}: ${message}` : message) + // dead_claim_refs renders as a decision card (strip & approve?), not a + // failure toast — the reviewer has a next step, nothing went wrong. + if (code !== 'dead_claim_refs') toast('error', code ? `${code}: ${message}` : message) setDecisionError({ code, message }) } @@ -183,12 +195,13 @@ export function PendingView() { } const approve = useMutation({ - mutationFn: (row: Row) => - rpc<{ kind: string; id: string }>(row.project.conn, 'kb.approve', { - proposal_id: row.proposal.id, + mutationFn: (vars: { row: Row; dropMissingClaims?: boolean }) => + rpc<{ kind: string; id: string }>(vars.row.project.conn, 'kb.approve', { + proposal_id: vars.row.proposal.id, + ...(vars.dropMissingClaims ? { drop_missing_claims: true } : {}), }), - onMutate: removeOptimistically, - onError: (err, _row, ctx) => { + onMutate: (vars) => removeOptimistically(vars.row), + onError: (err, _vars, ctx) => { rollback(ctx) decisionFailed(err) }, @@ -253,24 +266,66 @@ export function PendingView() { // Batch approve: approve every checked approvable row. No bulk endpoint exists — // loop client-side per project, the same shape as clear (reject-all). + // Rows refused with dead_claim_refs are collected for a one-click + // strip-and-retry pass instead of counting as plain failures. const approveSelected = useMutation({ - mutationFn: async () => { + mutationFn: async (vars: { rows: Row[]; dropMissingClaims?: boolean }) => { const results = await Promise.allSettled( - approveTargets.map((r) => - rpc(r.project.conn, 'kb.approve', { proposal_id: r.proposal.id }), + vars.rows.map((r) => + rpc(r.project.conn, 'kb.approve', { + proposal_id: r.proposal.id, + ...(vars.dropMissingClaims ? { drop_missing_claims: true } : {}), + }), ), ) - const ok = results.filter((x) => x.status === 'fulfilled').length - return { ok, failed: results.length - ok } + const deadRefs: Row[] = [] + let ok = 0 + let failed = 0 + results.forEach((res, i) => { + if (res.status === 'fulfilled') ok += 1 + else if (res.reason instanceof VouchRpcError && res.reason.code === 'dead_claim_refs') + deadRefs.push(vars.rows[i]) + else failed += 1 + }) + return { ok, failed, deadRefs } }, onError: decisionFailed, - onSuccess: ({ ok, failed }) => { - toast(failed ? 'info' : 'success', `Approved ${ok}${failed ? ` (${failed} failed)` : ''}`) + onSuccess: ({ ok, failed, deadRefs }) => { + setDeadRefRows(deadRefs) + const parts = [`Approved ${ok}`] + if (deadRefs.length) parts.push(`${deadRefs.length} cite missing claims`) + if (failed) parts.push(`${failed} failed`) + toast(failed ? 'info' : 'success', parts.join(' — ')) setChecked(new Set()) afterDecision() }, }) + // Wipe dead refs is KB-wide (pages + pending page proposals), so it is + // scoped to a single project like compile. Two-step: a dry-run previews + // what would be stripped, the confirm click applies it. + const canWipe = !aggregated && !!conn && hasMethod('kb.wipe_dead_refs') + const wipeDeadRefs = useMutation({ + mutationFn: (vars: { dryRun: boolean }) => + rpc(conn!, 'kb.wipe_dead_refs', { dry_run: vars.dryRun }), + onError: decisionFailed, + onSuccess: (res) => { + if (res.dry_run) { + if (res.dropped === 0) toast('info', 'No dead claim references found') + else setWipePreview(res) + return + } + setWipePreview(null) + toast( + 'success', + `Stripped ${res.dropped} dead reference(s) from ` + + `${Object.keys(res.pages).length} page(s) and ` + + `${Object.keys(res.proposals).length} pending proposal(s)`, + ) + afterDecision() + }, + }) + // Compile ingests ONE project's approved claims — it is offered when the // scope names a single project (use the scope switcher to pick one). const canCompile = !aggregated && !!conn && hasMethod('kb.compile') @@ -304,6 +359,52 @@ export function PendingView() { else if (e.event === 'proposal.claim.approve') freshApprovals += 1 } + // Rendered on the empty queue too: dead references live in durable pages, + // so the cleanup is offered even when nothing is pending. + const wipeBar = canWipe ? ( +
+ {wipePreview ? ( + <> + + {wipePreview.dropped} dead claim ref(s) in {Object.keys(wipePreview.pages).length} page(s),{' '} + {Object.keys(wipePreview.proposals).length} pending proposal(s) + + + + + ) : ( + <> + + + strip references to claims that no longer exist (audited) + + + )} +
+ ) : null + // The compile bar renders on the empty queue too — that is the natural // starting state for an ingest pass over already-approved claims. const compileBar = canCompile ? ( @@ -362,6 +463,7 @@ export function PendingView() { return (
{compileBar} + {wipeBar}
{compileBar} + {wipeBar} {canClear && (clearing ? (
@@ -435,7 +538,7 @@ export function PendingView() { {approveTargets.length >= 1 && ( <>
)} + {deadRefRows.length > 0 && ( +
+ + {deadRefRows.length} proposal(s) cite claims that no longer exist + + + +
+ )} {canMerge && mergeRows.length >= 2 && (
+ +
+
+ ) : decisionError ? (
- )} + ) : null} {!canDecide ? (

@@ -607,7 +763,7 @@ export function PendingView() { ) : (