Files
FabledScribe/src/scribe/routes/snippets.py
T
bvandeusen 35f3f09d12
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
feat(snippets): drift check — verify a snippet still matches its source
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
2026-07-28 18:20:33 -04:00

299 lines
12 KiB
Python

"""REST routes for snippets — reusable functions/components recorded for recall.
A snippet is a note with note_type='snippet' (see services/snippets.py). These
routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the
agent-facing surface. Both go through services/snippets.py, so the
serialize/parse contract and embedding-on-create live in one place (DRY).
ACL (rule #78): reads/writes of a single snippet resolve through the share-aware
`get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so
a shared editor isn't rejected by the owner-scoped service — mirroring
routes/notes.py. The list is owner-scoped, matching the note-browse surface.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found, parse_pagination
from scribe.services import dedup as dedup_svc
from scribe.services import snippets as snippets_svc
from scribe.services import systems as systems_svc
from scribe.services.note_usage import empty_usage, record_pulled, usage_for_notes
from scribe.services.access import (
can_write_note,
describe_provenance,
label_shared_items,
)
from scribe.services.notes import get_note_for_user
logger = logging.getLogger(__name__)
snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets")
# Fields the create/update payload may carry, mapped straight to the service.
_STR_FIELDS = ("name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol")
async def _load_snippet(uid: int, snippet_id: int):
"""Share-aware resolve of a snippet by id → (note, permission) or None if it
isn't accessible or isn't a snippet."""
result = await get_note_for_user(uid, snippet_id)
if result is None:
return None
note, permission = result
if note.note_type != snippets_svc.SNIPPET_NOTE_TYPE:
return None
return note, permission
@snippets_bp.route("", methods=["GET"])
@login_required
async def list_snippets_route():
uid = get_current_user_id()
q = request.args.get("q") or None
tag = request.args.get("tag", "")
try:
project_id = int(request.args.get("project_id", 0) or 0) or None
except (TypeError, ValueError):
project_id = None
# Reverse lookup — "what already lives here?" Empty args are ignored, so the
# plain list is unchanged.
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, 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.
items = await label_shared_items(uid, items)
# One aggregate for the whole page — a per-row lookup here would be N+1 by
# construction. Every row gets the key, zero-filled, so the UI renders
# "never pulled" rather than having to treat a missing field as a state.
usage = await usage_for_notes([int(it["id"]) for it in items])
for it in items:
it["usage"] = usage.get(int(it["id"]), empty_usage())
return jsonify({"snippets": items, "total": total})
@snippets_bp.route("", methods=["POST"])
@login_required
async def create_snippet_route():
uid = get_current_user_id()
data = await request.get_json() or {}
name = (data.get("name") or "").strip()
code = (data.get("code") or "").strip()
if not name or not code:
return jsonify({"error": "name and code are required"}), 400
project_id = data.get("project_id") or None
# Same near-duplicate gate the MCP create path applies: recording the same
# reusable thing twice is what merge then has to undo, so catch it here too.
# `force` is the deliberate override once the operator has seen the warning.
if not data.get("force"):
dup = await dedup_svc.find_duplicate_note(
uid,
snippets_svc.compose_title(name, data.get("when_to_use", "")),
snippets_svc.compose_body(
code=data.get("code", ""),
language=data.get("language", ""),
signature=data.get("signature", ""),
when_to_use=data.get("when_to_use", ""),
repo=data.get("repo", ""),
path=data.get("path", ""),
symbol=data.get("symbol", ""),
locations=data.get("locations"),
),
project_id=project_id,
is_task=False,
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
)
if dup is not None:
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
note = await snippets_svc.create_snippet(
uid,
name=name,
code=data.get("code", ""),
language=data.get("language", ""),
signature=data.get("signature", ""),
when_to_use=data.get("when_to_use", ""),
repo=data.get("repo", ""),
path=data.get("path", ""),
symbol=data.get("symbol", ""),
locations=data.get("locations"),
tags=data.get("tags"),
project_id=project_id,
)
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
out = snippets_svc.snippet_to_dict(note)
out["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
return jsonify(out), 201
@snippets_bp.route("/<int:snippet_id>", methods=["GET"])
@login_required
async def get_snippet_route(snippet_id: int):
uid = get_current_user_id()
loaded = await _load_snippet(uid, snippet_id)
if loaded is None:
return not_found("Snippet")
note, permission = loaded
data = snippets_svc.snippet_to_dict(note)
data["permission"] = permission
# Read the association as the OWNER: a shared reader isn't scoped to the
# owner's project, so their own id would come back empty (mirrors the
# write-as-owner pattern this module already uses).
data["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
]
data.update(await describe_provenance(uid, note))
data["usage"] = (await usage_for_notes([snippet_id])).get(
snippet_id, empty_usage()
)
# Opening the detail view IS a pull — the operator chose to look. Tagged
# apart from the MCP sources so "the agent reused it" and "a human read it"
# stay distinguishable; they mean different things for pruning (#2085).
record_pulled(user_id=uid, note_id=snippet_id, source="rest_snippet")
return jsonify(data)
@snippets_bp.route("/<int:snippet_id>", methods=["PATCH"])
@login_required
async def update_snippet_route(snippet_id: int):
uid = get_current_user_id()
loaded = await _load_snippet(uid, snippet_id)
if loaded is None:
return not_found("Snippet")
note, _ = loaded
if not await can_write_note(uid, snippet_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note.user_id
data = await request.get_json() or {}
# Partial update: only keys present in the payload change (the service
# treats None as "leave unchanged"). Empty strings ARE applied — the form
# sends the full field set, so a cleared field is an intentional clear.
kwargs = {k: data[k] for k in _STR_FIELDS if k in data}
if "locations" in data:
kwargs["locations"] = data["locations"]
if "tags" in data:
kwargs["tags"] = data["tags"]
if "project_id" in data:
# A present-but-empty project_id is a deliberate detach, not "unchanged"
# — the service distinguishes the two via its UNSET sentinel.
kwargs["project_id"] = data["project_id"] or None
updated = await snippets_svc.update_snippet(owner_uid, snippet_id, **kwargs)
if updated is None:
return not_found("Snippet")
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(owner_uid, snippet_id, data["system_ids"])
out = snippets_svc.snippet_to_dict(updated)
out["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(owner_uid, snippet_id)
]
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):
"""Unify source snippets into this one (the canonical target). Body:
{"source_ids": [int, ...]}. Requires write on the target and every source,
and all must share the target's owner (cross-owner merge is out of scope)."""
uid = get_current_user_id()
loaded = await _load_snippet(uid, snippet_id)
if loaded is None:
return not_found("Snippet")
target, _ = loaded
if not await can_write_note(uid, snippet_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json() or {}
raw = data.get("source_ids") or []
source_ids = [s for s in raw if isinstance(s, int) and s != snippet_id]
if not source_ids:
return jsonify({"error": "source_ids (non-empty list of other snippet ids) is required"}), 400
owner_uid = target.user_id
for sid in source_ids:
sloaded = await _load_snippet(uid, sid)
if sloaded is None:
return not_found(f"Snippet {sid}")
snote, _ = sloaded
if snote.user_id != owner_uid:
return jsonify({"error": "can only merge snippets with the same owner"}), 400
if not await can_write_note(uid, sid):
return jsonify({"error": f"Permission denied for snippet {sid}"}), 403
result = await snippets_svc.merge_snippets(owner_uid, snippet_id, source_ids)
if result is None:
return not_found("Snippet")
note, merged_ids = result
out = snippets_svc.snippet_to_dict(note)
out["merged_ids"] = merged_ids
return jsonify(out)
@snippets_bp.route("/<int:snippet_id>", methods=["DELETE"])
@login_required
async def delete_snippet_route(snippet_id: int):
uid = get_current_user_id()
loaded = await _load_snippet(uid, snippet_id)
if loaded is None:
return not_found("Snippet")
note, _ = loaded
if not await can_write_note(uid, snippet_id):
return jsonify({"error": "Permission denied"}), 403
if not await snippets_svc.delete_snippet(note.user_id, snippet_id):
return not_found("Snippet")
return "", 204