Two bugs surfaced by manual QA after picker unblock: 1. Every freshly-picked non-wildcard module rendered a red "missing" dot on the card. Root cause: GET /wp/api/modules/hashes hard-coded `type="wildcard"` (5.5.5 spec §4.2 reasoned "catalog only contains wildcards"), so when the in-graph WP_Context now embeds a combine / derivation / constraint / fixed_values, the resulting payload_hash never appeared in the libraryHashes map. The frontend predicate `isMissingFromLibrary` then flagged every non-wildcard as missing. Fix: drop the `type=` filter so the endpoint returns hashes for every kind. Cost: ~50% larger payload for typical libraries; still sub-10 KB for the largest libraries we've seen. 2. Picker enabled pipeline kind, contradicting the user-stated scope that pipelines stay deferred until the modal grows a pipeline preview. Fix: introduce a `HIDDEN_KINDS` set + `visibleModules` computed that filters pipeline rows out of the picker entirely. Pipeline tab dropped from the kind-tab strip. The defensive guard in `isPickable` reads from the same set so any future programmatic pick path inherits the gate. Tests: - Updated picker test to reflect 5 visible rows (pipeline filtered) + asserts the "Pipelines" tab and the literal pipeline row text are absent from the rendered picker. - Renamed + updated the backend hashes test to lock the new every-kind contract; combine row now expected in the response map. 485 vitest + pytest green.
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""/wp/api/modules CRUD + snapshot + match + duplicate + favorite endpoints."""
|
|
from __future__ import annotations
|
|
|
|
from aiohttp import web
|
|
|
|
from engine.db.repositories import ModuleNotFound, ModuleRepository
|
|
from engine.modules.snapshot import freeze_snapshot, payload_hash
|
|
from wp_api._helpers import db_session, json_error, json_ok
|
|
|
|
_UPDATABLE_FIELDS = (
|
|
"name", "description", "tags", "payload", "is_favorite", "category_id",
|
|
)
|
|
|
|
|
|
async def list_modules(request: web.Request) -> web.Response:
|
|
type_ = request.query.get("type")
|
|
category_id = request.query.get("category")
|
|
query = request.query.get("q")
|
|
favorites = request.query.get("favorites") == "1"
|
|
try:
|
|
limit = int(request.query["limit"]) if "limit" in request.query else None
|
|
offset = int(request.query.get("offset", 0))
|
|
except ValueError:
|
|
return json_error("limit/offset must be integers", status=400)
|
|
if limit is not None and limit < 0:
|
|
return json_error("limit must be non-negative", status=400)
|
|
if offset < 0:
|
|
return json_error("offset must be non-negative", status=400)
|
|
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
items = repo.list(
|
|
type=type_, category_id=category_id, query=query,
|
|
favorites_only=favorites, limit=limit, offset=offset,
|
|
)
|
|
# `total` is the unpaginated count so the SPA Dashboard can show
|
|
# "Wildcards: 15" even when limit=1 was passed for cheap polling.
|
|
total = repo.count(
|
|
type=type_, category_id=category_id, query=query,
|
|
favorites_only=favorites,
|
|
)
|
|
return json_ok({"items": items, "total": total})
|
|
|
|
|
|
async def create_module(request: web.Request) -> web.Response:
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be a JSON object", status=400)
|
|
required = {"type", "name", "payload"}
|
|
missing = required - body.keys()
|
|
if missing:
|
|
return json_error(f"missing fields: {sorted(missing)}", status=400)
|
|
|
|
try:
|
|
with db_session(request) as conn:
|
|
row = ModuleRepository(conn).create(
|
|
type=body["type"], name=body["name"],
|
|
description=body.get("description", ""),
|
|
category_id=body.get("category_id"),
|
|
tags=body.get("tags", []),
|
|
payload=body["payload"],
|
|
is_favorite=bool(body.get("is_favorite", False)),
|
|
)
|
|
except ValueError as e:
|
|
return json_error(str(e), status=400)
|
|
return json_ok(row, status=201)
|
|
|
|
|
|
async def import_from_workflow(request: web.Request) -> web.Response:
|
|
"""Insert a workflow-resident module snapshot into the library at
|
|
its existing 8-hex uuid. Used by the ContextWidget's
|
|
"Save to library" action when a loaded workflow contains a module
|
|
not present in this user's library.
|
|
|
|
Differs from `create_module` in that the caller supplies the id;
|
|
we preserve it so existing `@{uuid}` refs in other workflows /
|
|
modules don't break. Returns 409 if the id is already taken
|
|
(the indicator dot would already be cleared, so this should
|
|
only fire on race).
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be a JSON object", status=400)
|
|
required = {"id", "type", "name", "payload"}
|
|
missing = required - body.keys()
|
|
if missing:
|
|
return json_error(f"missing fields: {sorted(missing)}", status=400)
|
|
|
|
try:
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
try:
|
|
repo.get(body["id"])
|
|
return json_error(f"module {body['id']} already exists", status=409)
|
|
except ModuleNotFound:
|
|
pass # expected — that's the whole point of the endpoint
|
|
row = repo.create(
|
|
id=body["id"],
|
|
type=body["type"], name=body["name"],
|
|
description=body.get("description", ""),
|
|
category_id=body.get("category_id"),
|
|
tags=body.get("tags", []),
|
|
payload=body["payload"],
|
|
is_favorite=bool(body.get("is_favorite", False)),
|
|
)
|
|
except ValueError as e:
|
|
return json_error(str(e), status=400)
|
|
return json_ok(row, status=201)
|
|
|
|
|
|
async def get_module(request: web.Request) -> web.Response:
|
|
mid = request.match_info["id"]
|
|
with db_session(request) as conn:
|
|
try:
|
|
row = ModuleRepository(conn).get(mid)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
return json_ok(row)
|
|
|
|
|
|
async def update_module(request: web.Request) -> web.Response:
|
|
mid = request.match_info["id"]
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be a JSON object", status=400)
|
|
kwargs: dict = {k: body[k] for k in _UPDATABLE_FIELDS if k in body}
|
|
with db_session(request) as conn:
|
|
try:
|
|
row = ModuleRepository(conn).update(mid, **kwargs)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
return json_ok(row)
|
|
|
|
|
|
async def delete_module(request: web.Request) -> web.Response:
|
|
mid = request.match_info["id"]
|
|
with db_session(request) as conn:
|
|
try:
|
|
ModuleRepository(conn).delete(mid)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
return web.Response(status=204)
|
|
|
|
|
|
async def snapshot_module(request: web.Request) -> web.Response:
|
|
mid = request.match_info["id"]
|
|
with db_session(request) as conn:
|
|
try:
|
|
row = ModuleRepository(conn).get(mid)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
return json_ok(freeze_snapshot(row))
|
|
|
|
|
|
async def match_module(request: web.Request) -> web.Response:
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be a JSON object", status=400)
|
|
required = {"type", "name", "payload_hash"}
|
|
missing = required - body.keys()
|
|
if missing:
|
|
return json_error(f"missing fields: {sorted(missing)}", status=400)
|
|
|
|
with db_session(request) as conn:
|
|
rows = ModuleRepository(conn).list(type=body["type"], query=body["name"])
|
|
for row in rows:
|
|
if row["name"] != body["name"]:
|
|
continue
|
|
if payload_hash(row["payload"]) == body["payload_hash"]:
|
|
return json_ok({
|
|
"matched": True, "id": row["id"], "version": row["version"],
|
|
})
|
|
return json_ok({"matched": False})
|
|
|
|
|
|
async def duplicate_module(request: web.Request) -> web.Response:
|
|
mid = request.match_info["id"]
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
try:
|
|
src = repo.get(mid)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
copy = repo.create(
|
|
type=src["type"], name=f"{src['name']} (copy)",
|
|
description=src["description"], category_id=src["category_id"],
|
|
tags=list(src["tags"]), payload=src["payload"],
|
|
is_favorite=False,
|
|
)
|
|
return json_ok(copy, status=201)
|
|
|
|
|
|
async def toggle_favorite(request: web.Request) -> web.Response:
|
|
mid = request.match_info["id"]
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
try:
|
|
row = repo.get(mid)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
updated = repo.update(mid, is_favorite=not row["is_favorite"])
|
|
return json_ok(updated)
|
|
|
|
|
|
async def list_hashes(request: web.Request) -> web.Response:
|
|
"""Lightweight bulk hash fetch for drift detection.
|
|
|
|
Returns hashes for every module kind in the library. Used by the
|
|
SPA Manager AND by the in-graph WP_Context widget to drive the
|
|
drift / missing dots — both surfaces need the full set since
|
|
post-5.5.6 a WP_Context can embed any kind (wildcard, fixed_values,
|
|
combine, derivation, constraint, pipeline). The pre-5.5.6 endpoint
|
|
filtered to wildcards-only because that was the only embeddable
|
|
kind; lifting the filter fixes the false-positive missing dot on
|
|
every non-wildcard card.
|
|
|
|
Lightweight (no payload, no metadata) so the SPA can poll on every
|
|
workflow load without measurable cost."""
|
|
with db_session(request) as conn:
|
|
rows = ModuleRepository(conn).list()
|
|
# Post-migration-004 the row's `id` IS the 8-hex uuid that the
|
|
# tokenizer's `@{8hex}` ref captures; the published `hashes` map
|
|
# keys remain `uuid`-named on the wire so existing SPA consumers
|
|
# don't need to relabel.
|
|
return json_ok({
|
|
"hashes": {row["id"]: row["payload_hash"] for row in rows},
|
|
})
|
|
|
|
|
|
async def embed_bundle(request: web.Request) -> web.Response:
|
|
"""Per-pick snapshot fetch for the SPA library picker.
|
|
|
|
Body: {"uuids": [str, ...]}. Server returns one SnapshotEntry per
|
|
picked uuid — NO transitive `@{}` walk. Nested wildcard refs
|
|
inside an option value resolve at run time against the live
|
|
library on the executing machine. The workflow JSON only
|
|
embeds what the user explicitly picked; the referenced wildcards
|
|
only enter the catalog if (and when) the picked option happens
|
|
to surface them at run time.
|
|
|
|
Trade-off: workflow JSON is no longer self-contained. Reproducing
|
|
a graph on a different machine requires the same wildcard
|
|
library (or at least the wildcards the picked options happen to
|
|
resolve into). The user explicitly asked for this trim — it
|
|
matches their mental model of "I picked outfit, I expect outfit
|
|
to be embedded; color is a library detail".
|
|
|
|
Response shape kept stable so the SPA client doesn't need to
|
|
branch:
|
|
- modules: list of picked payloads in input order (drops misses)
|
|
- snapshots: dict[uuid, SnapshotEntry] of the picks (no deps)
|
|
- pickOrder: input uuid list
|
|
- walkOverflow: always [] (kept for shape compatibility)
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be a JSON object", status=400)
|
|
|
|
uuids = body.get("uuids")
|
|
if not isinstance(uuids, list) or not all(isinstance(u, str) for u in uuids):
|
|
return json_error("uuids must be a list of strings", status=400)
|
|
|
|
snapshots: dict[str, dict] = {}
|
|
picks: list[dict] = []
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
for uuid in uuids:
|
|
try:
|
|
row = repo.get(uuid)
|
|
except Exception:
|
|
continue
|
|
entry = {
|
|
"snapshot_version": 1,
|
|
"uuid": row["id"],
|
|
"type": row["type"],
|
|
"name": row["name"],
|
|
"payload": row["payload"],
|
|
"payload_hash": row["payload_hash"],
|
|
"source": {"kind": "user"},
|
|
}
|
|
snapshots[row["id"]] = entry
|
|
picks.append(row["payload"])
|
|
|
|
return json_ok({
|
|
"modules": picks,
|
|
"snapshots": snapshots,
|
|
"pickOrder": uuids,
|
|
"walkOverflow": [],
|
|
})
|
|
|
|
|
|
def register(router) -> None:
|
|
router.add_get("/wp/api/modules", list_modules)
|
|
router.add_post("/wp/api/modules", create_module)
|
|
router.add_post("/wp/api/modules/import-from-workflow", import_from_workflow)
|
|
router.add_get("/wp/api/modules/hashes", list_hashes)
|
|
router.add_post("/wp/api/modules/match", match_module)
|
|
router.add_post("/wp/api/modules/embed-bundle", embed_bundle)
|
|
router.add_get("/wp/api/modules/{id}", get_module)
|
|
router.add_put("/wp/api/modules/{id}", update_module)
|
|
router.add_delete("/wp/api/modules/{id}", delete_module)
|
|
router.add_post("/wp/api/modules/{id}/snapshot", snapshot_module)
|
|
router.add_post("/wp/api/modules/{id}/duplicate", duplicate_module)
|
|
router.add_post("/wp/api/modules/{id}/favorite", toggle_favorite)
|