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.
200 lines
8.7 KiB
Python
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]},
|
|
)
|