Files
DumiFlex-ComfyUI-Wildcard-P…/wp_api/modules.py
T
DumiFlex 2c8d94bb9e feat(api,spa): phase b+c — unified push-to-library with bundle propagation
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.
2026-05-19 18:12:16 +03:00

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)