fix(snippets): close the recall-surface gaps found reviewing the Drafter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231) milestones. The theme: a snippet could be recorded but not fully corrected, and the agent and web surfaces had drifted apart. - #2076 language was mis-derived from the first caller tag, so a snippet created with tags and no language read that tag back as its language — corrupting the tag set and the code fence on the next update. Only the FIRST tag can carry the language, since compose_tags emits [language, "snippet", *caller]. - #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be cleared and no snippet detached from its project. Now an omitted field is left alone, an empty string clears, and project_id follows the -1 = detach convention. A service-level UNSET sentinel keeps None available as the clear. - #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet could not be retired by the agent that recorded it), locations on MCP create and update, system_ids through the REST routes and the editor, and the near-duplicate gate on REST create with a "record it anyway" escape. - #2079 project scoping: list_snippets takes project_id through the service, the MCP tool and the REST route, defaulting to every project — reaching across projects is the point when the helper you need was written elsewhere. Sharing the list across owners is deliberately NOT in here: query_knowledge is shared with the Knowledge browse surface, so widening it changes behaviour well beyond snippets. Left open on #2079 for a scope decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
@@ -16,7 +16,9 @@ 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.access import can_write_note
|
||||
from scribe.services.notes import get_note_for_user
|
||||
|
||||
@@ -46,9 +48,13 @@ 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
|
||||
limit, offset = parse_pagination()
|
||||
items, total = await snippets_svc.list_snippets(
|
||||
uid, q=q, tag=tag, limit=limit, offset=offset,
|
||||
uid, q=q, tag=tag, limit=limit, offset=offset, project_id=project_id,
|
||||
)
|
||||
return jsonify({"snippets": items, "total": total})
|
||||
|
||||
@@ -63,6 +69,31 @@ async def create_snippet_route():
|
||||
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,
|
||||
@@ -77,7 +108,13 @@ async def create_snippet_route():
|
||||
tags=data.get("tags"),
|
||||
project_id=project_id,
|
||||
)
|
||||
return jsonify(snippets_svc.snippet_to_dict(note)), 201
|
||||
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"])
|
||||
@@ -90,6 +127,13 @@ async def get_snippet_route(snippet_id: int):
|
||||
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)
|
||||
]
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@@ -115,12 +159,20 @@ async def update_snippet_route(snippet_id: int):
|
||||
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")
|
||||
return jsonify(snippets_svc.snippet_to_dict(updated))
|
||||
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>/merge", methods=["POST"])
|
||||
@@ -173,8 +225,6 @@ async def delete_snippet_route(snippet_id: int):
|
||||
note, _ = loaded
|
||||
if not await can_write_note(uid, snippet_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
batch = await trash_delete(note.user_id, "note", snippet_id)
|
||||
if batch is None:
|
||||
if not await snippets_svc.delete_snippet(note.user_id, snippet_id):
|
||||
return not_found("Snippet")
|
||||
return "", 204
|
||||
|
||||
Reference in New Issue
Block a user