The in-node preview rolled every Context on seed 42, so changing a Context's seed never changed it. It now rolls each Context on its own seed widget (frame 1's seed under a Context Loop that overrides seeds).
220 lines
8.6 KiB
Python
220 lines
8.6 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>,
|
|
"seeds": [<int | null per step>, ...] (optional)
|
|
}
|
|
|
|
`seeds[i]` is step i's own seed (the Assembler sends each Context's seed
|
|
widget, so the preview is what Run prints and follows a seed change);
|
|
a missing or null entry uses `seed`.
|
|
|
|
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>, ...},
|
|
"axes": {<binding>: {<AXIS>: <tag>} | [{<AXIS>: <tag>}, ...], ...},
|
|
"negatives": {<binding>: [{"text", "pick", "source"}, ...], ...}}
|
|
|
|
Empty chain or any malformed step → empty resolved map (never 500).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, cast
|
|
|
|
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.log_once import log_once
|
|
from engine.modules.snapshot import ref_seed_uuids, walk_transitive_refs
|
|
from engine.pipeline import PipelineEngine
|
|
from engine.seed_derive import MAX_SAFE_SEED
|
|
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 = _got if isinstance(_got := 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()
|
|
except Exception:
|
|
return catalog
|
|
try:
|
|
migrate(conn)
|
|
repo = ModuleRepository(conn)
|
|
except Exception:
|
|
conn.close()
|
|
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]
|
|
try:
|
|
walk = walk_transitive_refs(roots, fetch_module=_fetch)
|
|
finally:
|
|
# Every preview request used to leave this connection open.
|
|
conn.close()
|
|
return cast("dict[str, dict[str, Any]]", 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)
|
|
|
|
# Optional per-step seeds (the Assembler sends each Context's own seed so
|
|
# the preview is what Run prints); a missing / null / bad entry falls back
|
|
# to `seed`. Masked to 50 bits like `effective_chain_seed`.
|
|
raw_seeds = body.get("seeds")
|
|
step_seeds: list[int] = []
|
|
if isinstance(raw_seeds, list):
|
|
for v in raw_seeds:
|
|
ok = isinstance(v, int) and not isinstance(v, bool)
|
|
step_seeds.append((v & MAX_SAFE_SEED) if ok else seed)
|
|
|
|
# Walk chain steps upstream → downstream; each step's modules run
|
|
# in their own PipelineEngine.run() with its step 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 i, step in enumerate(chain):
|
|
if not isinstance(step, list):
|
|
continue
|
|
step_seed = step_seeds[i] if i < len(step_seeds) else seed
|
|
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=step_seed)
|
|
except Exception as e:
|
|
# Previews run on every keystroke: one traceback per distinct error.
|
|
log_once(logger, logging.ERROR, ("preview", type(e).__name__, str(e)),
|
|
"preview resolve: pipeline run failed", exc_info=True)
|
|
# Don't return 500 — the user is just typing. Surface
|
|
# whatever we resolved up to this point.
|
|
break
|
|
|
|
# The rolled `accepts` axes ride alongside, keyed by binding exactly as
|
|
# `__wp_axes__` stores them. `strip_internals` drops that table, and
|
|
# without it the preview could only guess `$var.AXIS` from the wildcard's
|
|
# tag list — printing "sandals" beside an option that carries no shoes.
|
|
axes = ctx.get("__wp_axes__")
|
|
# Send-to-negative: each variable's negatives, so the Assembler preview
|
|
# can build its negative locally from whatever its template uses.
|
|
negs = ctx.get("__wp_negatives__")
|
|
return json_ok({
|
|
"resolved": _jsonify_resolved(strip_internals(ctx)),
|
|
"axes": axes if isinstance(axes, dict) else {},
|
|
"negatives": negs if isinstance(negs, dict) else {},
|
|
})
|
|
|
|
|
|
def register(router) -> None:
|
|
router.add_post("/wp/api/preview/resolve", resolve_preview)
|