Files
DumiFlex-ComfyUI-Wildcard-P…/wp_nodes/context_node.py
T
DumiFlex be1f765243 feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot
Replace the constraint one-shot/consumed-set model with a reach selector:
each constraint's target_select (default {mode:"all"}) decides which firing
target instances it covers, and every covering constraint is applied +
combined sequentially. A per-constraint hit counter (__wp_constraint_hits__)
replaces the consumed-set; reach is downstream-relative (a constraint is only
in ctx after its module ran).

- apply_constraints_for_target rewritten: hits/firing_uid/carrier_ctx params,
  _occurrence_matches matcher (carrier_ctx None from callers — nested-pick
  threading is a later task). consumed= kept accepted+ignored for back-compat.
- claim_carrier_constraints deleted (the positional spill failsafe is obsolete
  without consumption); its import + both wildcard_handler call sites + the
  _claim_carried helper removed.
- resolve.py nested path shares the ctx hit counter (get_constraint_hits) so
  first/next coverage spans direct + nested encounters; carrier_ctx=None.
- pipeline.py finalisation now hits-based: constraint_never_applied when a
  selector covered zero, new constraint_partial_reach when next N / pick
  matched fewer than asked.
- cross-node: __wp_constraint_hits__ replaces __wp_consumed_constraints__ in
  _CROSS_NODE_INTERNAL_KEYS; build_payload dedup also collapses partial_reach.

Existing one-shot tests adapted to the new semantics:
- test_constraint_first_instance: the implicit "fires once" tests re-expressed
  via explicit target_select={mode:"first"}; added an all-mode-default test and
  a partial_reach finalisation test. The pre-seeded-consumed no-op test became a
  first-mode "counter already past 1" test (consumed-set concept gone).
- test_user_chain_simulation: consumed assertions → hit-count assertions; the
  carrier-claim tests (no-spill, null-pick claim, source-not-picked, sibling
  one-shots) rewritten to the new model — a carrier that doesn't resolve the ref
  no longer "claims" anything, so the constraint applies on the next actual
  resolution (or legitimately never fires → never_applied).
- test_module_interactions: "only first fires" composition test replaced with a
  two-all-constraints-compose-on-one-target test (both excludes zero the pool →
  excludes-all fallback), plus a single-all-constraint survivor test.
2026-06-11 06:21:57 +03:00

200 lines
8.7 KiB
Python

"""WP_Context — runs a module list and emits a PipelineContext payload."""
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
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 walk_transitive_refs
from engine.pipeline import PipelineEngine
from engine.seed_derive import effective_chain_seed
from wp_nodes.types import (
ContextModulesInput,
PipelineContext,
build_payload,
deserialize_node_input,
)
def _expand_catalog_via_live_db(catalog: dict) -> dict:
"""Per-issue-#2: the embed-bundle endpoint no longer walks
transitive ``@{}`` refs — workflow JSON only carries what the
user explicitly picked. At graph-run time we fill in any nested
wildcards by querying the live library on the executing machine.
Strategy: feed the picked wildcards' uuids into
``walk_transitive_refs`` with a fetch callback that returns the
embedded snapshot when available (so picked entries keep their
saved payload — drift detection still works) and falls back to
the live DB when a referenced uuid is NOT embedded.
Failure modes:
- DB not reachable / migrations not yet run → return embedded
catalog as-is. Resolver will emit "Unknown wildcard ref"
warnings for unresolved nested refs (existing lenient behavior).
- Referenced uuid not in DB → walker records ``missing_target``;
resolver still emits the warning at run time.
"""
if not catalog:
return catalog
try:
conn = get_connection()
migrate(conn)
repo = ModuleRepository(conn)
except Exception:
# No DB access from this graph run (e.g. embedded ComfyUI
# without the SPA stack) — return what we have.
return catalog
def _fetch(uuid: str) -> dict | None:
embedded = catalog.get(uuid)
if embedded is not None:
# Re-shape embedded SnapshotEntry → repo row shape so
# walk_transitive_refs's downstream logic sees a
# consistent dict.
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
walk = walk_transitive_refs(list(catalog.keys()), fetch_module=_fetch)
return walk.snapshots
class WPContext(io.ComfyNode):
"""Context node: runs an ordered list of modules, emits PipelineContext."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_Context",
display_name="WP Context",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input(
"upstream",
optional=True,
tooltip=(
"Optional upstream Context to extend. Variables pass "
"through; this node's modules add or override bindings "
"on top. Leave unconnected to start fresh."
),
),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xFFFFFFFFFFFFFFFF,
control_after_generate=True,
tooltip=(
"Starting point for randomisation. Change for a "
"different roll; keep the same to reproduce. "
"Right-click → Convert widget to input to drive from "
"another seed node."
),
),
ContextModulesInput.Input("wp_modules", socketless=True),
],
outputs=[PipelineContext.Output("context")],
not_idempotent=True,
)
@classmethod
def execute(cls, seed, wp_modules, upstream=None):
upstream_ctx: dict = upstream.context if upstream is not None else {}
upstream_debug: dict = upstream.debug if upstream is not None else {}
upstream_internals: dict = (
upstream.internals if upstream is not None else {}
)
# Unified-list model: one `modules` array holds every kind.
# The catalog for `@{}` ref resolution is the wildcard subset,
# synthesised on the fly. Then we expand that catalog with
# any transitive nested wildcards by hitting the live library
# — picker no longer auto-walks at pick time (issue #2).
module_list, catalog, _pick_order = deserialize_node_input(wp_modules)
catalog = _expand_catalog_via_live_db(catalog)
ctx: dict = dict(upstream_ctx)
# Inject catalog ONCE at the top of execute, before pipeline
# run. Spec §2.6 — never re-inject mid-run.
ctx["__wp_catalog__"] = catalog
# Merge cross-node internals from the upstream payload —
# `__wp_picks__` so a downstream constraint can read source
# picks recorded in an earlier Context, `__wp_constraints__`
# so a constraint registered upstream still applies to a
# downstream target. Copy in defensively so this node's
# mutations don't corrupt the upstream payload (frozen
# dataclass implies the dict references are still shared).
for key, value in upstream_internals.items():
if isinstance(value, list):
ctx[key] = list(value)
elif isinstance(value, dict):
ctx[key] = dict(value)
elif isinstance(value, set):
# Defensive copy of any set-valued cross-node internal so
# upstream payload's set isn't mutated by this node's run.
# (SP3 swapped the constraint bookkeeping from a set to
# the `__wp_constraint_hits__` dict — handled by the dict
# branch above — but keep this for any future set keys.)
ctx[key] = set(value)
else:
ctx[key] = value
# Compute the actual chain seed for THIS iteration. When no
# WP_ContextLoop is upstream, `upstream_internals` lacks both
# keys → helper returns `widget_seed` unchanged (backwards-compat).
# When ContextLoop is upstream with override_seed=true, the
# override replaces the widget seed; loop_index>0 XORs in a
# stable hash shift so each iteration walks a distinct seed.
chain_seed = effective_chain_seed(
widget_seed=int(seed),
seed_override=upstream_internals.get("__wp_seed_override__"),
loop_index=int(upstream_internals.get("__wp_loop_index__", 0)),
)
ctx = PipelineEngine().run(module_list, ctx=ctx, seed=chain_seed)
payload = build_payload(ctx, upstream_debug=upstream_debug, seed=chain_seed)
# Emit two seed-tracking values via the UI payload so the
# frontend `executed` listener (widgets/context.ts) gets
# authoritative state — works whether the seed was supplied
# by this node's local widget OR by an upstream node feeding
# the socket (the local widget value would be stale in that
# case, but the engine always sees the real value here).
#
# - `seed`: the chain seed actually used this run.
# - `module_seeds`: { module_id → effective_seed_used }
# for every module that ran. Effective = `locked_seed`
# when locked, chain seed otherwise. Wrapped in a list
# because ComfyUI's UI payload convention is value-as-array.
# Key by per-instance `_uid` when present, falling back to the
# library `id`. Without this, sibling rows (same library entry
# instantiated twice in one Context) collapsed into a single
# map entry — last sibling overwrote the others — and the
# lock-toggle defaulted every sibling's locked_seed to the
# last-executed sibling's roll, not its own. Pre-`_uid`
# entries still get keyed by `id`, matching the old behaviour
# for unmigrated workflows.
module_seeds: dict[str, int] = {}
for entry in ctx.get("__wp_trace__", []):
uid = entry.get("_uid")
mid = entry.get("id")
key = uid if isinstance(uid, str) and uid else mid
es = entry.get("seed")
if isinstance(key, str) and key and isinstance(es, int):
module_seeds[key] = es
return io.NodeOutput(
payload,
ui={"seed": [int(seed)], "module_seeds": [module_seeds]},
)