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.
398 lines
16 KiB
Python
398 lines
16 KiB
Python
"""Snapshot freezing, content hashing, legacy-shape coercion, and transitive
|
|
ref walking.
|
|
|
|
A *snapshot* is the JSON shape embedded in a ComfyUI workflow's WP_Context
|
|
node when a user picks a library row. The snapshot is decoupled from the
|
|
library: graph execution reads only the snapshot, never the live DB.
|
|
|
|
Backward-compat: pre-SPA fixed_values modules in saved workflows lack the
|
|
``library_*`` and ``instance`` keys. ``coerce_legacy_module`` upgrades them
|
|
in-place to the canonical shape (still detached, ``library_id is None``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Callable, Iterable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Literal, TypedDict
|
|
|
|
from engine._utils import now_iso as _now_iso
|
|
from engine.syntax.tokenize import tokenize_text
|
|
from engine.syntax.types import TokenKind
|
|
|
|
|
|
class _SourceUser(TypedDict):
|
|
kind: Literal["user"]
|
|
|
|
|
|
class _SourceDep(TypedDict):
|
|
kind: Literal["dep"]
|
|
parent_uuids: list[str]
|
|
|
|
|
|
SnapshotSource = _SourceUser | _SourceDep
|
|
|
|
|
|
class SnapshotEntry(TypedDict):
|
|
"""Canonical shape stored in `__wp_catalog__` and embedded in
|
|
WP_Context workflow JSON. Spec §2.4 — pinned, do not rename fields.
|
|
|
|
`payload_hash` covers `payload` only (not name/description/tags) so
|
|
a rename does not flip drift state. `source` records why the entry
|
|
is in the embed: explicit user pick vs. transitive dep of one or
|
|
more picks (multi-parent supported)."""
|
|
snapshot_version: Literal[1]
|
|
uuid: str # 8 hex chars
|
|
type: str # ModuleType — wildcards-only in catalog (spec §2.7)
|
|
name: str # display label
|
|
payload: dict[str, Any]
|
|
payload_hash: str # SHA-256 of payload
|
|
source: SnapshotSource
|
|
|
|
|
|
def _fresh_instance() -> dict[str, Any]:
|
|
"""Default per-snapshot instance overrides. Fresh dict per call.
|
|
|
|
Invariant: every field defaults to ``None``. The frontend
|
|
"modified-state" predicate is ``value != null``, so any non-None
|
|
sentinel (e.g. ``""`` for a string field, ``False`` for a bool)
|
|
would falsely light up the orange dot, force the modal to default
|
|
to the Instance tab, and tag the section as modified — even on a
|
|
pristine snapshot. Engine readers already tolerate ``None`` (see
|
|
``wildcard_handler.py:216`` for the binding fallback chain and
|
|
``pipeline.py:160`` for the internal-flag coercion), so we keep a
|
|
single contract: ``None`` everywhere = "no override, use library
|
|
default".
|
|
"""
|
|
return {
|
|
"variable_binding": None,
|
|
"enabled_options": None,
|
|
"category_filter": None,
|
|
"option_weights": None,
|
|
# SP2a multi-select + the null toggle — part of the pool-shaping
|
|
# override set, so a frozen snapshot carries them too (all None =
|
|
# "no override"). Keeps this dict honest with the "every field" claim
|
|
# above + the instance schema (engine/schemas/instance_schemas.py).
|
|
"exclude_null": None,
|
|
"pick_min": None,
|
|
"pick_max": None,
|
|
"pick_separator": None,
|
|
"pick_independent": None, # SP2c: multi-pick with replacement when true
|
|
# Pick mode — `None`/missing → `random` (legacy behavior:
|
|
# weighted RNG over enabled options). `pinned` short-circuits
|
|
# the RNG and always picks `pinned_option_id`. `subcategory`
|
|
# is currently identical to `random` engine-side; the modal
|
|
# uses it as a UX framing for "manual subset via enabled_options".
|
|
"mode": None,
|
|
"pinned_option_id": None,
|
|
# Lock — when set, derives a stable per-instance RNG seed
|
|
# independent of the chain's `__wp_node_seed__`. Means the
|
|
# wildcard's pick stays the same value across runs even when
|
|
# the user cycles the Context node's seed. `None` = roll with
|
|
# the chain seed (default).
|
|
"locked_seed": None,
|
|
# Internal — when True, every binding this module produces is
|
|
# marked engine-only by `PipelineEngine.run` (via
|
|
# `__wp_internal_flags__`). Downstream modules still see the
|
|
# value; the public socket payload doesn't. ``None`` is coerced
|
|
# to ``False`` at the read site, so the default stays "off".
|
|
"internal": None,
|
|
"disabled_rule_ids": None,
|
|
"disabled_exception_keys": None,
|
|
"disabled_matrix_cells": None,
|
|
}
|
|
|
|
|
|
def payload_hash(payload: dict[str, Any]) -> str:
|
|
"""SHA-256 of canonical JSON. Stable across key order + whitespace."""
|
|
canonical = json.dumps(
|
|
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
)
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def freeze_snapshot(library_row: dict[str, Any]) -> dict[str, Any]:
|
|
"""Build the JSON shape that gets embedded in a node's modules array."""
|
|
return {
|
|
"library_id": library_row["id"],
|
|
"library_snapshot_at": _now_iso(),
|
|
"library_version_at_snapshot": library_row["version"],
|
|
"type": library_row["type"],
|
|
"name": library_row["name"],
|
|
"category_id": library_row.get("category_id"),
|
|
"payload": library_row["payload"],
|
|
"instance": _fresh_instance(),
|
|
}
|
|
|
|
|
|
def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Upgrade a legacy module dict to canonical snapshot shape.
|
|
|
|
Idempotent: returns ``raw`` unchanged if it already has both
|
|
``library_id`` and ``instance`` keys (the legacy "frozen" shape).
|
|
|
|
SPA-picked modules don't carry ``library_id`` (the picked uuid IS
|
|
the library id, stored as ``id``), but they may carry an
|
|
``instance`` dict with user overrides (option enable/disable,
|
|
weight overrides). Merge those onto the fresh-instance defaults
|
|
instead of stomping them with ``_fresh_instance()`` below.
|
|
"""
|
|
if "instance" in raw and "library_id" in raw:
|
|
return raw
|
|
|
|
# Capture caller-supplied instance early so we can merge it with
|
|
# defaults once the rest of the snapshot is built. Missing → empty.
|
|
raw_instance = raw.get("instance")
|
|
user_instance = raw_instance if isinstance(raw_instance, dict) else {}
|
|
|
|
payload: dict[str, Any]
|
|
if raw.get("type") == "fixed_values":
|
|
# Two shapes can land here:
|
|
#
|
|
# 1. Inline-created (legacy): `entries: [{variable_name, value}]`
|
|
# with no `payload.values`. Translate entries → values.
|
|
# 2. Library-picked (post-5.5.4): `payload: {values: [...]}` with
|
|
# an empty `entries: []` (the field is always present so the
|
|
# ContextWidget conflict-scanner doesn't have to null-check).
|
|
# Use `payload` directly — translating its empty entries
|
|
# would zero out a perfectly good values list.
|
|
raw_payload = raw.get("payload") or {}
|
|
legacy_entries = raw.get("entries") or []
|
|
if legacy_entries and not raw_payload.get("values"):
|
|
payload = {
|
|
"values": [
|
|
{
|
|
"id": f"val_{i:04x}",
|
|
"name": e.get("variable_name", ""),
|
|
"value": e.get("value", ""),
|
|
}
|
|
for i, e in enumerate(legacy_entries)
|
|
],
|
|
}
|
|
else:
|
|
payload = raw_payload
|
|
else:
|
|
payload = raw.get("payload", {})
|
|
|
|
meta = raw.get("meta") or {}
|
|
instance = _fresh_instance()
|
|
instance.update(user_instance)
|
|
return {
|
|
"library_id": raw.get("library_id"),
|
|
"library_snapshot_at": raw.get("library_snapshot_at"),
|
|
"library_version_at_snapshot": raw.get("library_version_at_snapshot"),
|
|
"type": raw.get("type", "fixed_values"),
|
|
"name": raw.get("name") or meta.get("name", ""),
|
|
"category_id": raw.get("category_id"),
|
|
"payload": payload,
|
|
"instance": instance,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transitive-ref walker (spec §2.7, §2.8, §2.10, §6)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WalkOverflow(TypedDict):
|
|
"""Walker-recorded anomaly. Returned alongside the partial snapshot
|
|
bundle; never aborts the walk. Spec §2.8."""
|
|
uuid: str
|
|
reason: Literal["max_depth", "cycle_detected", "missing_target"]
|
|
|
|
|
|
@dataclass
|
|
class WalkResult:
|
|
"""Return value of `walk_transitive_refs`. Two fields, two concerns:
|
|
`snapshots` is the materialised catalog dict; `walk_overflow` lists
|
|
every uuid the walker could not include (cycle, depth, miss)."""
|
|
snapshots: dict[str, SnapshotEntry] = field(default_factory=dict)
|
|
walk_overflow: list[WalkOverflow] = field(default_factory=list)
|
|
|
|
|
|
def extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
|
|
"""Collect all ``@{8hex}`` refs referenced inside a payload's
|
|
string-typed leaves. Uses ``tokenize_text`` exclusively — only
|
|
canonical 8-hex-char refs (spec §2.4) are followed.
|
|
|
|
Surface-agnostic: collects from any string in the dict/list tree;
|
|
the caller decides which module types have their payloads walked
|
|
(spec §2.7)."""
|
|
refs: set[str] = set()
|
|
|
|
def _scan(value: Any) -> None:
|
|
if isinstance(value, str):
|
|
for tok in tokenize_text(value):
|
|
if tok.kind == TokenKind.REF:
|
|
uid = (tok.meta or {}).get("uuid")
|
|
if isinstance(uid, str):
|
|
refs.add(uid)
|
|
elif isinstance(value, dict):
|
|
for v in value.values():
|
|
_scan(v)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
_scan(item)
|
|
|
|
_scan(payload)
|
|
return refs
|
|
|
|
|
|
def _row_to_snapshot_entry(
|
|
row: dict[str, Any], source: SnapshotSource,
|
|
) -> SnapshotEntry:
|
|
"""Convert a repository row dict to a canonical SnapshotEntry.
|
|
|
|
Post migration 004 the row's `id` IS the canonical 8-hex uuid;
|
|
there is no separate `uuid` field. The snapshot schema still
|
|
names the field `uuid` because that's the published wire format
|
|
embedded in workflow JSON — we just source it from `row["id"]`.
|
|
"""
|
|
return {
|
|
"snapshot_version": 1,
|
|
"uuid": row["id"],
|
|
"type": row["type"],
|
|
"name": row["name"],
|
|
"payload": row["payload"],
|
|
"payload_hash": row.get("payload_hash") or payload_hash(row["payload"]),
|
|
"source": source,
|
|
}
|
|
|
|
|
|
def ref_seed_uuids(modules: Iterable[Any] | None) -> set[str]:
|
|
"""Every ``@{uuid}`` target referenced by ANY module in a node's list.
|
|
|
|
A catalog is synthesised from wildcard modules only, and
|
|
``walk_transitive_refs`` follows refs found in wildcard payloads only. That
|
|
covers a ref authored inside a wildcard option, but ``@{uuid}`` is legal on
|
|
other surfaces too — most notably a derivation rule's action value, and the
|
|
per-instance ``action_value_overrides`` / ``condition_value_overrides`` the
|
|
edit modal writes when a user retargets a branch on the canvas.
|
|
|
|
Refs on those surfaces were never discovered, so their target reached the
|
|
catalog only by coincidence — when the user happened to ALSO have that
|
|
wildcard picked in the same node. Removing the wildcard from the node (a
|
|
reasonable thing to do once a derivation references it by uuid) dropped it
|
|
with nothing to put it back, and every such ref resolved to ``unknown_ref``
|
|
→ empty string no matter what the live library held.
|
|
|
|
Seeds therefore come from each module's ``payload`` AND its ``instance``:
|
|
the instance is where a canvas-side retarget lives, and it is not part of
|
|
the library payload the walker would otherwise see.
|
|
|
|
Tolerant of malformed rows — a corrupt widget value must never break a
|
|
graph run.
|
|
"""
|
|
seeds: set[str] = set()
|
|
for module in modules or []:
|
|
if not isinstance(module, dict):
|
|
continue
|
|
for key in ("payload", "instance"):
|
|
value = module.get(key)
|
|
if isinstance(value, dict):
|
|
seeds |= extract_at_uuid_refs(value)
|
|
return seeds
|
|
|
|
|
|
def walk_transitive_refs(
|
|
root_uuids: Iterable[str],
|
|
*,
|
|
fetch_module: Callable[[str], dict[str, Any] | None],
|
|
max_depth: int = 8,
|
|
) -> WalkResult:
|
|
"""Lazy transitive walker. Spec §2.10.
|
|
|
|
Starts from ``root_uuids``, follows ``@{8hex}`` refs in wildcard payloads
|
|
only (spec §2.7), and returns a ``WalkResult`` containing every reachable
|
|
SnapshotEntry plus a list of overflow records (cycle, depth, missing).
|
|
|
|
Cycle detection uses a per-path ancestor frozenset carried with each BFS
|
|
queue entry. When we pop ``(uuid, depth, parent, ancestors)`` and find
|
|
``uuid`` already in ``ancestors``, we record ``cycle_detected`` and skip
|
|
without emitting the node. This means the node that *caused* the cycle
|
|
reference is already snapshotted (it was emitted when we first reached it
|
|
on a shorter path), so both sides of a two-node cycle appear in snapshots.
|
|
|
|
Engine isolation invariant: this function does NOT import from ``engine.db``
|
|
or ``wp_api``; the caller injects a ``fetch_module(uuid) -> row | None``
|
|
callable. WP_Context graph nodes never call this — they receive a
|
|
pre-walked dict embedded in workflow JSON.
|
|
"""
|
|
result = WalkResult()
|
|
# parent_map: uuid -> list of direct parent uuids (accumulated across BFS)
|
|
parent_map: dict[str, list[str]] = {}
|
|
seen_overflow: set[tuple[str, str]] = set()
|
|
|
|
def _record_overflow(uuid: str, reason: str) -> None:
|
|
key = (uuid, reason)
|
|
if key in seen_overflow:
|
|
return
|
|
seen_overflow.add(key)
|
|
result.walk_overflow.append(
|
|
{"uuid": uuid, "reason": reason}, # type: ignore[arg-type]
|
|
)
|
|
|
|
# Queue items: (uuid, depth, parent_uuid_or_None, ancestor_frozenset)
|
|
# ancestor_frozenset contains all uuids on the path from a root to *before*
|
|
# this node — used to detect back-edges (cycles).
|
|
queue: list[tuple[str, int, str | None, frozenset[str]]] = []
|
|
|
|
for u in root_uuids:
|
|
queue.append((u, 0, None, frozenset()))
|
|
|
|
while queue:
|
|
uuid, depth, parent, ancestors = queue.pop(0)
|
|
|
|
# Track parentage before any short-circuit so multi-parent deps
|
|
# accumulate even if we have already snapshotted the child.
|
|
if parent is not None:
|
|
parent_map.setdefault(uuid, [])
|
|
if parent not in parent_map[uuid]:
|
|
parent_map[uuid].append(parent)
|
|
|
|
# Cycle: this uuid appears on the path from a root to here.
|
|
if uuid in ancestors:
|
|
_record_overflow(uuid, "cycle_detected")
|
|
continue
|
|
|
|
# Already snapshotted on a shorter BFS path — skip emission but
|
|
# parent tracking above already ran, so multi-parent deps accumulate.
|
|
if uuid in result.snapshots:
|
|
continue
|
|
|
|
if depth > max_depth:
|
|
_record_overflow(uuid, "max_depth")
|
|
continue
|
|
|
|
row = fetch_module(uuid)
|
|
if row is None:
|
|
_record_overflow(uuid, "missing_target")
|
|
continue
|
|
|
|
# Determine source based on whether this uuid has a parent.
|
|
if parent is None:
|
|
source: SnapshotSource = {"kind": "user"}
|
|
else:
|
|
source = {"kind": "dep", "parent_uuids": list(parent_map[uuid])}
|
|
result.snapshots[uuid] = _row_to_snapshot_entry(row, source)
|
|
|
|
# Only walk wildcard payloads — spec §2.7.
|
|
if row.get("type") != "wildcard":
|
|
continue
|
|
|
|
new_ancestors = ancestors | {uuid}
|
|
for child_uuid in extract_at_uuid_refs(row["payload"]):
|
|
queue.append((child_uuid, depth + 1, uuid, new_ancestors))
|
|
|
|
# After walk: re-stamp every dep's parent list (BFS may have accumulated
|
|
# additional parents after first emission).
|
|
for uuid, parents in parent_map.items():
|
|
if uuid not in result.snapshots:
|
|
continue
|
|
existing = result.snapshots[uuid]
|
|
if existing["source"]["kind"] == "dep":
|
|
existing["source"]["parent_uuids"] = list(parents)
|
|
|
|
return result
|