Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion comfy_cli/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,7 @@ def validate(
suggestions = e.get("suggestions", [])
if suggestions:
msg += f" (did you mean: {', '.join(suggestions[:3])}?)"
rprint(f" [red]•[/red] node {e.get('node_id', '?')}: {msg}")
rprint(f" [red]•[/red] node {e.get('node_id') or '?'}: {msg}")
for w in result["warnings"]:
rprint(f" [yellow]⚠[/yellow] {w.get('message', '')}")
renderer.emit(payload, command="validate", ok=result["valid"])
Expand Down
2 changes: 1 addition & 1 deletion comfy_cli/command/run/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def _preflight_validate(renderer, workflow: dict, object_info: dict, *, target_l
errors = validation.get("errors", [])
hint_parts = []
for e in errors[:5]:
line = f"node {e.get('node_id', '?')}: {e.get('message', '')}"
line = f"node {e.get('node_id') or '?'}: {e.get('message', '')}"
suggestions = e.get("suggestions") or []
if suggestions:
line += f" (did you mean: {', '.join(suggestions)}?)"
Expand Down
95 changes: 93 additions & 2 deletions comfy_cli/cql/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,10 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]:
errors: list[dict] = []
warnings: list[dict] = []
all_names = list(self._nodes.keys())
# No-outputs check: the server rejects any prompt with zero output nodes
# (execution.py:1155-1162, prompt_no_outputs). Track whether any
# recognized node is an output node.
has_output_node = False

for node_id, node_data in workflow.items():
# `_meta` is the compose/run provenance block (schema/blueprint/items),
Expand Down Expand Up @@ -744,6 +748,9 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]:
)
continue

if m.is_output_node:
has_output_node = True

port_by_name = {p.name: p for p in m.inputs}
# V3 autogrow inputs are declared once (e.g. `images`) but wired as
# slot keys (`images.image0`, `images.image1`, …). Track which
Expand Down Expand Up @@ -867,6 +874,28 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]:
warnings.extend(cat_warnings)

errors.extend(_check_autogrow_required(node_id, autogrow_ports, autogrow_seen, node_data))
errors.extend(_check_required_present(node_id, m, node_data))
Comment thread
mattmillerai marked this conversation as resolved.

# No-outputs check: the server rejects any prompt with zero output
# nodes (execution.py:1155-1162, prompt_no_outputs) — including an
# empty/node-less prompt. Suppress it only when an unknown_class_type
# error is present: that node could be the real (custom) output node we
# just can't see, so the missing-output message would be misleading
# noise on top of the unknown-class error the user must resolve first.
has_unknown_class = any(e.get("code") == "unknown_class_type" for e in errors)
if not has_output_node and not has_unknown_class:
errors.append(
{
# workflow-level error: no owning node, hence None (keeps the
# node_id/field keys every other error carries, for schema
# consistency).
"node_id": None,
"field": None,
"code": "prompt_no_outputs",
Comment thread
mattmillerai marked this conversation as resolved.
"message": "workflow has no output nodes — the server will reject it (prompt_no_outputs)",
"hint": "add an output node such as SaveImage/PreviewImage",
}
)

return {
"valid": len(errors) == 0,
Expand Down Expand Up @@ -1020,8 +1049,9 @@ def _validate_catalog_value(
) -> tuple[list[dict], list[dict]]:
"""Enum-membership and other catalog checks for one scalar input value.

Returns (errors, warnings): an unknown enum value is a hard error carrying
the valid options; every other catalog finding is a namespaced warning.
Returns (errors, warnings): unknown-enum and out-of-range values are hard
errors (the server rejects them); every other catalog finding is a
namespaced warning.
"""
errors: list[dict] = []
warnings: list[dict] = []
Expand All @@ -1046,12 +1076,73 @@ def _validate_catalog_value(
"valid_options": list(port.enum_values),
}
)
elif w["code"] in ("below_min", "above_max"):
Comment thread
mattmillerai marked this conversation as resolved.
# The server hard-rejects out-of-range values
# (value_smaller_than_min / value_bigger_than_max, execution.py:1008-1033),
# so promote these to errors — same as unknown_enum_value above. One
# theoretical over-strictness: the server skips its built-in range
# checks for args covered by a node's custom VALIDATE_INPUTS
# (execution.py:1007); we accept that trade-off exactly as the
# unknown_enum_value hard error does.
bound = port.options.min if w["code"] == "below_min" else port.options.max
op = ">=" if w["code"] == "below_min" else "<="
errors.append(
{
"node_id": node_id,
"field": input_name,
"code": w["code"],
"message": w["message"],
"hint": f"use a value {op} {bound}",
}
)
else:
w["field"] = f"{node_id}.{class_type}.{w['field']}"
warnings.append(w)
return errors, warnings


def _check_required_present(node_id: str, m: Morphism, node_data: dict) -> list[dict]:
"""Required inputs that are absent from ``node_data["inputs"]`` entirely.

Mirrors the server (execution.py:884-900): any required input the frontend
didn't serialize is a hard reject (required_input_missing). The per-input
loop only inspects keys that ARE present, so this catches the absent ones.
Skipped, because each is handled by its own path (and would otherwise
double-error): autogrow ports (``_check_autogrow_required``) and the dynamic
types COMFY_DYNAMICCOMBO / COMFY_DYNAMICSLOT (DynamicSlot is always optional
server-side anyway). The server has NO exemption for required inputs that
carry a default — the frontend always serializes widget values, so absence
is a genuine authoring error; we do not skip ports with defaults.
"""
present = node_data.get("inputs") or {}
errors: list[dict] = []
for port in m.inputs:
if not port.required or port.is_autogrow:
continue
if port.type.startswith("COMFY_DYNAMICCOMBO") or port.type.startswith("COMFY_DYNAMICSLOT"):
continue
if port.name in present:
continue
errors.append(
{
"node_id": node_id,
"field": port.name,
"code": "required_input_missing",
"message": (
f"required input {port.name!r} is missing — "
f"the server will reject this node (required_input_missing)"
),
"hint": f"add {port.name!r} to inputs"
+ (
f" (e.g. a {port.type} value)"
if not port.is_link
else f" (wire a {port.type} link: [<node_id>, <output_index>])"
),
}
)
return errors


def _check_autogrow_required(
node_id: str, autogrow_ports: dict[str, Port], autogrow_seen: set[str], node_data: dict
) -> list[dict]:
Expand Down
4 changes: 3 additions & 1 deletion tests/comfy_cli/command/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,9 @@ def test_ui_workflow_converts_and_submits(self, ui_workflow_file, fake_target):
patch("comfy_cli.command.run.convert_ui_to_api", return_value=self.CONVERTED) as mock_convert,
patch(
"comfy_cli.cql.engine._load_from_target",
return_value={"KSampler": {}}, # any truthy dict suffices for the converter
# An output-node KSampler so preflight's no-outputs check passes
# (the converter is mocked and ignores object_info anyway).
return_value={"KSampler": {"output_node": True}},
),
patch("comfy_cli.comfy_client.Client", return_value=mock_client),
patch("comfy_cli.command.run._spawn_watcher"),
Expand Down
Loading
Loading