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>
This commit is contained in:
2026-06-21 23:27:05 +02:00
parent aa909448d7
commit ce371ffe13
2 changed files with 325 additions and 0 deletions
@@ -0,0 +1,88 @@
# Multi-Reroute (Rail) — Design
Date: 2026-06-21
Status: Approved (brainstorming complete, ready for implementation plan)
## 1. Purpose
A single node holding **N parallel pass-through lanes** (a "rail"), so you can run tidy
bundles of wires across the graph instead of dropping many separate Reroute nodes. Each lane
forwards any type; you grow/shrink the rail with +/ at either end.
Seventh node in the `ComfyUI-Datasete-Gates` suite.
## 2. Approach
A **real pass-through node** with **any-type lanes** (`AnyType("*")`). Lane `i`'s input is
forwarded to lane `i`'s output. An unconnected lane outputs an `ExecutionBlocker` so nothing
downstream of an unused lane runs. (Not the frontend-only virtual-reroute trick — simpler and
robust across all types; the trade-off is slots read as `*` instead of adapting to the wired
type.)
## 3. IO
- Up to `MAX_LANES` (32) lanes, each: optional input `in_<i>` (`*`) → output `out_<i>` (`*`).
- The node always returns a length-`MAX_LANES` tuple; the frontend shows only the active
lanes (default 4). Wired output indices are stable, so unshown trailing outputs are simply
unconnected.
```
RETURN_TYPES = (ANY,) * MAX_LANES RETURN_NAMES = ("out_1", …, "out_32")
INPUT_TYPES = {"optional": {"in_1": (ANY,), …}}
```
No `IS_CHANGED` override — a reroute should be transparent/cacheable (re-runs only when an
input value actually changes).
## 4. Run logic
```python
def run(self, **kwargs):
blocker = ExecutionBlocker(None)
return tuple(
kwargs.get(f"in_{i+1}") if kwargs.get(f"in_{i+1}") is not None else blocker
for i in range(MAX_LANES)
)
```
Lane-count-agnostic: connected lanes forward their value; empty lanes block. The visible lane
count is purely a frontend concern.
## 5. Frontend (`web/multi_reroute.js`)
- Render `lanes` lane rows (input + output pair), default 4; persist the count in a hidden
widget so reload restores the rail (the "use raw widgets_values to add slots before link
rewiring" pattern already used in this repo).
- **+/ buttons**:
- **Bottom add/remove** (Phase 1): reveal/hide the next/last lane pair — trivial and
wiring-safe (only the end moves).
- **Top add/remove** (Phase 2): insert/remove a lane at the top while **preserving the
other lanes' wiring** — requires capturing links and re-mapping slot indices
(rgthree-style). Kept separate so a bug here can't scramble existing rails.
- Lanes use the shared `AnyType` so any wire connects.
- (Phase 3 polish) compact reroute-pill look / optional per-lane labels.
## 6. Edge cases
- Empty lane → `ExecutionBlocker` (downstream skipped). A legitimate `None` value is treated
as empty (reroute values are objects/tensors, effectively never `None`).
- Removing a lane is from the **end** in Phase 1 (indices stay stable → links intact).
Mid/top removal is Phase 2 with remap.
- More than `MAX_LANES` requested → capped (logged in UI).
- Mixed types across lanes is fine — each lane is independent `*`.
## 7. Code shape
- `gates/anytype.py` *(new)* — shared `AnyType("*")` + `ANY` (textgate can dedupe onto this
later; not touched now).
- `gates/reroute_node.py` *(new)* — pure `build_outputs(values, max_lanes, blocker)` +
`MultiReroute` node (lazy `ExecutionBlocker` import for testability).
- `web/multi_reroute.js` *(new)* — dynamic lane slots + +/ buttons + persistence.
- root `__init__.py` — additive merge of the node mapping.
## 8. Testing
- pytest: `anytype` equals-everything; `build_outputs` forwards connected lanes and blocks
empty ones (length == MAX_LANES); node `RETURN_TYPES` length + all-`*`.
- Manual (live): add/remove lanes (bottom, then top), wire mixed types through, confirm values
pass and reload restores the rail; empty lanes don't trigger downstream.
@@ -0,0 +1,237 @@
# 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**
```python
# 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**
```python
# 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.py` — `build_outputs`
**Files:** Create `gates/reroute_node.py`; Test `tests/test_reroute.py`
**Step 1: Failing test**
```python
# 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**
```python
# 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.py` — `MultiReroute` node
**Files:** Modify `gates/reroute_node.py`, `tests/test_reroute.py`
**Step 1: Failing test**
```python
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)**
```python
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__:`:
```python
from .gates.reroute_node import NODE_CLASS_MAPPINGS as _RR_NODES, \
NODE_DISPLAY_NAME_MAPPINGS as _RR_NAMES
```
and merge into the final dicts:
```python
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.