CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 34s
A snippet had no field for prose, so what a session learned about one went into when_to_use — the trigger joined onto every chunk it is embedded as. A sweep found write-ups of up to 3 KB there, headings and all. - notes: stored after the code under `## Notes`, parsed back from the body, carried by every path that rebuilds it (update, merge, un-merge). A snippet with no notes composes the body it always did. - create/update_snippet (MCP) take notes and return trigger_advice when when_to_use is long, headed or multi-paragraph. Advice, not a refusal. - Tool docs, the reusing-code skill and the editor hint describe the trigger as the situation and point the explanation at notes. - Editor gains a Notes field; the detail view renders it as markdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
363 lines
15 KiB
Python
363 lines
15 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 attach_usage, record_pulled
|
|
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",
|
|
"notes",
|
|
)
|
|
|
|
|
|
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.
|
|
await attach_usage(items)
|
|
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,
|
|
name.strip(),
|
|
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"),
|
|
notes=data.get("notes", ""),
|
|
),
|
|
project_id=project_id,
|
|
is_task=False,
|
|
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
|
|
# Matched on the artefact — location and code — before prose, the
|
|
# same way the MCP create path does (#2518). Both surfaces must
|
|
# apply the identical gate or the web UI becomes the way to record
|
|
# a duplicate the agent would have been stopped from writing.
|
|
code=data.get("code", ""),
|
|
locations=snippets_svc.resolve_locations(
|
|
data.get("repo", ""), data.get("path", ""), data.get("symbol", ""),
|
|
data.get("locations"),
|
|
),
|
|
data=snippets_svc.compose_data(
|
|
name=name, when_to_use=data.get("when_to_use", ""),
|
|
),
|
|
)
|
|
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,
|
|
notes=data.get("notes", ""),
|
|
)
|
|
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)
|
|
# Forge-checked freshness (#2690) — same decoration the MCP pull gets.
|
|
await snippets_svc.attach_live_body(note, data)
|
|
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))
|
|
await attach_usage([data])
|
|
# 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:
|
|
# The CALLER, not owner_uid — see routes/lessons.py for the why (#4249).
|
|
await systems_svc.set_record_systems(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>/unmerge", methods=["POST"])
|
|
@login_required
|
|
async def unmerge_snippet_route(snippet_id: int):
|
|
"""Pull one source back out of a merged survivor. Body: {"source_id": int}.
|
|
|
|
Also the repair for a half-undone merge: restoring a source from the trash by
|
|
hand leaves the survivor still claiming its call sites, and running this on an
|
|
already-restored source strips them."""
|
|
uid = get_current_user_id()
|
|
data = await request.get_json() or {}
|
|
source_id = data.get("source_id")
|
|
if not isinstance(source_id, int):
|
|
return jsonify({"error": "source_id (int) is required"}), 400
|
|
try:
|
|
result = await snippets_svc.unmerge_snippet(uid, snippet_id, source_id)
|
|
except PermissionError as exc:
|
|
return jsonify({"error": str(exc)}), 403
|
|
except snippets_svc.UnmergeError as exc:
|
|
# 409, not 400: the request is well-formed, the record's state is what
|
|
# makes it impossible — and the message says which.
|
|
return jsonify({"error": str(exc)}), 409
|
|
if result is None:
|
|
return not_found("Snippet")
|
|
survivor, restored = result
|
|
return jsonify({
|
|
"survivor": snippets_svc.snippet_to_dict(survivor),
|
|
"restored": snippets_svc.snippet_to_dict(restored) if restored else None,
|
|
})
|
|
|
|
|
|
@snippets_bp.route("/duplicates", methods=["GET"])
|
|
@login_required
|
|
async def duplicate_snippets_route():
|
|
"""Near-duplicate snippets already recorded, grouped into merge candidates.
|
|
|
|
Registered ABOVE the `/<int:snippet_id>` routes on purpose — Quart matches
|
|
an int converter before a static segment either way, but keeping the literal
|
|
path first makes the precedence obvious to the next person reading this."""
|
|
uid = get_current_user_id()
|
|
try:
|
|
threshold = float(request.args.get("threshold", 0) or 0)
|
|
except (TypeError, ValueError):
|
|
threshold = 0.0
|
|
return jsonify(await dedup_svc.find_duplicate_snippets(
|
|
uid, threshold=threshold if threshold > 0 else None
|
|
))
|
|
|
|
|
|
@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
|