Phases 2 to 8 of the AI assistant overhaul plus fixes from real-model chats: one tool registry with native tool calls, server-kept chat logs with prompt caching, an FTS5 library index with opt-in meaning search, keys bound to their address and a guard on every AI route, page tools and opt-in web search, saved chats with streaming, Try again, a context meter and compaction, chat images, delete/category/tag permissions with Undo, constraint and test cards, card fixes and previews, questions with answers to click, the derivation NOT toggle (schema v10, community catalog 0021), request fixes for the newest Claude models, and OpenAI's Responses API. Review fixes: Try again clears the old turn's fixes and answers, category Undo removes only what it made, a failed delete rolls back in order, not_matches patterns are checked, busy chats refuse a second request, and chat search ignores stored JSON.
795 lines
33 KiB
Python
795 lines
33 KiB
Python
"""/wp/api/modules CRUD + snapshot + match + duplicate + favorite endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sqlite3
|
|
|
|
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,
|
|
json_ok_revalidated,
|
|
make_etag,
|
|
matches_if_none_match,
|
|
)
|
|
from wp_api._validators import validate_body_size, validate_meta, validate_wildcard_name
|
|
|
|
|
|
def _hydrate_constraint_exceptions(conn, payload: dict) -> None:
|
|
"""Fill missing ``source`` / ``target`` value strings on a constraint
|
|
payload's exceptions from the referenced wildcards' option ids.
|
|
|
|
Older saves stored exceptions with only ``source_id`` / ``target_id``
|
|
(no string field), which the engine's ``validate_payload`` rejects
|
|
because the runtime resolver still keys instance-override lookups
|
|
by ``(source_value, target_value)`` pairs. Hydrating server-side
|
|
means clients can keep sending id-only exceptions without every
|
|
save endpoint duplicating the wildcard-option lookup.
|
|
|
|
Mutates *payload* in place. Skips exceptions that already have a
|
|
non-empty ``source``/``source_value`` (and same for target). Skips
|
|
silently if the referenced wildcard is missing or the option id no
|
|
longer exists — the engine validator will surface a clearer error
|
|
than a half-applied hydration would.
|
|
"""
|
|
if not isinstance(payload, dict):
|
|
return
|
|
excs = payload.get("exceptions")
|
|
if not isinstance(excs, list) or not excs:
|
|
return
|
|
repo = ModuleRepository(conn)
|
|
|
|
def _opt_value(wid: object, oid: object) -> str | None:
|
|
if not isinstance(wid, str) or not isinstance(oid, str):
|
|
return None
|
|
try:
|
|
row = repo.get(wid)
|
|
except ModuleNotFound:
|
|
return None
|
|
if row.get("type") != "wildcard":
|
|
return None
|
|
for opt in (row.get("payload") or {}).get("options") or []:
|
|
if opt.get("id") == oid:
|
|
v = opt.get("value")
|
|
return v if isinstance(v, str) else None
|
|
return None
|
|
|
|
src_wid = payload.get("source_wildcard_id")
|
|
tgt_wid = payload.get("target_wildcard_id")
|
|
# Per-(wid, oid) cache so a constraint with N exceptions referencing
|
|
# the same option doesn't refetch the wildcard row N times.
|
|
cache: dict[tuple[str, str], str | None] = {}
|
|
|
|
def _lookup(wid: object, oid: object) -> str | None:
|
|
if not isinstance(wid, str) or not isinstance(oid, str):
|
|
return None
|
|
key = (wid, oid)
|
|
if key in cache:
|
|
return cache[key]
|
|
v = _opt_value(wid, oid)
|
|
cache[key] = v
|
|
return v
|
|
|
|
for exc in excs:
|
|
if not isinstance(exc, dict):
|
|
continue
|
|
# Skip exceptions that already carry a usable source/target string
|
|
# (empty IS valid — it's the null-option marker, see the engine
|
|
# validator note). Only fill when the field is genuinely absent
|
|
# (None for both legacy + tier-2 names).
|
|
legacy_src = exc.get("source")
|
|
tier_src = exc.get("source_value")
|
|
if not isinstance(legacy_src, str) and not isinstance(tier_src, str):
|
|
v = _lookup(src_wid, exc.get("source_id"))
|
|
if v is not None:
|
|
exc["source_value"] = v
|
|
legacy_tgt = exc.get("target")
|
|
tier_tgt = exc.get("target_value")
|
|
if not isinstance(legacy_tgt, str) and not isinstance(tier_tgt, str):
|
|
v = _lookup(tgt_wid, exc.get("target_id"))
|
|
if v is not None:
|
|
exc["target_value"] = v
|
|
|
|
|
|
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)
|
|
# Optional per-type rules that apply when AUTHORING only. `resolve`
|
|
# re-runs `validate_payload` on every graph execution, so a rule
|
|
# belongs here whenever rejecting an already-stored payload mid-run
|
|
# would be worse than letting it through. Handlers that define no
|
|
# such rules simply don't have the hook.
|
|
authoring = getattr(handler, "validate_authoring", None)
|
|
if callable(authoring):
|
|
authoring(payload)
|
|
except ValueError as exc:
|
|
return str(exc)
|
|
return None
|
|
|
|
_UPDATABLE_FIELDS = (
|
|
"name", "description", "tags", "payload", "is_favorite", "category_id",
|
|
"content_rating",
|
|
)
|
|
|
|
|
|
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})
|
|
|
|
|
|
_NEW_ID = re.compile(r"[0-9a-f]{8}")
|
|
|
|
|
|
async def create_module(request: web.Request) -> web.Response:
|
|
err = validate_body_size(request.content_length)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
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_meta(body)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
# Wildcard names become the `#name` segment of nested refs — block
|
|
# ref-grammar-reserved chars here so the engine never stores a name
|
|
# the regex can't parse back.
|
|
if body["type"] == "wildcard":
|
|
err = validate_wildcard_name(body["name"])
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
|
|
try:
|
|
with db_session(request) as conn:
|
|
if body["type"] == "constraint":
|
|
_hydrate_constraint_exceptions(conn, body["payload"])
|
|
err = _validate_payload_for_type(body["type"], body["payload"])
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
repo = ModuleRepository(conn)
|
|
# An optional `id` (8 hex) lets the AI chat pre-assign the id of
|
|
# a module it proposes, so a constraint or ref proposed in the
|
|
# same answer can already point at it.
|
|
given = body.get("id")
|
|
if given is not None:
|
|
if not isinstance(given, str) or not _NEW_ID.fullmatch(given):
|
|
return json_error("id must be 8 lowercase hex characters", status=400)
|
|
try:
|
|
repo.get(given)
|
|
return json_error(f"module {given} already exists", status=409)
|
|
except ModuleNotFound:
|
|
pass
|
|
row = repo.create(
|
|
id=given,
|
|
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)),
|
|
content_rating=body.get("content_rating", "safe"),
|
|
)
|
|
if given is not None:
|
|
# Back at an id it had (Undo on the AI chat's delete card):
|
|
# the bundles its delete flagged find it again.
|
|
_unflag_orphaned_in_bundles(conn, given)
|
|
except ValueError as e:
|
|
return json_error(str(e), status=400)
|
|
except sqlite3.IntegrityError as e:
|
|
# Most common: caller passed a `category_id` that doesn't exist
|
|
# in the categories table. Pre-fix this surfaced as a 500 with
|
|
# a stack trace; surface it as a clean 400 instead so the
|
|
# client can act on the error.
|
|
return json_error(f"foreign-key constraint failed: {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).
|
|
"""
|
|
err = validate_body_size(request.content_length)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
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_meta(body)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
if body["type"] == "wildcard":
|
|
err = validate_wildcard_name(body["name"])
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
|
|
try:
|
|
with db_session(request) as conn:
|
|
if body["type"] == "constraint":
|
|
_hydrate_constraint_exceptions(conn, body["payload"])
|
|
err = _validate_payload_for_type(body["type"], body["payload"])
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
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)),
|
|
content_rating=body.get("content_rating", "safe"),
|
|
)
|
|
except ValueError as e:
|
|
return json_error(str(e), status=400)
|
|
except sqlite3.IntegrityError as e:
|
|
return json_error(f"foreign-key constraint failed: {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.
|
|
|
|
Optional ``If-Match: <version>`` request header enables optimistic
|
|
concurrency: if the row's current ``version`` no longer matches
|
|
the value the client last saw, the PUT is rejected with ``409``
|
|
instead of silently overwriting another writer's work. Header is
|
|
opt-in — omitting it preserves the legacy last-write-wins behavior.
|
|
|
|
Response shape on success:
|
|
{<module fields>, "bundles_updated": ["bundle_id_1", ...]}
|
|
"""
|
|
mid = request.match_info["id"]
|
|
err = validate_body_size(request.content_length)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
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)
|
|
err = validate_meta(body)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
# PUT body may rename without re-stating `type` — the wildcard name
|
|
# guard needs the existing row's type. Defer the actual check until
|
|
# we have the existing row in hand (next db_session below).
|
|
kwargs: dict = {k: body[k] for k in _UPDATABLE_FIELDS if k in body}
|
|
propagate = bool(body.get("propagate_to_bundles", True))
|
|
|
|
# Optional optimistic-concurrency guard. Two-window edits previously
|
|
# raced silently — both PUTs returned 200, last writer won. With
|
|
# If-Match the second writer gets a 409 and can re-fetch.
|
|
expected_version_raw = request.headers.get("If-Match")
|
|
expected_version: int | None = None
|
|
if expected_version_raw is not None:
|
|
try:
|
|
expected_version = int(expected_version_raw.strip().strip('"'))
|
|
except ValueError:
|
|
return json_error("If-Match must be an integer version", status=400)
|
|
|
|
# If the caller is rewriting the payload, validate it against the
|
|
# row's existing module type — same guard as POST/import-from-workflow.
|
|
# Same lookup also feeds the wildcard-name char guard for renames.
|
|
existing_type: str | None = None
|
|
if "payload" in kwargs or "name" 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)
|
|
existing_type = existing["type"]
|
|
if "payload" in kwargs and existing_type == "constraint":
|
|
_hydrate_constraint_exceptions(conn, kwargs["payload"])
|
|
if "payload" in kwargs and existing_type is not None:
|
|
err = _validate_payload_for_type(existing_type, kwargs["payload"])
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
if "name" in kwargs and existing_type == "wildcard":
|
|
err = validate_wildcard_name(kwargs["name"])
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
|
|
bundles_updated: list[str] = []
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
if expected_version is not None:
|
|
try:
|
|
current = repo.get(mid)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
if current["version"] != expected_version:
|
|
return json_error(
|
|
f"version mismatch: If-Match={expected_version} but "
|
|
f"current version is {current['version']}",
|
|
status=409,
|
|
)
|
|
# Capture the row's pre-update name so we can detect renames
|
|
# AFTER the update succeeds. Without this we'd need a second
|
|
# repo.get() inside the rename branch — wasteful.
|
|
pre_name: str | None = None
|
|
if "name" in kwargs and existing_type == "wildcard":
|
|
try:
|
|
pre_name = repo.get(mid)["name"]
|
|
except ModuleNotFound:
|
|
pass
|
|
try:
|
|
row = repo.update(mid, **kwargs)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
except sqlite3.IntegrityError as e:
|
|
return json_error(f"foreign-key constraint failed: {e}", status=400)
|
|
# Wildcard rename: rewrite the `#name` segment of every
|
|
# @{uuid...} ref in the library so workflows render the new
|
|
# name everywhere. Runs inside the same transaction as the
|
|
# update for atomicity. Skips when only the name CASE changed
|
|
# and the engine considers them equivalent — cheapest skip is
|
|
# "string equal".
|
|
if (
|
|
existing_type == "wildcard"
|
|
and "name" in kwargs
|
|
and pre_name is not None
|
|
and pre_name != kwargs["name"]
|
|
):
|
|
from engine.cascade.fixers import fix_wildcard_rename_name
|
|
fix_wildcard_rename_name(conn, mid, kwargs["name"])
|
|
# 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)
|
|
|
|
|
|
def _flag_orphaned_in_bundles(conn, module_id: str) -> list[str]:
|
|
"""Mark every bundle child snapshot whose ``id`` matches ``module_id``
|
|
as orphaned so the SPA can surface a "library entry gone" badge.
|
|
|
|
Bundles are deliberately self-contained — children stay as frozen
|
|
payload snapshots even after the source module is deleted, so a
|
|
user can still pull a working bundle into a workflow. The downside
|
|
is that the bundle now references a phantom module with no live
|
|
library ancestor. Stamping ``meta.orphaned = true`` (plus a
|
|
timestamp) is the smallest structural change that keeps the
|
|
snapshot intact while giving the UI something to filter on.
|
|
|
|
Returns the list of bundle ids that had at least one child stamped.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
bundle_repo = BundleRepository(conn)
|
|
stamped_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
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") == module_id:
|
|
next_child = dict(child)
|
|
meta = next_child.get("meta")
|
|
if not isinstance(meta, dict):
|
|
meta = {}
|
|
if not meta.get("orphaned"):
|
|
meta = {**meta, "orphaned": True, "orphaned_at": stamped_at}
|
|
next_child["meta"] = meta
|
|
rewritten = True
|
|
new_children.append(next_child)
|
|
else:
|
|
new_children.append(child)
|
|
if rewritten:
|
|
bundle_repo.update(bundle["id"], children=new_children)
|
|
affected.append(bundle["id"])
|
|
return affected
|
|
|
|
|
|
def _unflag_orphaned_in_bundles(conn: sqlite3.Connection, module_id: str) -> list[str]:
|
|
"""Undo `_flag_orphaned_in_bundles` for a module that is back."""
|
|
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:
|
|
meta = child.get("meta") if isinstance(child, dict) else None
|
|
if isinstance(meta, dict) and child.get("id") == module_id and meta.get("orphaned"):
|
|
meta = {k: v for k, v in meta.items() if k not in ("orphaned", "orphaned_at")}
|
|
child = {**child, "meta": meta}
|
|
rewritten = True
|
|
new_children.append(child)
|
|
if rewritten:
|
|
bundle_repo.update(bundle["id"], children=new_children)
|
|
affected.append(bundle["id"])
|
|
return affected
|
|
|
|
|
|
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)
|
|
# After the row's gone, flag any bundle children whose snapshot
|
|
# pointed at this id so the SPA can render a "stale snapshot"
|
|
# badge. Same transaction means the bundles never see a window
|
|
# where the module is missing but children aren't flagged.
|
|
_flag_orphaned_in_bundles(conn, mid)
|
|
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,
|
|
# An 18+ derivative is still 18+ — carry the rating so the copy
|
|
# isn't silently downgraded to SFW and out from under the filter.
|
|
content_rating=src["content_rating"],
|
|
)
|
|
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 set_community_origin(request: web.Request) -> web.Response:
|
|
"""POST /wp/api/modules/{id}/community-origin -- stamp the row as
|
|
published to a community post.
|
|
|
|
Called by the host bridge's `markPublished` after the embed
|
|
successfully creates a post (or new version) from this row. Links
|
|
the local row to its community post so the SPA shows the community
|
|
pill, the update-available checker tracks it, and a re-publish is
|
|
recognized as a version bump rather than a fresh post.
|
|
|
|
Body: ``{post_slug: str, version_number: int}``. Does NOT bump the
|
|
row version (origin metadata isn't runtime-affecting)."""
|
|
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)
|
|
slug = body.get("post_slug")
|
|
version = body.get("version_number")
|
|
if not isinstance(slug, str) or not slug:
|
|
return json_error("post_slug must be a non-empty string", status=400)
|
|
if not isinstance(version, int) or isinstance(version, bool):
|
|
return json_error("version_number must be an integer", status=400)
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
try:
|
|
row = repo.set_community_origin(
|
|
mid, post_slug=slug, version_number=version,
|
|
)
|
|
except ModuleNotFound:
|
|
return json_error(f"module not found: {mid}", status=404)
|
|
return json_ok(row)
|
|
|
|
|
|
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.
|
|
|
|
The RESPONSE is small (no payload, no metadata). Producing it was not:
|
|
this used to call `ModuleRepository.list()`, which is `SELECT *` plus a
|
|
`json.loads` of every payload in the library, and then kept three fields
|
|
per row. Measured on a real install (2026-08-06): 33 KB on the wire every
|
|
5 seconds, per open tab, with no caching headers — and both the SPA and
|
|
the in-graph WP_Context subscribe, so a ComfyUI tab with a Context node
|
|
polls this whether or not the manager is ever opened.
|
|
|
|
Now conditional. The ETag comes from a cheap `COUNT/SUM(version)/MAX`
|
|
stamp, so an unchanged library answers 304 without touching a payload —
|
|
which is the case that repeats. The map is only built when the stamp
|
|
moves.
|
|
"""
|
|
with db_session(request) as conn:
|
|
repo = ModuleRepository(conn)
|
|
etag = make_etag("modules-hashes-v1", repo.library_stamp())
|
|
if matches_if_none_match(request, etag):
|
|
return json_ok_revalidated(request, None, etag=etag)
|
|
rows = repo.list_hash_rows()
|
|
# Wire key stays "hashes" (value string -> object) to avoid relabeling
|
|
# consumers. `type` enables cross-kind id-clash detection; `payload_hash`
|
|
# is the existing drift signal the in-graph WP_Context compares against
|
|
# its embedded snapshot. Post-migration-004 the row's `id` IS the 8-hex
|
|
# uuid the tokenizer's `@{8hex}` ref captures.
|
|
return json_ok_revalidated(request, {
|
|
"hashes": {
|
|
row["id"]: {
|
|
"type": row["type"],
|
|
"payload_hash": row["payload_hash"],
|
|
}
|
|
for row in rows
|
|
},
|
|
}, etag=etag)
|
|
|
|
|
|
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)
|
|
router.add_post(
|
|
"/wp/api/modules/{id}/community-origin", set_community_origin,
|
|
)
|