Files
ComfyUI-Dataset-Gates/docs/plans/2026-06-21-multi-reroute-implementation.md
Ethanfel ce371ffe13 Add Multi-Reroute (Rail) design + implementation plan
Multi-lane any-type pass-through node ("rail"): in_i -> out_i, empty lane
-> ExecutionBlocker; +/- to add/remove lanes (bottom in P1, top with
wiring-preserving remap in P2). Pure build_outputs + shared AnyType; lazy
comfy import keeps it unit-testable. No IS_CHANGED (transparent passthrough).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:27:05 +02:00

7.8 KiB
Raw Permalink Blame History

Multi-Reroute (Rail) Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: A MultiReroute node — N parallel any-type pass-through lanes ("rail") with +/ to add/remove lanes — so wire bundles stay tidy without many separate Reroute nodes.

Architecture: A real pass-through node with AnyType("*") lanes: in_i → out_i, empty lane → ExecutionBlocker. Pure build_outputs is unit-tested; ExecutionBlocker is imported lazily. The frontend manages dynamic lane slots and persists the lane count. Bottom add/remove is Phase 1 (wiring-safe); top add/remove (link-preserving) is Phase 2.

Tech Stack: Python 3.12 (stdlib), pytest 9; vanilla JS frontend.


Conventions (read once)

  • Test python: /media/p5/miniforge3/bin/python (PY=...).
  • Run tests: cd /media/p5/ComfyUI-Datasete-Gates && $PY -m pytest tests/test_anytype.py tests/test_reroute.py -v
  • gates/anytype.py and gates/reroute_node.py import-safe without comfy (lazy ExecutionBlocker inside run).
  • __init__.py edit is additive — re-Read first, extend the mappings.
  • MAX_LANES = 32, default visible lanes = 4.
  • Commit style: Conventional Commits + repo Co-Authored-By; stage only this node's paths.

Task 1: anytype.py — shared wildcard

Files: Create gates/anytype.py; Test tests/test_anytype.py

Step 1: Failing test

# tests/test_anytype.py
from gates import anytype

def test_any_equals_everything():
    assert (anytype.ANY != "IMAGE") is False
    assert (anytype.ANY != "LATENT") is False
    assert isinstance(anytype.ANY, str)

Step 2: Run → FAIL.

Step 3: Implement

# gates/anytype.py
"""Shared ComfyUI wildcard type."""


class AnyType(str):
    def __ne__(self, other):
        return False


ANY = AnyType("*")

Step 4: Run → PASS. Step 5: Commit feat: shared AnyType wildcard


Task 2: reroute_node.pybuild_outputs

Files: Create gates/reroute_node.py; Test tests/test_reroute.py

Step 1: Failing test

# tests/test_reroute.py
from gates import reroute_node as rr

def test_build_outputs_forwards_and_blocks():
    B = object()  # blocker sentinel
    vals = {"in_1": "A", "in_3": "C"}
    out = rr.build_outputs(vals, max_lanes=4, blocker=B)
    assert out == ("A", B, "C", B)

def test_build_outputs_length():
    B = object()
    assert len(rr.build_outputs({}, max_lanes=rr.MAX_LANES, blocker=B)) == rr.MAX_LANES

Step 2: Run → FAIL.

Step 3: Implement

# gates/reroute_node.py
from .anytype import ANY

NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}

MAX_LANES = 32


def build_outputs(values, max_lanes, blocker):
    out = []
    for i in range(max_lanes):
        v = values.get(f"in_{i + 1}")
        out.append(v if v is not None else blocker)
    return tuple(out)

Step 4: Run → PASS. Step 5: Commit feat: multi-reroute build_outputs


Task 3: reroute_node.pyMultiReroute node

Files: Modify gates/reroute_node.py, tests/test_reroute.py

Step 1: Failing test

def test_io_shape():
    assert len(rr.MultiReroute.RETURN_TYPES) == rr.MAX_LANES
    assert all(t == "*" for t in rr.MultiReroute.RETURN_TYPES)
    assert rr.MultiReroute.RETURN_NAMES[0] == "out_1"
    it = rr.MultiReroute.INPUT_TYPES()
    assert "in_1" in it["optional"] and f"in_{rr.MAX_LANES}" in it["optional"]

Step 2: Run → FAIL.

Step 3: Implement (append)

class MultiReroute:
    CATEGORY = "Datasete Gates"
    FUNCTION = "run"
    RETURN_TYPES = (ANY,) * MAX_LANES
    RETURN_NAMES = tuple(f"out_{i + 1}" for i in range(MAX_LANES))

    @classmethod
    def INPUT_TYPES(cls):
        return {"optional": {f"in_{i + 1}": (ANY,) for i in range(MAX_LANES)}}

    def run(self, **kwargs):
        from comfy_execution.graph_utils import ExecutionBlocker
        return build_outputs(kwargs, MAX_LANES, ExecutionBlocker(None))


NODE_CLASS_MAPPINGS = {"MultiReroute": MultiReroute}
NODE_DISPLAY_NAME_MAPPINGS = {"MultiReroute": "Multi Reroute (Rail)"}

RETURN_TYPES entries are the AnyType instance (== "*"), so the test's t == "*" holds. No IS_CHANGED (transparent/cacheable passthrough).

Step 4: Run → PASS. Step 5: Commit feat: MultiReroute node (any-type pass-through lanes)


Task 4: Register in __init__.py (MERGE)

Files: Modify __init__.py

Step 1: Re-Read __init__.py; add inside if __package__::

    from .gates.reroute_node import NODE_CLASS_MAPPINGS as _RR_NODES, \
        NODE_DISPLAY_NAME_MAPPINGS as _RR_NAMES

and merge into the final dicts:

    NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_RR_NODES}
    NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_RR_NAMES}

Step 2: $PY -c "import gates.reroute_node; print(gates.reroute_node.NODE_CLASS_MAPPINGS)".

Step 3: Full suite green: $PY -m pytest tests/ -v.

Step 4: Commit feat: register MultiReroute


Task 5: web/multi_reroute.js — dynamic lanes + bottom +/ (Phase 1)

Files: Create web/multi_reroute.js

app.registerExtension for MultiReroute:

  • On nodeCreated: ensure the node shows lanes lane pairs (default 4). Keep a hidden lanes widget (count) so the rail count persists across save/reload — restore by re-adding that many slot pairs (mirror the repo's existing dynamic-slot restore that reads raw widgets_values before link rewiring).
  • Maintain exactly lanes visible input slots in_1..in_lanes and output slots out_1..out_lanes (add/remove the trailing pair as the count changes).
  • Bottom +: lanes++ → add in_{n}/out_{n}. Bottom : lanes-- → remove the last pair (only the end moves, so existing wiring is untouched). Clamp 1..MAX_LANES.
  • Buttons via on-node widgets (or the node context menu) labeled + lane / lane.
  • Slots are any-type (*) so any wire connects.

Manual verify: add/remove lanes from the bottom; wire IMAGE through lane 1 and LATENT through lane 2; values pass; save+reload restores the lane count and wiring.

Commit feat: multi-reroute frontend — dynamic lanes + bottom add/remove


Task 6: web/multi_reroute.js — top +/ with wiring preservation (Phase 2)

Files: Modify web/multi_reroute.js

  • Top +: insert a new lane at the top. Because slots are positional and links bind to slot index, capture all current input/output links, add a pair, and re-map links so the existing lanes keep their connections and the new empty lane is visually first.
  • Top : remove the top lane, remap the rest up.
  • Implement against a small lane-model (list of logical lanes ↔ slot indices); rebuild slots and reconnect from the model so a failure can't silently drop wires.

Manual verify: with lanes 13 wired, Top + adds an empty lane at top and 13 stay wired; Top removes it cleanly. Save/reload still consistent.

Commit feat: multi-reroute top add/remove (wiring-preserving)


Task 7: Live smoke test in ComfyUI

Restart ComfyUI. Verify:

  • "Multi Reroute (Rail)" appears under "Datasete Gates" with 4 lanes.
  • Bottom +/ add/remove lanes; Top +/ (Phase 2) keep existing wiring.
  • Route mixed types (IMAGE, MASK, LATENT, STRING) through separate lanes → all pass intact.
  • An unconnected lane doesn't trigger its downstream (ExecutionBlocker).
  • Save + reload restores lane count and all connections.

Commit (if fixes) fix: multi-reroute live-test adjustments


Definition of done

  • $PY -m pytest tests/test_anytype.py tests/test_reroute.py -v green; full tests/ green.
  • Manual checklist passes: lanes add/remove (bottom; top in P2), mixed-type pass-through, empty-lane blocking, persistence.