feat(snippets): drift check — verify a snippet still matches its source
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Build & push image (push) Has been skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Failing after 22s
CI & Build / Build & push image (push) Has been skipped
A recorded snippet points at a repo · path · symbol that WILL rot: files move, symbols get renamed, implementations diverge from the copy stored here. Nothing detected any of it, so a record degraded silently from "canonical reference" to "confidently wrong" — worse than no record, since it is surfaced with the same authority either way. WHERE THE CHECK RUNS. Agent-side, which the task flagged as the design question to settle first. Scribe has no checkout of the operator's repos and must not acquire one: giving the server repo access would make every install a credential problem and break instance-agnosticism (rule #115). The agent already has the working tree, so it does the comparing; the server remembers the verdict, makes it queryable, and knows when it has expired. New MCP tool verify_snippet teaches the four-step procedure and records the result; a REST endpoint mirrors it so the UI can clear a marker after a manual fix. WHY THE VERDICT CARRIES A CODE HASH. A verdict describes the code it was checked against. Invalidating it on edit means deciding which edits count — a when_to_use tweak shouldn't void a code check, a rewrite must — which is fiddly and easy to get subtly wrong, and easy for a new write path to forget entirely. Stamping the verdict with a hash sidesteps all of it: one whose code_sha no longer matches is self-evidently expired, computed at read time, no invalidation branch to maintain. That makes "expired" the interesting filter case. It is not `drifted` (nothing was found wrong) and not `unverified` (a check did happen), yet it plainly needs looking at — so `verification=attention` covers both. To keep that one index-served predicate rather than a post-filter that would make the pagination total a lie, data now also mirrors the CURRENT code's fingerprint as data.code_sha, and a jsonpath compares the two fields within the row. The filter is implemented in both dialects, SQL and Python, for the same reason the location filter is: the semantic arm's candidates arrive already fetched. A merge deliberately carries no verdict forward — the survivor's code is a union of several sources, so no prior check describes it, and unverified is the honest answer. UI: a danger-toned drift badge on each card (an actively misleading record outranks a merely unused one), and a "Needs attention" filter. Its empty state says plainly that never-verified snippets don't appear there — otherwise `attention` would mean "everything" on day one and be useless as a worklist. Refs #2086 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
@@ -62,10 +62,12 @@ async def list_snippets_route():
|
||||
repo = request.args.get("repo", "")
|
||||
path = request.args.get("path", "")
|
||||
symbol = request.args.get("symbol", "")
|
||||
# Drift check (#2086). "attention" is what the UI's filter chip sends.
|
||||
verification = request.args.get("verification", "")
|
||||
limit, offset = parse_pagination()
|
||||
items, total = await snippets_svc.list_snippets(
|
||||
uid, q=q, tag=tag, limit=limit, offset=offset, project_id=project_id,
|
||||
repo=repo, path=path, symbol=symbol,
|
||||
repo=repo, path=path, symbol=symbol, verification=verification,
|
||||
)
|
||||
# Mark rows owned by someone else so the UI can show whose they are — an
|
||||
# unmarked row in your own list reads as one you recorded and vetted.
|
||||
@@ -203,6 +205,44 @@ async def update_snippet_route(snippet_id: int):
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>/verify", methods=["POST"])
|
||||
@login_required
|
||||
async def verify_snippet_route(snippet_id: int):
|
||||
"""Record a drift-check verdict. Body: {"status": ..., "detail", "path"}.
|
||||
|
||||
The CHECK itself runs wherever the code is — an agent with the working tree
|
||||
— because Scribe has no checkout and shouldn't have one. This endpoint just
|
||||
stores what was found. It's here for parity with the MCP tool and so the UI
|
||||
can clear a stale marker after the operator fixes a record by hand."""
|
||||
uid = get_current_user_id()
|
||||
if await _load_snippet(uid, snippet_id) is None:
|
||||
return not_found("Snippet")
|
||||
# Distinguished from not-found deliberately: the service returns None for
|
||||
# both, and telling a shared reader "no such snippet" about one they can
|
||||
# plainly see is a confusing lie.
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
data = await request.get_json() or {}
|
||||
status = (data.get("status") or "").strip()
|
||||
if not status:
|
||||
return jsonify({"error": "status is required"}), 400
|
||||
try:
|
||||
updated = await snippets_svc.record_verification(
|
||||
uid, snippet_id,
|
||||
status=status,
|
||||
detail=data.get("detail") or "",
|
||||
path=data.get("path") or "",
|
||||
)
|
||||
except ValueError as exc:
|
||||
# An unknown status — reject it rather than storing a value the filter
|
||||
# would then never match.
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if updated is None:
|
||||
return not_found("Snippet")
|
||||
return jsonify(snippets_svc.snippet_to_dict(updated))
|
||||
|
||||
|
||||
@snippets_bp.route("/<int:snippet_id>/merge", methods=["POST"])
|
||||
@login_required
|
||||
async def merge_snippet_route(snippet_id: int):
|
||||
|
||||
Reference in New Issue
Block a user