Lands the broader save-to-library rethink across the server + extension
surfaces. Closes Bugs 1-5 from the 2026-05-19 live QA report; Bug 6
(payload validation) was already plugged by the previous commit.
API contract (Phase B):
- PUT /wp/api/modules/{id} now accepts {payload, name, description,
tags, propagate_to_bundles} and persists all of them in one
transaction. Returns bundles_updated[] so the caller can surface
"this save touched N bundles" feedback.
- PUT /wp/api/modules/{id}/payload kept as a backwards-compat alias
that mirrors the same body shape (payload + meta + propagate). The
older route silently dropped meta; now it routes through the same
helpers as the canonical PUT.
- Bundle propagation: when a module write happens, every bundle whose
children[].id matches the updated module gets its frozen child
snapshot rewritten in lockstep (payload + meta) and its
payload_hash recomputed. Atomic via shared db_session.
- GET /wp/api/bundles?contains_module=<id> filters the list to bundles
that reference that module — drives the modal's "this save affects
N bundles" preview.
- 7 new pytest cases in test_save_to_library_unified.py covering meta
persistence on both PUT routes, opt-in/opt-out propagation, and the
bundles filter.
SPA (Phase C):
- New PushToLibraryModal.vue. Owns editable name/description/tags +
payload preview + bundles-affected list + propagate checkbox. Two
buttons: Save as new entry (always available — POST /modules) and
Update existing (greyed when payload_hash is empty). Replaces the
five per-kind onXxxSaveToLibraryClick handlers + their five copies
of the same PUT body that lived in ModuleEditModal.
- ContextWidget right-click menu entry becomes Push to library and
routes to the same modal. Predicate relaxed from
isMissingFromLibrary(m) to !!m.payload, so inline-created modules
(no payload_hash) can finally reach the library — they get the
fork-only path in the modal.
- The five instance modals canSaveToLibrary computeds drop the
isLibraryTracked + isModified gate. Button is visible whenever
there is a payload; the modal decides what to do with it. Tests
updated to match the new contract.
- 8 new vitest cases in PushToLibraryModal.test.ts covering field
seeding, button disabling, PUT/POST request shape, bundle list
rendering, and the propagate opt-out round-trip.
- Legacy saveToLibrary(m) function in ContextWidget is gone (no
callers since the menu predicate change). Its bypass POST to
import-from-workflow lost the meta-editing path the new modal
exposes, so deletion is net positive.
Size note: total extension bundle is now 306254 / 307200 bytes
gzipped — 946 bytes of headroom on the size gate. Future feature
work touching this surface needs to factor in budget.
Live QA pending: extension code needs ComfyUI restart to reload.
434 lines
17 KiB
Python
434 lines
17 KiB
Python
"""/wp/api/modules CRUD + snapshot + match + duplicate + favorite endpoints."""
|
|
from __future__ import annotations
|
|
|
|
from aiohttp import web
|
|
|
|
from engine.db.repositories import BundleRepository, ModuleNotFound, ModuleRepository
|
|
from engine.modules.dispatcher import get_handler
|
|
from engine.modules.snapshot import freeze_snapshot, payload_hash
|
|
from wp_api._helpers import db_session, json_error, json_ok
|
|
|
|
|
|
def _validate_payload_for_type(type_id: str, payload: dict) -> str | None:
|
|
"""Run the registered ``validate_payload`` for this module type.
|
|
|
|
Returns the error string when the payload is malformed, or ``None``
|
|
when it passes. Unknown types skip validation (the create/PUT path
|
|
rejects unknown types separately via the repository layer).
|
|
"""
|
|
handler = get_handler(type_id)
|
|
if handler is None:
|
|
return None
|
|
try:
|
|
handler.validate_payload(payload)
|
|
except ValueError as exc:
|
|
return str(exc)
|
|
return None
|
|
|
|
_UPDATABLE_FIELDS = (
|
|
"name", "description", "tags", "payload", "is_favorite", "category_id",
|
|
)
|
|
|
|
|
|
def _propagate_module_to_bundles(conn, module_row: dict) -> list[str]:
|
|
"""Rewrite every bundle's child snapshot whose id matches the updated
|
|
module so the bundle's frozen ``children[]`` reflects the new payload
|
|
+ meta. Returns the list of bundle ids whose children were rewritten.
|
|
|
|
Bundles store children as a JSON blob; SQLite has no efficient
|
|
contains-key query for nested arrays. Library volumes are dozens of
|
|
bundles, not thousands — Python-side filter is fine.
|
|
"""
|
|
mid = module_row["id"]
|
|
bundle_repo = BundleRepository(conn)
|
|
affected: list[str] = []
|
|
for bundle in bundle_repo.list():
|
|
children = bundle.get("children") or []
|
|
if not isinstance(children, list):
|
|
continue
|
|
rewritten = False
|
|
new_children: list[dict] = []
|
|
for child in children:
|
|
if isinstance(child, dict) and child.get("id") == mid:
|
|
# Overwrite payload + meta but preserve any per-bundle
|
|
# fields the snapshot carries (enabled/collapsed flags,
|
|
# bundle-scope instance overrides, etc).
|
|
next_child = dict(child)
|
|
next_child["payload"] = module_row["payload"]
|
|
next_child["payload_hash"] = module_row["payload_hash"]
|
|
meta = next_child.get("meta")
|
|
if not isinstance(meta, dict):
|
|
meta = {}
|
|
# Library name/description/tags become the authoritative
|
|
# source for the child snapshot too — keeps drift detection
|
|
# honest when the user later opens this bundle elsewhere.
|
|
meta["name"] = module_row["name"]
|
|
meta["library_name"] = module_row["name"]
|
|
if module_row.get("description") is not None:
|
|
meta["description"] = module_row["description"]
|
|
if module_row.get("tags") is not None:
|
|
meta["tags"] = list(module_row["tags"])
|
|
next_child["meta"] = meta
|
|
new_children.append(next_child)
|
|
rewritten = True
|
|
else:
|
|
new_children.append(child)
|
|
if rewritten:
|
|
bundle_repo.update(bundle["id"], children=new_children)
|
|
affected.append(bundle["id"])
|
|
return affected
|
|
|
|
|
|
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)
|
|
|
|
err = _validate_payload_for_type(body["type"], body["payload"])
|
|
if err is not None:
|
|
return json_error(err, 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)
|
|
|
|
err = _validate_payload_for_type(body["type"], body["payload"])
|
|
if err is not None:
|
|
return json_error(err, 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:
|
|
"""PUT /wp/api/modules/{id} — partial update.
|
|
|
|
Accepts any subset of {name, description, tags, payload, is_favorite,
|
|
category_id} plus an optional ``propagate_to_bundles`` boolean (default
|
|
true) that controls whether saved bundles containing this module get
|
|
their child snapshots refreshed in lockstep.
|
|
|
|
When ``payload`` is present the new value is validated via the
|
|
registered module handler before write — same guard as POST and
|
|
import-from-workflow.
|
|
|
|
Response shape on success:
|
|
{<module fields>, "bundles_updated": ["bundle_id_1", ...]}
|
|
"""
|
|
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}
|
|
propagate = bool(body.get("propagate_to_bundles", True))
|
|
|
|
# If the caller is rewriting the payload, validate it against the
|
|
# row's existing module type — same guard as POST/import-from-workflow.
|
|
if "payload" in kwargs:
|
|
with db_session(request) as conn:
|
|
try:
|
|
existing = ModuleRepository(conn).get(mid)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
err = _validate_payload_for_type(existing["type"], kwargs["payload"])
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
|
|
bundles_updated: list[str] = []
|
|
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)
|
|
# Bundle propagation runs in the same transaction so an update is
|
|
# atomic across modules + bundles tables. Only fires when payload
|
|
# or library-facing meta (name/description/tags) changed and the
|
|
# caller hasn't opted out.
|
|
propagatable_keys = {"payload", "name", "description", "tags"}
|
|
if propagate and propagatable_keys & kwargs.keys():
|
|
bundles_updated = _propagate_module_to_bundles(conn, row)
|
|
|
|
out = dict(row)
|
|
out["bundles_updated"] = bundles_updated
|
|
return json_ok(out)
|
|
|
|
|
|
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). 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)
|