Skip to content
Open
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ history.

### Added

- `comfy workflow slots` rows for a subgraph's promoted widgets carry
`source_address`, the interior node and widget the row stands for
(`57/28.unet_name`), resolved through nested promotions and renamed inputs.
The key is optional: a row omits it when its interior widget does not
resolve, when its value comes from an interior PrimitiveNode, or when the
input is a plain legacy `proxyWidgets` fallback (a legacy entry that will be
repaired into a linked input does carry it).
The row's `address` is unchanged and stays the one to edit.
- `comfy run --output-node-ids 9,12` (repeatable) runs only the named output
nodes and the nodes they depend on, locally and with `--where cloud` (sent to
`/prompt` as `partial_execution_targets`). Ids that are not in the workflow,
Expand Down
145 changes: 128 additions & 17 deletions comfy_cli/cql/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -5266,6 +5266,12 @@ def _declared_subgraph_slots(
}
if linked_from is not None:
slot["linked_from"] = linked_from
if pi is not None and pi.is_widget:
source_address = _promoted_source_address(
instance_id, sg, pi, defs, graph, instance, scope if scope is not None else workflow
)
if source_address is not None:
slot["source_address"] = source_address
declared.append(slot)
if workflow is not None:
# Legacy ``proxyWidgets`` promotions the definition does not back
Expand All @@ -5283,38 +5289,143 @@ def _declared_subgraph_slots(
if current is _promoted.UNSET:
all_resolved = False
continue
declared.append(
{
"address": f"{instance_id}.{entry.name}",
"name": entry.name,
"type": str(entry.type),
"current_value": current,
"instance_id": instance_id,
"node_type": instance.get("type", ""),
}
)
slot = {
"address": f"{instance_id}.{entry.name}",
"name": entry.name,
"type": str(entry.type),
"current_value": current,
"instance_id": instance_id,
"node_type": instance.get("type", ""),
}
# A primitive bypass's value lives on an interior PrimitiveNode
# that may feed several widgets; source_address names one concrete
# widget, so these rows omit it (documented in SKILL.md).
if entry.plan == _promoted.PLAN_CREATE:
Comment thread
mattmillerai marked this conversation as resolved.
Resolved
source_address = _promoted_source_address(
instance_id,
sg,
entry.as_promoted_input(sg),
defs,
graph,
instance,
scope if scope is not None else workflow,
)
if source_address is not None:
slot["source_address"] = source_address
declared.append(slot)
return declared, (any_declared and all_resolved)


def promoted_source_port(sg: dict, pi: Any, defs: dict[str, dict], graph: Graph) -> tuple[str, Port | None]:
"""``(interior class, Port)`` of the concrete widget behind promoted input
``pi`` — the schema a host value is validated against."""
def _promoted_source_address(
instance_id: str,
sg: dict,
pi: Any,
defs: dict[str, dict],
graph: Graph,
instance: dict | None = None,
scope: dict | None = None,
) -> str | None:
"""``<instance>/<interior>[/<deeper>…].<widget>`` — the concrete interior
widget a promoted row stands for, through any nested promotion chain.
Informational only: the row is still edited at ``<instance>.<name>``.
``None`` when the chain does not resolve to a live interior widget: a
missing interior node, or a widget the catalog says that class lacks (a
class the catalog does not know is trusted as saved). A dynamic-combo
sub-widget is checked against the option execution selects — a promoted
selector's host value (``instance`` in ``scope``) over the interior one."""
from comfy_cli.cql import promoted as _promoted

target = _promoted.deepest_source(sg, pi, defs)
Comment thread
mattmillerai marked this conversation as resolved.
Resolved
Comment thread
mattmillerai marked this conversation as resolved.
Resolved
if target is None:
return "", None
return None
path, widget = target
class_type, port = promoted_source_port(sg, pi, defs, graph)
if port is None:
if not class_type:
return None
m = graph.node(class_type)
if m is not None:
# A dynamic-combo sub-widget (``model.size_preset``) is not a
# top-level port; its selector is.
head = widget.split(".", 1)[0]
if head == widget or not any(p.name == head and p.is_dynamic_combo for p in m.inputs):
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# …and only the SELECTED option's sub-widgets exist on the node.
node = _promoted_source_node(sg, path, defs)
values = _widgets_as_positional(node.get("widgets_values") if node else None, graph, class_type)
selected = _promoted_selector_value(instance, scope, sg, path, head, defs)
order = graph.widget_order_for_node(class_type, values)
if selected is not _promoted.UNSET and head in order and order.index(head) < len(values):
values = list(values)
values[order.index(head)] = selected
order = graph.widget_order_for_node(class_type, values)
if widget not in order:
return None
return f"{_SUBGRAPH_PATH_SEP.join([instance_id, *path])}.{widget}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


def _promoted_selector_value(
instance: dict | None, scope: dict | None, sg: dict, path: list[str], selector: str, defs: dict[str, dict]
) -> Any:
"""The host value execution writes over the interior dynamic-combo
``selector`` at the end of ``path`` when that selector is itself promoted
— outermost host first, as the converter's overlay lets an outer host win.
:data:`promoted.UNSET` when no host materializes it, or when a live link
feeds it (its value is only known at run time), so the caller keeps the
interior's saved selection."""
from comfy_cli.cql import promoted as _promoted

current_instance, current_scope, current_sg = instance, scope, sg
for depth in range(len(path)):
if current_instance is None or current_scope is None:
break
remaining = path[depth:]
for spi in _promoted.promoted_inputs(current_sg, defs):
if not spi.is_widget or _promoted.deepest_source(current_sg, spi, defs) != (remaining, selector):
continue
if _promoted.live_external_link(current_scope, current_instance, spi.name, current_sg) is not None:
return _promoted.UNSET
value = _promoted.host_value(current_instance, spi)
if value is not _promoted.UNSET:
return value
inner = _promoted._inner_node(current_sg, remaining[0])
inner_def = defs.get(str(inner.get("type", ""))) if inner is not None else None
if inner_def is None:
break
current_instance, current_scope, current_sg = inner, current_sg, inner_def
return _promoted.UNSET


def _promoted_source_node(sg: dict, path: list[str], defs: dict[str, dict]) -> dict | None:
"""The terminal interior node at the end of a promoted source ``path``,
descending into nested subgraph definitions; ``None`` if any hop is missing."""
from comfy_cli.cql import promoted as _promoted

current_sg = sg
node: dict | None = None
for seg in path:
node = next((n for n in current_sg.get("nodes") or [] if str(n.get("id")) == seg), None)
node = _promoted._inner_node(current_sg, seg)
if node is None:
return "", None
return None
nxt = defs.get(str(node.get("type", "")))
if nxt is not None:
current_sg = nxt
class_type = str(node.get("type", "")) if node else ""
return node


def promoted_source_port(sg: dict, pi: Any, defs: dict[str, dict], graph: Graph) -> tuple[str, Port | None]:
"""``(interior class, Port)`` of the concrete widget behind promoted input
``pi`` — the schema a host value is validated against."""
from comfy_cli.cql import promoted as _promoted

target = _promoted.deepest_source(sg, pi, defs)
if target is None:
return "", None
path, widget = target
node = _promoted_source_node(sg, path, defs)
if node is None:
return "", None
class_type = str(node.get("type", ""))
m = graph.node(class_type)
if m is None:
return class_type, None
Expand Down
26 changes: 13 additions & 13 deletions comfy_cli/cql/promoted.py
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,7 @@ def source_value(
return UNSET
stack = _stack | {id(sg)}
defs = defs if defs is not None else defs_by_id(workflow)
inner = next((n for n in sg.get("nodes") or [] if str(n.get("id")) == pi.source_node), None)
inner = _inner_node(sg, pi.source_node)
if inner is None:
return UNSET
if pi.nested:
Expand Down Expand Up @@ -958,15 +958,7 @@ def deepest_source(sg: dict, pi: PromotedInput, defs: dict[str, dict]) -> tuple[
path.append(current.source_node)
if not current.nested:
return path, str(current.source_widget)
nodes = current_sg.get("nodes")
inner = next(
(
n
for n in (nodes if isinstance(nodes, list) else [])
if isinstance(n, dict) and str(n.get("id")) == current.source_node
),
None,
)
inner = _inner_node(current_sg, current.source_node)
inner_def = defs.get(str(inner.get("type", ""))) if inner is not None else None
if inner_def is None or current.source_input is None:
return None
Expand Down Expand Up @@ -1138,7 +1130,7 @@ def resolve_write(
)
if link is not None:
origin = link.get("origin_id")
origin_node = next((n for n in parent_def.get("nodes") or [] if str(n.get("id")) == str(origin)), None)
origin_node = _inner_node(parent_def, origin)
raise ValueError(
f"{given} is fed by interior node {'/'.join(segments[:-1])}/{origin} "
f"({origin_node.get('type') if origin_node else '?'}) — that link supplies the value, so a widget "
Expand Down Expand Up @@ -1388,7 +1380,7 @@ def as_promoted_input(self, sg: dict) -> PromotedInput:
validation/source-port lookup a linked promotion gets applies."""
if self.plan == PLAN_PRIMITIVE:
tid, slot = self.targets[0]
target = next((n for n in sg.get("nodes") or [] if str(n.get("id")) == tid), None)
target = _inner_node(sg, tid)
entry = (target.get("inputs") or [])[slot] if target is not None else None
marker = entry.get("widget") if isinstance(entry, dict) else None
widget = marker.get("name") if isinstance(marker, dict) and marker.get("name") else entry.get("name")
Expand Down Expand Up @@ -1437,7 +1429,15 @@ def _proxy_tuples(instance: dict) -> list[list]:


def _inner_node(sg: dict, node_id: Any) -> dict | None:
return next((n for n in sg.get("nodes") or [] if isinstance(n, dict) and str(n.get("id")) == str(node_id)), None)
nodes = sg.get("nodes")
return next(
(
n
for n in (nodes if isinstance(nodes, list) else [])
if isinstance(n, dict) and str(n.get("id")) == str(node_id)
),
None,
)


def _node_widget_names(node: dict, graph) -> list[str]:
Expand Down
8 changes: 8 additions & 0 deletions comfy_cli/skills/comfy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,14 @@ comfy --json workflow slots path.json
# --node <id> lists one node's slots, subgraph interior included (a
# template id like 94 works on an inserted canvas; that node's own rows
# carry template_node_id)
# a promoted subgraph widget row (<instance_id>.<name>) carries
Comment thread
mattmillerai marked this conversation as resolved.
Resolved
# source_address when its interior widget resolves — the interior widget
# it stands for, e.g. 57/28.unet_name (the real widget name, even when
# the promotion is renamed). Optional: a row omits it when its source
# does not resolve, when the value comes from an interior PrimitiveNode,
# or when the input is a plain legacy proxyWidgets fallback (a legacy
# entry the CLI will repair into a linked input does carry it). Read-only
# context: edit at the row's own address

# 2. Set a single slot
comfy workflow set-slot path.json 6.text="a cat"
Expand Down
Loading
Loading