feat(design-systems): REST + MCP surfaces, at parity
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 26s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 26s
Milestone #254 step 4 (#2290). Eleven capabilities, both surfaces, one service. Design systems are owner-scoped top-level records rather than project-scoped ones, so these do not nest under /api/projects/ the way systems do — routes/rulebooks.py was the closer shape. The one exception is the project pointer, which is genuinely about a project: PUT /api/projects/<id>/design-system, PUT rather than PATCH because clearing it is a first-class outcome and not an omission. `/resolved` and `/tokens` are deliberately separate endpoints. One answers "what does this system CHANGE", the other "what does it end up BEING", and a system that overrides nothing has an empty token list and a full resolved set. Shipping only one would have made the other a client-side computation of exactly the kind the record model exists to remove. ResolvedToken.to_dict carries the SHADOWED contributions, not just the winner. Dropping them at the serialisation boundary would have discarded the one thing step 2 was built to preserve, and it would have been invisible — the payload still looks complete. Three sentinel translations on the MCP side, each tested, because an agent cannot omit an argument and a wrong mapping here is silent: - parent_id: 0 = unchanged, -1 = clear (become a family system), positive = set. Renaming a system must not silently re-root it. - order_index: -1 = unchanged, since 0 is a valid position. - value_by_mode: guarded on `is not None`, not truthiness, so `{}` can strip every mode from a token instead of being unreachable. DesignSystemCycle maps to 400 on REST and to a ValueError carrying the message on MCP — kept apart from 404 throughout. An agent told "not found" retries the same call; one told what the loop is can fix it. Two structural guards beyond the parity list: every endpoint must be reachable on the app (catching a decorator copied without its path, where the second handler silently never runs), and every public coroutine in the tools module must be registered (a tool written but never registered is invisible to an agent, and nothing else would notice).
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""Design system + token REST endpoints (milestone #254 step 4).
|
||||
|
||||
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
|
||||
Design systems are owner-scoped top-level records rather than project-scoped
|
||||
ones, so these do NOT nest under `/api/projects/` — `routes/rulebooks.py` is the
|
||||
closer shape.
|
||||
|
||||
Two failures this layer has to keep apart, which is why the service raises for
|
||||
one and returns None for the other:
|
||||
|
||||
- `DesignSystemCycle` -> 400 with its message. "That parent already inherits
|
||||
from this system" is a correctable mistake and the caller needs to be told
|
||||
which one they made.
|
||||
- None -> 404, covering both "no such system" and "not yours". Conflating
|
||||
those two IS the intent: distinguishing them would confirm the existence of
|
||||
records the caller may not see.
|
||||
"""
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import login_required
|
||||
from scribe.services import design_systems as ds_svc
|
||||
from scribe.services.design_systems import DesignSystemCycle
|
||||
|
||||
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _uid() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
def _not_found(what: str = "design system"):
|
||||
return jsonify({"error": f"{what} not found"}), 404
|
||||
|
||||
|
||||
# ── Design systems ──────────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.get("/design-systems")
|
||||
@login_required
|
||||
async def list_design_systems():
|
||||
"""The caller's design systems. An empty list is the ordinary state for an
|
||||
install that has never made one, not an error."""
|
||||
rows = await ds_svc.list_design_systems(_uid())
|
||||
return jsonify({"design_systems": [s.to_dict() for s in rows]})
|
||||
|
||||
|
||||
@design_systems_bp.post("/design-systems")
|
||||
@login_required
|
||||
async def create_design_system():
|
||||
data = await request.get_json() or {}
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
system = await ds_svc.create_design_system(
|
||||
user_id=_uid(),
|
||||
title=title,
|
||||
description=data.get("description") or None,
|
||||
parent_id=data.get("parent_id"),
|
||||
)
|
||||
if system is None:
|
||||
return jsonify({"error": "parent design system not found"}), 404
|
||||
return jsonify(system.to_dict()), 201
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def get_design_system(design_system_id: int):
|
||||
system = await ds_svc.get_design_system(_uid(), design_system_id)
|
||||
if system is None:
|
||||
return _not_found()
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.patch("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def update_design_system(design_system_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {k: v for k, v in data.items() if k in ("title", "description")}
|
||||
# Presence, not truthiness: `{"parent_id": null}` means "make this a root",
|
||||
# which a `if data.get("parent_id")` filter would silently drop.
|
||||
if "parent_id" in data:
|
||||
fields["parent_id"] = data["parent_id"]
|
||||
try:
|
||||
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
|
||||
except DesignSystemCycle as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if system is None:
|
||||
return _not_found()
|
||||
return jsonify(system.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
|
||||
@login_required
|
||||
async def delete_design_system(design_system_id: int):
|
||||
if not await ds_svc.delete_design_system(_uid(), design_system_id):
|
||||
return _not_found()
|
||||
return "", 204
|
||||
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/resolved")
|
||||
@login_required
|
||||
async def resolve_design_system(design_system_id: int):
|
||||
"""The EFFECTIVE token set — everything inherited, with this system's on top.
|
||||
|
||||
Distinct from `/tokens` on purpose: that returns what this system CHANGES,
|
||||
this returns what it ends up being. Both are real questions and answering
|
||||
only one would make the other a client-side computation.
|
||||
"""
|
||||
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
|
||||
if resolved is None:
|
||||
return _not_found()
|
||||
return jsonify({
|
||||
"design_system_id": design_system_id,
|
||||
"tokens": [t.to_dict() for t in resolved],
|
||||
})
|
||||
|
||||
|
||||
# ── Tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.get("/design-systems/<int:design_system_id>/tokens")
|
||||
@login_required
|
||||
async def list_design_tokens(design_system_id: int):
|
||||
"""This system's OWN tokens — its override set, not its effective set."""
|
||||
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
|
||||
return _not_found()
|
||||
rows = await ds_svc.list_tokens(_uid(), design_system_id)
|
||||
return jsonify({"tokens": [t.to_dict() for t in rows]})
|
||||
|
||||
|
||||
@design_systems_bp.post("/design-systems/<int:design_system_id>/tokens")
|
||||
@login_required
|
||||
async def create_design_token(design_system_id: int):
|
||||
data = await request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
token = await ds_svc.create_token(
|
||||
user_id=_uid(),
|
||||
design_system_id=design_system_id,
|
||||
name=name,
|
||||
value_by_mode=data.get("value_by_mode"),
|
||||
group_name=data.get("group_name") or None,
|
||||
purpose=data.get("purpose") or None,
|
||||
order_index=data.get("order_index") or 0,
|
||||
)
|
||||
if token is None:
|
||||
return _not_found()
|
||||
return jsonify(token.to_dict()), 201
|
||||
|
||||
|
||||
@design_systems_bp.patch("/design-tokens/<int:token_id>")
|
||||
@login_required
|
||||
async def update_design_token(token_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in ("name", "value_by_mode", "group_name", "purpose", "order_index")
|
||||
}
|
||||
token = await ds_svc.update_token(_uid(), token_id, **fields)
|
||||
if token is None:
|
||||
return _not_found("design token")
|
||||
return jsonify(token.to_dict())
|
||||
|
||||
|
||||
@design_systems_bp.delete("/design-tokens/<int:token_id>")
|
||||
@login_required
|
||||
async def delete_design_token(token_id: int):
|
||||
if not await ds_svc.delete_token(_uid(), token_id):
|
||||
return _not_found("design token")
|
||||
return "", 204
|
||||
|
||||
|
||||
# ── The project pointer ─────────────────────────────────────────────────
|
||||
|
||||
@design_systems_bp.put("/projects/<int:project_id>/design-system")
|
||||
@login_required
|
||||
async def set_project_design_system(project_id: int):
|
||||
"""Point a project at a design system. `{"design_system_id": null}` clears it.
|
||||
|
||||
PUT rather than PATCH: this sets one field to exactly what is sent, and
|
||||
clearing it is a first-class outcome rather than an omission.
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
ok = await ds_svc.set_project_design_system(
|
||||
_uid(), project_id, data.get("design_system_id")
|
||||
)
|
||||
if not ok:
|
||||
return _not_found("project or design system")
|
||||
return jsonify({"project_id": project_id,
|
||||
"design_system_id": data.get("design_system_id")})
|
||||
Reference in New Issue
Block a user