A nested ref in a derivation resolved to empty whenever the wildcard it points
at was not ALSO picked into the same Context node — no matter what the live
library held, and no matter how the ref was filtered.
Root cause is discovery, not resolution, which is why it looked like a
resolution bug. The catalog is synthesised from wildcard modules only, and
`walk_transitive_refs` was seeded from those uuids and followed refs found in
wildcard payloads only. A `@{uuid}` is legal on other surfaces too — most
importantly a derivation rule's action value, and the per-instance
`action_value_overrides` the edit modal writes when a user retargets a branch
on the canvas. Neither was ever scanned.
So such a ref's target reached the catalog only by coincidence: when the user
happened to also have that wildcard picked in the node. Removing it — a
reasonable thing to do once a derivation references it by uuid — dropped it
with nothing to put it back, and the ref became `unknown_ref` → empty string.
`ref_seed_uuids` (engine/modules/snapshot.py) now collects refs from every
module's `payload` AND `instance`, and both catalog builders seed the walk
with them. The instance half matters on its own: a canvas-side retarget lives
there and is not part of the library payload the walker would otherwise see.
Both builders are fixed, not just the graph one. `wp_api/preview.py` had the
same seeding, so the SPA preview resolved against a pool the engine would not
use — that is precisely how "the SPA says 1 of 11 options match, the graph
says 0 of 10" arose. Fixing one and not the other would have left the two
disagreeing in the opposite direction.
Embedded-first precedence is unchanged: a picked module still shadows the
library, so drift detection and workflow reproducibility hold. This only adds
targets that were previously absent entirely. An unresolvable uuid still warns
and resolves to empty rather than raising.
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
"""POST /wp/api/preview/resolve — assembler-preview RNG-faithful resolve.
|
|
|
|
Frontend assembler widget calls this to get the same `(name → value)`
|
|
map a real WP_Context chain would produce at run time when every node
|
|
is seeded with the supplied seed (default 42). Returning ground truth
|
|
from the engine guarantees the preview matches what the user sees on
|
|
queue without porting Python's MT19937 to JS.
|
|
|
|
Body shape:
|
|
{
|
|
"chain": [<modules-list-step-1>, <modules-list-step-2>, ...],
|
|
"seed": <int, default 42>
|
|
}
|
|
|
|
Each `modules-list-step-N` is the same list shape ContextWidget
|
|
serialises into a node's `modules` widget value: list of module dicts
|
|
with `id`, `type`, `enabled`, `payload`, `entries`, `instance`, ...
|
|
|
|
Returns:
|
|
{"resolved": {<name>: <value>, ...}}
|
|
|
|
Empty chain or any malformed step → empty resolved map (never 500).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from aiohttp import web
|
|
|
|
from engine.context import strip_internals
|
|
from engine.db.connection import get_connection
|
|
from engine.db.migrations import migrate
|
|
from engine.db.repositories import ModuleNotFound, ModuleRepository
|
|
from engine.modules.snapshot import ref_seed_uuids, walk_transitive_refs
|
|
from engine.pipeline import PipelineEngine
|
|
from engine.syntax.types import ListVar
|
|
from wp_api._helpers import json_error, json_ok
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _build_catalog(modules: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
"""Wildcard subset → SnapshotEntry-shaped catalog keyed by uuid.
|
|
|
|
Mirrors `wp_nodes/types.py:deserialize_node_input` so the engine
|
|
sees the same shape it gets at run time.
|
|
"""
|
|
catalog: dict[str, dict[str, Any]] = {}
|
|
for m in modules:
|
|
if not isinstance(m, dict) or m.get("type") != "wildcard":
|
|
continue
|
|
mid = m.get("id")
|
|
if not isinstance(mid, str):
|
|
continue
|
|
payload = m.get("payload")
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
meta = m.get("meta") if isinstance(m.get("meta"), dict) else {}
|
|
catalog[mid] = {
|
|
"snapshot_version": 1,
|
|
"uuid": mid,
|
|
"type": "wildcard",
|
|
"name": meta.get("name", "") if isinstance(meta, dict) else "",
|
|
"payload": payload,
|
|
"payload_hash": m.get("payload_hash") or "",
|
|
"source": {"kind": "user"},
|
|
}
|
|
return catalog
|
|
|
|
|
|
def _expand_with_db(
|
|
catalog: dict[str, dict[str, Any]],
|
|
modules: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Pull in nested `@{uuid}` refs from the live DB so preview can
|
|
resolve them — mirrors `wp_nodes/context_node.py:_expand_catalog_via_live_db`.
|
|
|
|
`modules` matters for the same reason it does there: a `@{uuid}` written on
|
|
a derivation action (or its per-instance override) is invisible to a walk
|
|
seeded from wildcard uuids alone. Preview has to seed identically or it
|
|
reports a pool the engine will not use — which is exactly how the
|
|
"SPA says 1 of 11, the graph says 0 of 10" split arose.
|
|
|
|
Failure-tolerant: any DB hiccup returns the embedded catalog
|
|
unchanged and the resolver falls back to the standard
|
|
"unknown ref" warning at expand time.
|
|
"""
|
|
seeds = ref_seed_uuids(modules)
|
|
if not catalog and not seeds:
|
|
return catalog
|
|
try:
|
|
conn = get_connection()
|
|
migrate(conn)
|
|
repo = ModuleRepository(conn)
|
|
except Exception:
|
|
return catalog
|
|
|
|
def _fetch(uuid: str) -> dict[str, Any] | None:
|
|
embedded = catalog.get(uuid)
|
|
if embedded is not None:
|
|
return {
|
|
"id": uuid,
|
|
"type": embedded["type"],
|
|
"name": embedded.get("name", ""),
|
|
"payload": embedded["payload"],
|
|
"payload_hash": embedded.get("payload_hash", ""),
|
|
}
|
|
try:
|
|
return repo.get(uuid)
|
|
except ModuleNotFound:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
roots = list(catalog.keys()) + [u for u in sorted(seeds) if u not in catalog]
|
|
walk = walk_transitive_refs(roots, fetch_module=_fetch)
|
|
return walk.snapshots
|
|
|
|
|
|
def _jsonify_resolved(resolved: dict[str, Any]) -> dict[str, Any]:
|
|
"""Make a resolved ctx map JSON-serialisable. A multi-pick var is a
|
|
`ListVar` dataclass (engine/syntax/types.py) that aiohttp's JSON encoder
|
|
can't handle — without this the endpoint 500s. Emit it as
|
|
``{"items": [...], "sep": ...}`` so the TS preview can join (bare $name)
|
|
or index ($name.K) it, matching the engine's resolution exactly."""
|
|
out: dict[str, Any] = {}
|
|
for k, v in resolved.items():
|
|
out[k] = {"items": list(v.items), "sep": v.sep} if isinstance(v, ListVar) else v
|
|
return out
|
|
|
|
|
|
async def resolve_preview(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)
|
|
|
|
chain = body.get("chain")
|
|
if not isinstance(chain, list):
|
|
return json_error("chain must be a list", status=400)
|
|
|
|
try:
|
|
seed = int(body.get("seed", 42))
|
|
except (TypeError, ValueError):
|
|
return json_error("seed must be an integer", status=400)
|
|
|
|
# Walk chain steps upstream → downstream; each step's modules run
|
|
# in their own PipelineEngine.run() with the supplied seed. Earlier
|
|
# ctx feeds the next step as upstream. Catalog accumulates wildcards
|
|
# from every step + their nested refs from the live DB.
|
|
ctx: dict[str, Any] = {}
|
|
accum_catalog: dict[str, dict[str, Any]] = {}
|
|
for step in chain:
|
|
if not isinstance(step, list):
|
|
continue
|
|
modules = [m for m in step if isinstance(m, dict)]
|
|
accum_catalog.update(_build_catalog(modules))
|
|
# Re-expand each step so newly-introduced wildcards pull their
|
|
# nested deps too. walk_transitive_refs is idempotent.
|
|
accum_catalog = _expand_with_db(accum_catalog, modules)
|
|
ctx["__wp_catalog__"] = accum_catalog
|
|
try:
|
|
ctx = PipelineEngine().run(modules, ctx=ctx, seed=seed)
|
|
except Exception:
|
|
logger.exception("preview resolve: pipeline run failed")
|
|
# Don't return 500 — the user is just typing. Surface
|
|
# whatever we resolved up to this point.
|
|
break
|
|
|
|
return json_ok({"resolved": _jsonify_resolved(strip_internals(ctx))})
|
|
|
|
|
|
def register(router) -> None:
|
|
router.add_post("/wp/api/preview/resolve", resolve_preview)
|