From 32f616e067f44462bf08942af87ed36be40e7889 Mon Sep 17 00:00:00 2001 From: Ethan Fel Date: Sun, 21 Jun 2026 18:41:17 +0200 Subject: [PATCH] Add Text Gate (Manual Pass) design + implementation plan Blocking text gate: pauses, shows incoming text in an editable box, Pass emits the edited text. Optional any-type signal input + signal passthrough output for ordering. Reuses gate_bus via an additive string payload channel with a should_cancel hook so the Pass-only gate still honors global Cancel (processing_interrupted). TDD plan; comfy imports stay lazy for testability. Co-Authored-By: Claude Opus 4.8 --- docs/plans/2026-06-21-text-gate-design.md | 67 ++++ .../2026-06-21-text-gate-implementation.md | 310 ++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 docs/plans/2026-06-21-text-gate-design.md create mode 100644 docs/plans/2026-06-21-text-gate-implementation.md diff --git a/docs/plans/2026-06-21-text-gate-design.md b/docs/plans/2026-06-21-text-gate-design.md new file mode 100644 index 0000000..359b758 --- /dev/null +++ b/docs/plans/2026-06-21-text-gate-design.md @@ -0,0 +1,67 @@ +# Text Gate (Manual Pass) — Design + +Date: 2026-06-21 +Status: Approved (brainstorming complete, ready for implementation plan) + +## 1. Purpose + +A simple blocking gate for text: during a run it **pauses**, shows the incoming text in an +**editable** box, and waits for a **Pass** click; on pass it emits the (possibly edited) +text. An optional any-type **signal** input lets you force execution order, and a +**signal** passthrough output lets you chain gates in a fixed sequence. Fourth node in the +`ComfyUI-Datasete-Gates` suite; reuses the Image Gate's `gate_bus` blocking infra. + +## 2. IO + +| dir | name | type | notes | +|---|---|---|---| +| in | `text` | STRING (`forceInput`) | incoming text from upstream | +| in (optional) | `signal` | `*` (AnyType) | accepts anything; only used to sequence this node after its source | +| hidden | `unique_id` | UNIQUE_ID | keys the pause | +| out | `text` | STRING | the edited text passed by the user | +| out | `signal` | `*` (AnyType) | passthrough of the input signal (fires on pass) → chain ordering | + +## 3. Behavior (the pause) + +On execute: +1. `GateBus.arm(unique_id)`; push the incoming text to the UI + (`PromptServer.send_sync("datasete-textgate-show", {id, text})`). +2. Frontend shows an **editable textarea** prefilled with the text + a **Pass** button. +3. **Block** on `GateBus.wait_payload(unique_id, should_cancel=...)` until Pass. +4. **Pass** → frontend POSTs the edited text to `/datasete_text_gate/pass`; the node returns + `(edited_text, signal)`. + +`IS_CHANGED` returns `nan` → pauses on every run. + +**No Stop button**, but the wait loop honors ComfyUI's global Cancel via a `should_cancel` +callback (`comfy.model_management.processing_interrupted`) so a queue-cancel can't deadlock +the gate; on cancel it raises `InterruptProcessingException`. + +## 4. Reuse / changes to existing files (all additive) + +- `gates/gate_bus.py` — add a **payload channel**: `payloads` dict, `put_payload`, + `wait_payload(..., should_cancel=None)`; `arm()` also clears `payloads`. Existing + int-choice/mask API untouched (Image Gate keeps working). +- `gates/gate_server.py` — add `send_text()` + route `POST /datasete_text_gate/pass`. +- `gates/textgate.py` *(new)* — `AnyType("*")` + `ANY`; the `TextGate` node (lazy comfy + imports so it unit-tests without ComfyUI). +- `web/text_gate.js` *(new)* — listen for `datasete-textgate-show`, render editable textarea + + Pass, POST the edited text. +- root `__init__.py` — merge `TextGate` into the mappings (gate_server already imported). + +## 5. Edge cases + +- Signal not connected → `signal=None`; output `None` (downstream still ordered by the + dependency). +- `AnyType` output value `None` connects fine (the `__ne__`→False trick makes type checks + pass), matching the installed custom-node convention. +- Empty incoming text → empty textarea; Pass emits whatever's there (possibly `""`). +- Global queue-cancel while blocked → clean interrupt (see §3). + +## 6. Testing + +- pytest: `gate_bus` payload roundtrip + `arm` clears payloads + `wait_payload` cancel via + flag and via `should_cancel`; `AnyType` equals-everything; `TextGate` RETURN_TYPES/NAMES + and `IS_CHANGED==nan`. +- Manual (live): pause shows editable text, edit + Pass emits edited text; signal in forces + order; signal out chains to a second gate; global Cancel unblocks cleanly. diff --git a/docs/plans/2026-06-21-text-gate-implementation.md b/docs/plans/2026-06-21-text-gate-implementation.md new file mode 100644 index 0000000..d79fb3b --- /dev/null +++ b/docs/plans/2026-06-21-text-gate-implementation.md @@ -0,0 +1,310 @@ +# Text Gate (Manual Pass) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a ComfyUI node `Text Gate (Manual Pass)` that pauses a run, shows the incoming text in an editable box, and on a Pass click emits the (edited) text — plus an optional any-type `signal` input and a `signal` passthrough output for ordering. + +**Architecture:** Reuse the Image Gate's `gates/gate_bus.py` blocking infra, extended with a string **payload channel** (`put_payload`/`wait_payload`) plus a `should_cancel` hook so the Pass-only gate still honors ComfyUI's global Cancel. The node `gates/textgate.py` defines an `AnyType("*")` and keeps comfy imports lazy so it unit-tests without ComfyUI. `gates/gate_server.py` gains `send_text()` + a pass route; `web/text_gate.js` renders the editable textarea + Pass. + +**Tech Stack:** Python 3.12, aiohttp; pytest 9; vanilla JS frontend. (No torch needed for this node.) + +--- + +## Conventions (read once) + +- **Test python:** `/media/p5/miniforge3/bin/python` (`PY=...`). +- **Run tests:** `cd /media/p5/ComfyUI-Datasete-Gates && $PY -m pytest tests/test_gate_bus.py tests/test_textgate.py -v` +- Edits to `gate_bus.py` / `gate_server.py` / `__init__.py` are **additive** — re-Read each + first, keep the Image Gate working, and run the full suite after. +- `gate_bus.py` stays stdlib-only. `textgate.py` imports comfy lazily inside `run()`. +- Concurrency: other sessions may share this tree; stage only this node's paths per commit. +- Commit style: Conventional Commits + repo Co-Authored-By trailer. + +--- + +### Task 1: `gate_bus.py` — string payload channel + +**Files:** Modify `gates/gate_bus.py`, `tests/test_gate_bus.py` + +**Step 1: Failing test** + +```python +# add to tests/test_gate_bus.py +def test_payload_roundtrip(): + gb.GateBus.arm("p") + gb.GateBus.put_payload("p", "hello edited") + assert gb.GateBus.wait_payload("p") == "hello edited" + +def test_payload_consumed(): + gb.GateBus.arm("p") + gb.GateBus.put_payload("p", "x") + gb.GateBus.wait_payload("p") + assert "p" not in gb.GateBus.payloads + +def test_arm_clears_payload(): + gb.GateBus.put_payload("p", "stale") + gb.GateBus.arm("p") + assert "p" not in gb.GateBus.payloads + +def test_wait_payload_cancel_flag_raises(): + import pytest + gb.GateBus.arm("p") + gb.GateBus.cancelled = True + with pytest.raises(gb.GateCancelled): + gb.GateBus.wait_payload("p") +``` + +**Step 2: Run → FAIL.** `$PY -m pytest tests/test_gate_bus.py -v` + +**Step 3: Implement** — add a class attr and methods to `GateBus`, and clear payloads in `arm`: + +```python + payloads = {} # node_id(str) -> arbitrary payload (e.g., edited text) +``` + +In `arm`, add: + +```python + cls.payloads.pop(str(node_id), None) +``` + +New methods: + +```python + @classmethod + def put_payload(cls, node_id, value): + cls.payloads[str(node_id)] = value + + @classmethod + def wait_payload(cls, node_id, period=0.1, should_cancel=None): + sid = str(node_id) + while sid not in cls.payloads: + if cls.cancelled or (should_cancel is not None and should_cancel()): + cls.cancelled = False + raise GateCancelled() + time.sleep(period) + return cls.payloads.pop(sid) +``` + +**Step 4: Run → PASS** (and existing gate_bus tests still pass). + +**Step 5: Commit** `feat: gate_bus payload channel + should_cancel` + +--- + +### Task 2: `gate_bus.py` — `should_cancel` triggers cancel + +**Files:** Modify `tests/test_gate_bus.py` + +**Step 1: Failing test** + +```python +def test_wait_payload_should_cancel_raises(): + import pytest + gb.GateBus.arm("p") + with pytest.raises(gb.GateCancelled): + gb.GateBus.wait_payload("p", should_cancel=lambda: True) +``` + +**Step 2: Run → PASS immediately** (implemented in Task 1). If it fails, fix Task 1's loop. + +**Step 3:** (no code) — this task just locks the behavior with a test. + +**Step 4: Commit** `test: gate_bus wait_payload honors should_cancel` + +--- + +### Task 3: `textgate.py` — `AnyType` wildcard + +**Files:** Create `gates/textgate.py`; Test `tests/test_textgate.py` + +**Step 1: Failing test** + +```python +# tests/test_textgate.py +from gates import textgate + +def test_anytype_is_compatible_with_everything(): + assert (textgate.ANY != "IMAGE") is False + assert (textgate.ANY != "LATENT") is False + assert isinstance(textgate.ANY, str) +``` + +**Step 2: Run → FAIL.** + +**Step 3: Implement** + +```python +# gates/textgate.py +from . import gate_bus + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + + +class AnyType(str): + """Type that compares equal to any other type (ComfyUI wildcard convention).""" + def __ne__(self, other): + return False + + +ANY = AnyType("*") +``` + +**Step 4: Run → PASS.** **Step 5: Commit** `feat: textgate AnyType wildcard` + +--- + +### Task 4: `textgate.py` — `TextGate` node + +**Files:** Modify `gates/textgate.py`, `tests/test_textgate.py` + +**Step 0: Verify the global-cancel getter:** +`grep -n "def processing_interrupted\|def interrupt_current_processing\|class InterruptProcessingException" /media/p5/Comfyui/comfy/model_management.py` +Use the boolean getter that exists (expected `processing_interrupted`). + +**Step 1: Failing test** + +```python +import math + +def test_textgate_io_shape(): + assert textgate.TextGate.RETURN_NAMES == ("text", "signal") + assert textgate.TextGate.RETURN_TYPES[0] == "STRING" + assert textgate.TextGate.RETURN_TYPES[1] == textgate.ANY + +def test_textgate_is_changed_nan(): + v = textgate.TextGate.IS_CHANGED(text="hi", unique_id="1") + assert math.isnan(v) +``` + +**Step 2: Run → FAIL.** + +**Step 3: Implement (append)** + +```python +class TextGate: + CATEGORY = "Datasete Gates" + FUNCTION = "run" + RETURN_TYPES = ("STRING", ANY) + RETURN_NAMES = ("text", "signal") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"forceInput": True}), + }, + "optional": { + "signal": (ANY, {}), + }, + "hidden": {"unique_id": "UNIQUE_ID"}, + } + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def run(self, text, unique_id, signal=None): + from . import gate_server + import comfy.model_management as mm + + gate_bus.GateBus.arm(unique_id) + gate_server.send_text(unique_id, text) + try: + edited = gate_bus.GateBus.wait_payload( + unique_id, should_cancel=mm.processing_interrupted) # confirm symbol (Step 0) + except gate_bus.GateCancelled: + raise mm.InterruptProcessingException() + return (edited, signal) + + +NODE_CLASS_MAPPINGS = {"TextGate": TextGate} +NODE_DISPLAY_NAME_MAPPINGS = {"TextGate": "Text Gate (Manual Pass)"} +``` + +**Step 4: Run → PASS.** (`run()` covered by the live test, not unit tests.) + +**Step 5: Commit** `feat: TextGate node — pause, editable pass-through, signal passthrough` + +--- + +### Task 5: `gate_server.py` — text route + preview, and register (MERGE) + +**Files:** Modify `gates/gate_server.py`, `__init__.py` + +**Step 1: Re-Read `gates/gate_server.py`**, then append (additive — don't touch the image-gate routes): + +```python +def send_text(node_id, text): + PromptServer.instance.send_sync( + "datasete-textgate-show", {"id": str(node_id), "text": text or ""} + ) + + +@routes.post("/datasete_text_gate/pass") +async def _text_pass(request): + post = await request.post() + GateBus.put_payload(post.get("id"), post.get("text", "")) + return web.json_response({}) +``` + +**Step 2: Re-Read `__init__.py`** and merge `TextGate` into the mappings (gate_server is +already imported for the Image Gate, so the new route registers automatically): + +```python + from .gates.textgate import NODE_CLASS_MAPPINGS as _TEXT_NODES, \ + NODE_DISPLAY_NAME_MAPPINGS as _TEXT_NAMES + NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_TEXT_NODES} + NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_TEXT_NAMES} +``` + +**Step 3:** `$PY -c "import gates.textgate; print(gates.textgate.NODE_CLASS_MAPPINGS)"` → shows TextGate. + +**Step 4:** Full suite green: `$PY -m pytest tests/ -v` + +**Step 5: Commit** `feat: text gate server route + register TextGate` + +--- + +### Task 6: `web/text_gate.js` — editable pause UI + +**Files:** Create `web/text_gate.js` + +Implement `app.registerExtension` for `TextGate`: +- Listen for the `datasete-textgate-show` socket event (`api.addEventListener`); when it + fires for this node's id, render a DOM widget: an **editable `