From 34625d48498a07a383f3d1e83b6e51424c9bcf1f Mon Sep 17 00:00:00 2001 From: Yang Cheng <188540289+SiaoZeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:12:50 +0800 Subject: [PATCH 1/6] test(workflow): add set-mode command contract --- .../comfy_cli/command/test_workflow_slots.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/comfy_cli/command/test_workflow_slots.py b/tests/comfy_cli/command/test_workflow_slots.py index ad1b17f02..c6198e466 100644 --- a/tests/comfy_cli/command/test_workflow_slots.py +++ b/tests/comfy_cli/command/test_workflow_slots.py @@ -441,6 +441,79 @@ def test_set_slot_unknown_node(self, patched_graph, tmp_path, capsys): assert "not found" in env["error"]["message"].lower() + +# --------------------------------------------------------------------------- +# set-mode — structural workflow variants +# --------------------------------------------------------------------------- + + +class TestSetMode: + def test_set_mode_updates_top_level_node_in_place(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + + env = _run(["set-mode", str(path), "6=bypass"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + clip = next(n for n in on_disk["nodes"] if n["id"] == 6) + assert clip["mode"] == 4 + assert env["data"]["applied"] == ["6"] + + def test_set_mode_addresses_subgraph_interior_node(self, tmp_path, capsys): + workflow = { + "nodes": [{"id": 10, "type": "subgraph-definition"}], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "subgraph-definition", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20=mute"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + inner = on_disk["definitions"]["subgraphs"][0]["nodes"][0] + assert inner["mode"] == 2 + + def test_set_mode_supports_stdout_without_modifying_source(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + original_text = path.read_text() + + env = _run(["set-mode", str(path), "3=normal", "6=bypass", "--stdout"], capsys) + + assert env["ok"] is True + assert env["changed"] is False + assert path.read_text() == original_text + nodes = {n["id"]: n for n in env["data"]["workflow_json"]["nodes"]} + assert nodes[3]["mode"] == 0 + assert nodes[6]["mode"] == 4 + + def test_set_mode_rejects_invalid_mode_without_partial_write(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + original_text = path.read_text() + + env = _run(["set-mode", str(path), "3=bypass", "6=disabled"], capsys) + + assert env["ok"] is False + assert env["error"]["code"] == "workflow_mode_invalid" + assert path.read_text() == original_text + + def test_set_mode_rejects_unknown_node_without_partial_write(self, tmp_path, capsys): + path = _write_workflow(tmp_path, _direct_workflow()) + original_text = path.read_text() + + env = _run(["set-mode", str(path), "3=bypass", "999=mute"], capsys) + + assert env["ok"] is False + assert env["error"]["code"] == "workflow_mode_invalid" + assert path.read_text() == original_text + # --------------------------------------------------------------------------- # vary — direct mode # --------------------------------------------------------------------------- From 82fe5f29d8d12a00e26e23d3a707d0a50acd3f8b Mon Sep 17 00:00:00 2001 From: Yang Cheng <188540289+SiaoZeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:12:50 +0800 Subject: [PATCH 2/6] feat(workflow): add node mode overrides --- comfy_cli/command/workflow.py | 140 +++++++++++++++++- comfy_cli/discovery.py | 1 + comfy_cli/error_codes.py | 5 + comfy_cli/schemas/workflow.json | 8 +- comfy_cli/skills/comfy/SKILL.md | 9 +- .../comfy_cli/command/test_workflow_slots.py | 2 +- 6 files changed, 155 insertions(+), 10 deletions(-) diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index e21a263b6..a0f1907b9 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -1,11 +1,11 @@ """``comfy workflow`` — slot-based editing of ComfyUI frontend-format workflows. -Three primitives: +Four editing primitives: comfy workflow slots # what can I tweak? - comfy workflow set-slot ADDR=VALUE [...] # tweak one or more + comfy workflow set-slot ADDR=VALUE [...] # tweak widget values + comfy workflow set-mode NODE=MODE [...] # normal/mute/bypass nodes comfy workflow vary --slot ADDR='[v1,v2]' # produce N variants - Plus one read-only reader that needs no object_info at all: comfy workflow notes # what did the author write? @@ -322,6 +322,140 @@ def set_slot_cmd( renderer.emit(payload, command="workflow set-slot", changed=not stdout) +# --------------------------------------------------------------------------- +# set-mode +# --------------------------------------------------------------------------- + + +_NODE_MODES = {"normal": 0, "mute": 2, "bypass": 4} + + +def _node_with_id(nodes: Any, node_id: str) -> dict[str, Any] | None: + if not isinstance(nodes, list): + return None + return next( + (node for node in nodes if isinstance(node, dict) and str(node.get("id")) == node_id), + None, + ) + + +def _resolve_mode_node(workflow: dict[str, Any], address: str) -> dict[str, Any]: + parts = [part.strip() for part in address.split("/")] + if not parts or any(not part for part in parts): + raise ValueError(f"Invalid node address {address!r}; expected NODE_ID or INSTANCE_ID/INNER_ID") + + node = _node_with_id(workflow.get("nodes"), parts[0]) + if node is None: + raise ValueError(f"Node {parts[0]!r} not found") + + definitions = workflow.get("definitions") + subgraphs = definitions.get("subgraphs") if isinstance(definitions, dict) else None + for inner_id in parts[1:]: + node_type = node.get("type") + subgraph = ( + next( + (item for item in subgraphs if isinstance(item, dict) and item.get("id") == node_type), + None, + ) + if isinstance(subgraphs, list) + else None + ) + if subgraph is None: + raise ValueError(f"Node {parts[0]!r} is not a subgraph instance at {address!r}") + node = _node_with_id(subgraph.get("nodes"), inner_id) + if node is None: + raise ValueError(f"Node {inner_id!r} not found in {address!r}") + + return node + + +@app.command( + "set-mode", + help="Set one or more nodes to normal, mute, or bypass in place (or --stdout).", +) +@tracking.track_command("workflow") +def set_mode_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + overrides: Annotated[ + list[str], + typer.Argument( + metavar="NODE=MODE...", + help="NODE_ID=MODE or INSTANCE_ID/INNER_ID=MODE; MODE is normal, mute, or bypass.", + ), + ], + stdout: Annotated[ + bool, + typer.Option( + "--stdout/--in-place", + show_default=False, + help="Return the modified workflow instead of writing back to .", + ), + ] = False, +): + renderer = get_renderer() + p, workflow = _load_workflow_or_fail(renderer, file) + + parsed: dict[str, int] = {} + for raw in overrides: + if "=" not in raw: + renderer.error( + code="workflow_mode_invalid", + message=f"Expected `NODE=MODE`, got {raw!r}", + hint="MODE must be normal, mute, or bypass", + ) + raise typer.Exit(code=1) + address, _, raw_mode = raw.partition("=") + address = address.strip() + mode = raw_mode.strip().lower() + if mode not in _NODE_MODES: + renderer.error( + code="workflow_mode_invalid", + message=f"Unknown node mode {raw_mode.strip()!r} for {address!r}", + hint="MODE must be normal, mute, or bypass", + ) + raise typer.Exit(code=1) + parsed[address] = _NODE_MODES[mode] + + try: + resolved = [(address, _resolve_mode_node(workflow, address), mode) for address, mode in parsed.items()] + except ValueError as e: + renderer.error( + code="workflow_mode_invalid", + message=str(e), + hint="use NODE_ID or INSTANCE_ID/INNER_ID from the frontend workflow", + ) + raise typer.Exit(code=1) from e + + for _address, node, mode in resolved: + node["mode"] = mode + + if stdout and renderer.is_pretty(): + import sys + + sys.stdout.write(json.dumps(workflow, indent=2)) + sys.stdout.write("\n") + sys.stdout.flush() + return + + if not stdout: + atomic_write_text(p, json.dumps(workflow, indent=2)) + + payload: dict[str, Any] = { + "workflow": str(p), + "applied": list(parsed), + "warnings": [], + "wrote": None if stdout else str(p), + } + if stdout: + payload["out"] = "stdout" + payload["workflow_json"] = workflow + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] applied {len(parsed)} node mode(s) → [dim]{p}[/dim]") + for address in parsed: + rprint(f" [dim]·[/dim] {address}") + renderer.emit(payload, command="workflow set-mode", changed=not stdout) + + # --------------------------------------------------------------------------- # vary # --------------------------------------------------------------------------- diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index 82b3a693c..6ebefb367 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -75,6 +75,7 @@ # workflow editing "comfy workflow slots": "workflow", "comfy workflow set-slot": "workflow", + "comfy workflow set-mode": "workflow", "comfy workflow vary": "workflow", "comfy workflow notes": "workflow", # workflow cloud CRUD + fragment composition diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index 797e8a715..04f541bc9 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -512,6 +512,11 @@ class ErrorCode: "A slot override failed validation (bad shape, unknown address, etc.).", "see `details` — addresses follow `.`", ), + ErrorCode( + "workflow_mode_invalid", + "A workflow node-mode override failed validation.", + "use `NODE_ID=MODE` or `INSTANCE_ID/INNER_ID=MODE`; MODE is normal, mute, or bypass", + ), # --- workflow fragments / compose --------------------------------------- ErrorCode( "fragment_invalid", diff --git a/comfy_cli/schemas/workflow.json b/comfy_cli/schemas/workflow.json index 43d95a764..54f0e5671 100644 --- a/comfy_cli/schemas/workflow.json +++ b/comfy_cli/schemas/workflow.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "comfy workflow *", - "description": "Output shape for workflow editing commands (slots, set-slot, vary) and the read-only notes reader.", + "description": "Output shape for workflow editing commands (slots, set-slot, set-mode, vary) and the read-only notes reader.", "type": "object", "properties": { "workflow": { "type": "string" }, @@ -32,9 +32,9 @@ }, "applied": { "type": "array" }, "warnings": { "type": "array" }, - "wrote": { "type": ["string", "null"], "description": "file written, or null when nothing was written (set-slot --stdout)" }, - "out": { "type": "string", "description": "set-slot: where the result went — \"stdout\" when --stdout returned it instead of writing the file" }, - "workflow_json": { "type": "object", "description": "set-slot --stdout: the modified workflow itself (human mode prints it raw on stdout instead)" }, + "wrote": { "type": ["string", "null"], "description": "file written, or null when nothing was written (--stdout)" }, + "out": { "type": "string", "description": "where the result went — \"stdout\" when --stdout returned it instead of writing the file" }, + "workflow_json": { "type": "object", "description": "--stdout: the modified workflow itself (human mode prints it raw on stdout instead)" }, "variants": { "type": ["array", "null"], "description": "vary without --out-dir: the produced workflows (human mode prints them as NDJSON on stdout instead); null when they were written to --out-dir" }, "written": { "type": "array" }, "out_dir": { "type": ["string", "null"] }, diff --git a/comfy_cli/skills/comfy/SKILL.md b/comfy_cli/skills/comfy/SKILL.md index 115e7a627..81f68359d 100644 --- a/comfy_cli/skills/comfy/SKILL.md +++ b/comfy_cli/skills/comfy/SKILL.md @@ -778,8 +778,8 @@ fan-out outputs by array order** — read `outputs_by_item`. ## Edit workflows in place -`workflow slots`, `set-slot`, and `vary` work on any frontend-format -workflow JSON — not just templates. Get slot addresses first: +`workflow slots`, `set-slot`, `set-mode`, and `vary` work on any +frontend-format workflow JSON — not just templates. Get slot addresses first: ```bash # 1. Discover addressable slots — addresses are ., never titles @@ -791,6 +791,11 @@ comfy --json workflow slots path.json # 2. Set a single slot comfy workflow set-slot path.json 6.text="a cat" +# Toggle structural branches without paying the runtime cost of a zero-strength node +comfy workflow set-mode path.json 105=bypass +# subgraph interior: / +comfy workflow set-mode path.json 10/20=mute + # 3. Generate variations (slot lists are zipped — same length required) comfy --json workflow slots wf.json # discover addresses first comfy workflow vary wf.json \ diff --git a/tests/comfy_cli/command/test_workflow_slots.py b/tests/comfy_cli/command/test_workflow_slots.py index c6198e466..9e5499afe 100644 --- a/tests/comfy_cli/command/test_workflow_slots.py +++ b/tests/comfy_cli/command/test_workflow_slots.py @@ -441,7 +441,6 @@ def test_set_slot_unknown_node(self, patched_graph, tmp_path, capsys): assert "not found" in env["error"]["message"].lower() - # --------------------------------------------------------------------------- # set-mode — structural workflow variants # --------------------------------------------------------------------------- @@ -514,6 +513,7 @@ def test_set_mode_rejects_unknown_node_without_partial_write(self, tmp_path, cap assert env["error"]["code"] == "workflow_mode_invalid" assert path.read_text() == original_text + # --------------------------------------------------------------------------- # vary — direct mode # --------------------------------------------------------------------------- From aa66ebe37f7a535075691e42ca57b75280fa0113 Mon Sep 17 00:00:00 2001 From: Yang Cheng <188540289+SiaoZeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:12:50 +0800 Subject: [PATCH 3/6] test(workflow): cover isolated mode overrides --- .../comfy_cli/command/test_workflow_slots.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/comfy_cli/command/test_workflow_slots.py b/tests/comfy_cli/command/test_workflow_slots.py index 9e5499afe..ffb8343d5 100644 --- a/tests/comfy_cli/command/test_workflow_slots.py +++ b/tests/comfy_cli/command/test_workflow_slots.py @@ -480,6 +480,145 @@ def test_set_mode_addresses_subgraph_interior_node(self, tmp_path, capsys): inner = on_disk["definitions"]["subgraphs"][0]["nodes"][0] assert inner["mode"] == 2 + def test_set_mode_forks_shared_subgraph_definition(self, tmp_path, capsys): + workflow = { + "nodes": [ + {"id": 10, "type": "shared-definition"}, + {"id": 11, "type": "shared-definition"}, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "shared-definition", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20=bypass"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + instances = {node["id"]: node for node in on_disk["nodes"]} + assert instances[10]["type"] != instances[11]["type"] + definitions = { + definition["id"]: definition + for definition in on_disk["definitions"]["subgraphs"] + } + changed = definitions[instances[10]["type"]]["nodes"][0] + unchanged = definitions[instances[11]["type"]]["nodes"][0] + assert changed["mode"] == 4 + assert unchanged["mode"] == 0 + + def test_set_mode_forks_shared_definitions_at_every_depth(self, tmp_path, capsys): + workflow = { + "nodes": [ + {"id": 10, "type": "outer-definition"}, + {"id": 11, "type": "outer-definition"}, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "outer-definition", + "nodes": [{"id": 20, "type": "inner-definition"}], + }, + { + "id": "inner-definition", + "nodes": [{"id": 30, "type": "LoraLoader", "mode": 0}], + }, + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20/30=bypass"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + definitions = { + definition["id"]: definition + for definition in on_disk["definitions"]["subgraphs"] + } + top = {node["id"]: node for node in on_disk["nodes"]} + changed_outer = definitions[top[10]["type"]] + unchanged_outer = definitions[top[11]["type"]] + changed_inner_instance = changed_outer["nodes"][0] + unchanged_inner_instance = unchanged_outer["nodes"][0] + assert changed_inner_instance["type"] != unchanged_inner_instance["type"] + assert definitions[changed_inner_instance["type"]]["nodes"][0]["mode"] == 4 + assert definitions[unchanged_inner_instance["type"]]["nodes"][0]["mode"] == 0 + + def test_set_mode_supports_legacy_name_typed_subgraph(self, tmp_path, capsys): + workflow = { + "nodes": [{"id": 10, "type": "Legacy Subgraph"}], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "subgraph-uuid", + "name": "Legacy Subgraph", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + + env = _run(["set-mode", str(path), "10/20=mute"], capsys) + + assert env["ok"] is True + on_disk = json.loads(path.read_text()) + assert on_disk["definitions"]["subgraphs"][0]["nodes"][0]["mode"] == 2 + + def test_set_mode_keeps_shared_source_unchanged_after_late_failure( + self, tmp_path, capsys + ): + workflow = { + "nodes": [ + {"id": 10, "type": "shared-definition"}, + {"id": 11, "type": "shared-definition"}, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": "shared-definition", + "nodes": [{"id": 20, "type": "LoraLoader", "mode": 0}], + } + ] + }, + } + path = _write_workflow(tmp_path, workflow) + original_text = path.read_text() + + env = _run( + ["set-mode", str(path), "10/20=bypass", "999=mute"], capsys + ) + + assert env["ok"] is False + assert path.read_text() == original_text + + def test_set_mode_sanitizes_pretty_output(self, tmp_path, capsys): + workflow = { + "nodes": [{"id": "[blue]6[/blue]", "type": "CLIPTextEncode"}], + "links": [], + } + path = _write_workflow(tmp_path, workflow, "[red]workflow.json") + + captured, _stderr, result = _invoke( + ["set-mode", str(path), "[blue]6[/blue]=bypass"], + capsys, + _force_pretty_renderer, + ) + + assert result.exit_code == 0 + assert "[red]workflow.json" in captured + assert "[blue]6[/blue]" in captured + def test_set_mode_supports_stdout_without_modifying_source(self, tmp_path, capsys): path = _write_workflow(tmp_path, _direct_workflow()) original_text = path.read_text() From e03f208c8217b8c1cb23efe7747321d4b12e6417 Mon Sep 17 00:00:00 2001 From: Yang Cheng <188540289+SiaoZeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:12:50 +0800 Subject: [PATCH 4/6] fix(workflow): isolate subgraph mode overrides --- comfy_cli/command/workflow.py | 61 +++++-------------- .../comfy_cli/command/test_workflow_slots.py | 24 +++----- 2 files changed, 23 insertions(+), 62 deletions(-) diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index a0f1907b9..3dc2bf763 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -20,6 +20,7 @@ from __future__ import annotations +import copy import json import unicodedata from pathlib import Path @@ -330,45 +331,6 @@ def set_slot_cmd( _NODE_MODES = {"normal": 0, "mute": 2, "bypass": 4} -def _node_with_id(nodes: Any, node_id: str) -> dict[str, Any] | None: - if not isinstance(nodes, list): - return None - return next( - (node for node in nodes if isinstance(node, dict) and str(node.get("id")) == node_id), - None, - ) - - -def _resolve_mode_node(workflow: dict[str, Any], address: str) -> dict[str, Any]: - parts = [part.strip() for part in address.split("/")] - if not parts or any(not part for part in parts): - raise ValueError(f"Invalid node address {address!r}; expected NODE_ID or INSTANCE_ID/INNER_ID") - - node = _node_with_id(workflow.get("nodes"), parts[0]) - if node is None: - raise ValueError(f"Node {parts[0]!r} not found") - - definitions = workflow.get("definitions") - subgraphs = definitions.get("subgraphs") if isinstance(definitions, dict) else None - for inner_id in parts[1:]: - node_type = node.get("type") - subgraph = ( - next( - (item for item in subgraphs if isinstance(item, dict) and item.get("id") == node_type), - None, - ) - if isinstance(subgraphs, list) - else None - ) - if subgraph is None: - raise ValueError(f"Node {parts[0]!r} is not a subgraph instance at {address!r}") - node = _node_with_id(subgraph.get("nodes"), inner_id) - if node is None: - raise ValueError(f"Node {inner_id!r} not found in {address!r}") - - return node - - @app.command( "set-mode", help="Set one or more nodes to normal, mute, or bypass in place (or --stdout).", @@ -416,8 +378,17 @@ def set_mode_cmd( raise typer.Exit(code=1) parsed[address] = _NODE_MODES[mode] + new_workflow = copy.deepcopy(workflow) + resolved: list[tuple[str, dict[str, Any], int]] = [] try: - resolved = [(address, _resolve_mode_node(workflow, address), mode) for address, mode in parsed.items()] + from comfy_cli.cql.engine import _resolve_node_path, _subgraph_defs_by_id + + for address, mode in parsed.items(): + segments = [part.strip() for part in address.split("/")] + if not segments or any(not part for part in segments): + raise ValueError(f"invalid node address {address!r}; expected NODE_ID or INSTANCE_ID/INNER_ID") + node = _resolve_node_path(new_workflow, segments, _subgraph_defs_by_id(new_workflow)) + resolved.append((address, node, mode)) except ValueError as e: renderer.error( code="workflow_mode_invalid", @@ -432,13 +403,13 @@ def set_mode_cmd( if stdout and renderer.is_pretty(): import sys - sys.stdout.write(json.dumps(workflow, indent=2)) + sys.stdout.write(json.dumps(new_workflow, indent=2)) sys.stdout.write("\n") sys.stdout.flush() return if not stdout: - atomic_write_text(p, json.dumps(workflow, indent=2)) + atomic_write_text(p, json.dumps(new_workflow, indent=2)) payload: dict[str, Any] = { "workflow": str(p), @@ -448,11 +419,11 @@ def set_mode_cmd( } if stdout: payload["out"] = "stdout" - payload["workflow_json"] = workflow + payload["workflow_json"] = new_workflow if renderer.is_pretty(): - rprint(f"[bold green]✓[/bold green] applied {len(parsed)} node mode(s) → [dim]{p}[/dim]") + rprint(f"[bold green]✓[/bold green] applied {len(parsed)} node mode(s) → [dim]{sanitize_markup(p)}[/dim]") for address in parsed: - rprint(f" [dim]·[/dim] {address}") + rprint(f" [dim]·[/dim] {sanitize_markup(address)}") renderer.emit(payload, command="workflow set-mode", changed=not stdout) diff --git a/tests/comfy_cli/command/test_workflow_slots.py b/tests/comfy_cli/command/test_workflow_slots.py index ffb8343d5..037dbbffa 100644 --- a/tests/comfy_cli/command/test_workflow_slots.py +++ b/tests/comfy_cli/command/test_workflow_slots.py @@ -504,10 +504,7 @@ def test_set_mode_forks_shared_subgraph_definition(self, tmp_path, capsys): on_disk = json.loads(path.read_text()) instances = {node["id"]: node for node in on_disk["nodes"]} assert instances[10]["type"] != instances[11]["type"] - definitions = { - definition["id"]: definition - for definition in on_disk["definitions"]["subgraphs"] - } + definitions = {definition["id"]: definition for definition in on_disk["definitions"]["subgraphs"]} changed = definitions[instances[10]["type"]]["nodes"][0] unchanged = definitions[instances[11]["type"]]["nodes"][0] assert changed["mode"] == 4 @@ -539,10 +536,7 @@ def test_set_mode_forks_shared_definitions_at_every_depth(self, tmp_path, capsys assert env["ok"] is True on_disk = json.loads(path.read_text()) - definitions = { - definition["id"]: definition - for definition in on_disk["definitions"]["subgraphs"] - } + definitions = {definition["id"]: definition for definition in on_disk["definitions"]["subgraphs"]} top = {node["id"]: node for node in on_disk["nodes"]} changed_outer = definitions[top[10]["type"]] unchanged_outer = definitions[top[11]["type"]] @@ -574,9 +568,7 @@ def test_set_mode_supports_legacy_name_typed_subgraph(self, tmp_path, capsys): on_disk = json.loads(path.read_text()) assert on_disk["definitions"]["subgraphs"][0]["nodes"][0]["mode"] == 2 - def test_set_mode_keeps_shared_source_unchanged_after_late_failure( - self, tmp_path, capsys - ): + def test_set_mode_keeps_shared_source_unchanged_after_late_failure(self, tmp_path, capsys): workflow = { "nodes": [ {"id": 10, "type": "shared-definition"}, @@ -595,29 +587,27 @@ def test_set_mode_keeps_shared_source_unchanged_after_late_failure( path = _write_workflow(tmp_path, workflow) original_text = path.read_text() - env = _run( - ["set-mode", str(path), "10/20=bypass", "999=mute"], capsys - ) + env = _run(["set-mode", str(path), "10/20=bypass", "999=mute"], capsys) assert env["ok"] is False assert path.read_text() == original_text def test_set_mode_sanitizes_pretty_output(self, tmp_path, capsys): workflow = { - "nodes": [{"id": "[blue]6[/blue]", "type": "CLIPTextEncode"}], + "nodes": [{"id": "[blue]6", "type": "CLIPTextEncode"}], "links": [], } path = _write_workflow(tmp_path, workflow, "[red]workflow.json") captured, _stderr, result = _invoke( - ["set-mode", str(path), "[blue]6[/blue]=bypass"], + ["set-mode", str(path), "[blue]6=bypass"], capsys, _force_pretty_renderer, ) assert result.exit_code == 0 assert "[red]workflow.json" in captured - assert "[blue]6[/blue]" in captured + assert "[blue]6" in captured def test_set_mode_supports_stdout_without_modifying_source(self, tmp_path, capsys): path = _write_workflow(tmp_path, _direct_workflow()) From aae0976a2b37fdc744cb9c179821627dbf600e02 Mon Sep 17 00:00:00 2001 From: Yang Cheng <188540289+SiaoZeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:12:50 +0800 Subject: [PATCH 5/6] test(cql): cover legacy subgraph isolation --- tests/comfy_cli/cql/test_engine.py | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 11681520a..896680361 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -2265,6 +2265,85 @@ def test_single_instance_no_fork(self, graph: Graph): # The single def got the new value directly assert wf["definitions"]["subgraphs"][0]["nodes"][0]["widgets_values"][0] == "NEW" + def test_legacy_and_uuid_instances_share_one_definition(self, graph: Graph): + from comfy_cli.cql.engine import _apply_one_slot + + wf = { + "nodes": [ + {"id": 10, "type": "Sub"}, + {"id": 12, "type": "uuid-def-1"}, + ], + "definitions": { + "subgraphs": [ + { + "id": "uuid-def-1", + "name": "Sub", + "nodes": [ + { + "id": 9, + "type": "CLIPTextEncode", + "widgets_values": ["orig"], + } + ], + } + ] + }, + } + + _apply_one_slot(wf, "10/9.text", "legacy-only", graph) + + definitions = { + definition["id"]: definition + for definition in wf["definitions"]["subgraphs"] + } + instances = {node["id"]: node for node in wf["nodes"]} + assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][ + 0 + ] == "legacy-only" + assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][ + 0 + ] == "orig" + + def test_fork_keeps_second_legacy_instance_resolvable(self, graph: Graph): + from comfy_cli.cql.engine import _apply_one_slot + + wf = { + "nodes": [ + {"id": 10, "type": "Sub"}, + {"id": 12, "type": "Sub"}, + ], + "definitions": { + "subgraphs": [ + { + "id": "uuid-def-1", + "name": "Sub", + "nodes": [ + { + "id": 9, + "type": "CLIPTextEncode", + "widgets_values": ["orig"], + } + ], + } + ] + }, + } + + _apply_one_slot(wf, "10/9.text", "first", graph) + _apply_one_slot(wf, "12/9.text", "second", graph) + + definitions = { + definition["id"]: definition + for definition in wf["definitions"]["subgraphs"] + } + instances = {node["id"]: node for node in wf["nodes"]} + assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][ + 0 + ] == "first" + assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][ + 0 + ] == "second" + # =========================================================================== # TestExpandVariations From af19ae56ce2f44177a691878058e37e2c983e9a1 Mon Sep 17 00:00:00 2001 From: Yang Cheng <188540289+SiaoZeng@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:12:50 +0800 Subject: [PATCH 6/6] fix(cql): isolate legacy subgraph aliases --- comfy_cli/cql/engine.py | 57 +++++++++++++++++++----------- tests/comfy_cli/cql/test_engine.py | 26 ++++---------- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 212bcaa72..051542c09 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -2348,33 +2348,48 @@ def _resolve_node_path(workflow: dict, segments: list[str], defs_by_id: dict[str return node -def _count_instances(workflow: dict, def_id: str) -> int: - """Count nodes (top-level + interior-of-definitions) instantiating ``def_id``.""" - count = 0 - for n in workflow.get("nodes") or []: - if isinstance(n, dict) and str(n.get("type", "")) == def_id: - count += 1 - for sg in (workflow.get("definitions") or {}).get("subgraphs") or []: - if isinstance(sg, dict): - for n in sg.get("nodes") or []: - if isinstance(n, dict) and str(n.get("type", "")) == def_id: - count += 1 - return count +def _iter_workflow_nodes(workflow: dict): + for node in workflow.get("nodes") or []: + if isinstance(node, dict): + yield node + for definition in (workflow.get("definitions") or {}).get("subgraphs") or []: + if not isinstance(definition, dict): + continue + for node in definition.get("nodes") or []: + if isinstance(node, dict): + yield node + + +def _count_instances(workflow: dict, definition: dict, defs_by_id: dict[str, dict]) -> int: + """Count every raw type alias that resolves to ``definition``.""" + return sum(defs_by_id.get(str(node.get("type", ""))) is definition for node in _iter_workflow_nodes(workflow)) def _isolate_shared_subgraph(workflow: dict, instance: dict, defs_by_id: dict[str, dict]) -> None: - """If ``instance``'s subgraph definition is shared with another instance, - deep-copy it under a fresh id and repoint ``instance`` so an interior write - can't alias sibling instances. No-op when the instance already owns its def. + """Fork a shared definition and leave every sibling on the original. + + Legacy saves may type an instance by a unique definition name rather than + its UUID. Resolve sharing by definition identity, then canonicalize every + alias to the original id before adding the fork; otherwise the duplicate + name makes the untouched legacy sibling unresolvable. """ - def_id = str(instance.get("type", "")) - sg = defs_by_id.get(def_id) - if sg is None or _count_instances(workflow, def_id) <= 1: + definition = defs_by_id.get(str(instance.get("type", ""))) + if definition is None or _count_instances(workflow, definition, defs_by_id) <= 1: return - new_sg = copy.deepcopy(sg) + + original_id = definition.get("id") + if not isinstance(original_id, str) or not original_id: + original_id = str(_uuid.uuid4()) + definition["id"] = original_id + + for node in _iter_workflow_nodes(workflow): + if defs_by_id.get(str(node.get("type", ""))) is definition: + node["type"] = original_id + + new_definition = copy.deepcopy(definition) new_id = str(_uuid.uuid4()) - new_sg["id"] = new_id - workflow.setdefault("definitions", {}).setdefault("subgraphs", []).append(new_sg) + new_definition["id"] = new_id + workflow.setdefault("definitions", {}).setdefault("subgraphs", []).append(new_definition) instance["type"] = new_id diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 896680361..794be974b 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -2292,17 +2292,10 @@ def test_legacy_and_uuid_instances_share_one_definition(self, graph: Graph): _apply_one_slot(wf, "10/9.text", "legacy-only", graph) - definitions = { - definition["id"]: definition - for definition in wf["definitions"]["subgraphs"] - } + definitions = {definition["id"]: definition for definition in wf["definitions"]["subgraphs"]} instances = {node["id"]: node for node in wf["nodes"]} - assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][ - 0 - ] == "legacy-only" - assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][ - 0 - ] == "orig" + assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][0] == "legacy-only" + assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][0] == "orig" def test_fork_keeps_second_legacy_instance_resolvable(self, graph: Graph): from comfy_cli.cql.engine import _apply_one_slot @@ -2332,17 +2325,10 @@ def test_fork_keeps_second_legacy_instance_resolvable(self, graph: Graph): _apply_one_slot(wf, "10/9.text", "first", graph) _apply_one_slot(wf, "12/9.text", "second", graph) - definitions = { - definition["id"]: definition - for definition in wf["definitions"]["subgraphs"] - } + definitions = {definition["id"]: definition for definition in wf["definitions"]["subgraphs"]} instances = {node["id"]: node for node in wf["nodes"]} - assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][ - 0 - ] == "first" - assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][ - 0 - ] == "second" + assert definitions[instances[10]["type"]]["nodes"][0]["widgets_values"][0] == "first" + assert definitions[instances[12]["type"]]["nodes"][0]["widgets_values"][0] == "second" # ===========================================================================