From ec717055ac1f758ac6f25ce5b73de22ee16af40c Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 17 Jul 2026 14:10:46 -0700 Subject: [PATCH 1/2] feat: validate auto-converts UI-format workflows like `comfy run` (BE-3359) Detect frontend/UI-export workflows in `comfy validate` and lower them to API format with the existing convert_ui_to_api before validating, exactly as `comfy run` does. Previously a UI export validated vacuously (each wrapper key emitted a non_node_key warning, zero nodes checked, verdict valid:true). Graph now retains the raw object_info it was built from (Graph.object_info), so the converter reuses it with no second fetch and offline --input keeps working. The JSON envelope gains converted_from_ui + converted_node_count. --- comfy_cli/cmdline.py | 44 ++++++ comfy_cli/cql/engine.py | 11 ++ .../command/test_validate_command.py | 140 ++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 tests/comfy_cli/command/test_validate_command.py diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 4662b788..0e1e1445 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -929,7 +929,9 @@ def validate( ): from pathlib import Path + from comfy_cli.command.run import is_ui_workflow from comfy_cli.cql.engine import Graph, LoadError + from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api renderer = get_renderer() @@ -972,6 +974,43 @@ def validate( ) raise typer.Exit(code=1) from e + # Detect a UI-export (frontend/canvas) workflow and lower it to API format + # before validating — exactly as `comfy run` does. Without this the wrapper + # keys (`nodes`, `links`, `groups`, `config`, …) each emit a `non_node_key` + # warning, zero nodes are checked, and the result is a vacuous `valid:true`. + # The converter reuses the object_info the graph was already built from + # (`graph.object_info`), so offline `--input` works and no second fetch happens. + converted_from_ui = False + if is_ui_workflow(wf_data): + if renderer.is_pretty(): + rprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]") + try: + converted = convert_ui_to_api(wf_data, graph.object_info) + except WorkflowConversionError as e: + renderer.error( + code="workflow_not_api_format", + message=f"Workflow is a UI export that could not be converted to API format: {e}", + hint="use ComfyUI's 'File > Export (API)' to save as API format", + ) + raise typer.Exit(code=1) from e + except Exception as e: # noqa: BLE001 — never leak a raw traceback to the agent flow + renderer.error( + code="conversion_crash", + message=f"Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}", + hint="report this at https://github.com/Comfy-Org/comfy-cli/issues", + details={"exception_type": type(e).__name__}, + ) + raise typer.Exit(code=1) from e + if not converted: + renderer.error( + code="workflow_not_api_format", + message="Workflow is a UI export that converted to no executable nodes", + hint="use ComfyUI's 'File > Export (API)' to save as API format", + ) + raise typer.Exit(code=1) + wf_data = converted + converted_from_ui = True + result = graph.validate_workflow(wf_data) payload = { @@ -982,6 +1021,11 @@ def validate( "errors": result["errors"], "warnings": result["warnings"], } + if converted_from_ui: + # Signal that validation ran against the converted graph, not the file's + # literal bytes, and report how many nodes the conversion produced. + payload["converted_from_ui"] = True + payload["converted_node_count"] = len(wf_data) if renderer.is_pretty(): if result["valid"]: diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index a8068aaa..5af010f8 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -430,6 +430,16 @@ def __init__(self) -> None: self._consumers: dict[str, list[Morphism]] = defaultdict(list) self._types: set[str] = set() self._annotated = False + # The raw ``/object_info`` payload this graph was built from. Retained + # verbatim so callers that also need to lower a UI-format workflow to + # API format (``convert_ui_to_api``) can reuse it without a second fetch. + self._raw: dict[str, Any] = {} + + @property + def object_info(self) -> dict[str, Any]: + """The raw ``/object_info`` dict this graph was built from (``{}`` if + the graph was constructed without one).""" + return self._raw @classmethod def from_object_info(cls, object_info: dict[str, Any]) -> Graph: @@ -439,6 +449,7 @@ def from_object_info(cls, object_info: dict[str, Any]) -> Graph: details={"top_level_type": type(object_info).__name__}, ) g = cls() + g._raw = object_info for node_id, raw in object_info.items(): if not isinstance(raw, dict): continue diff --git a/tests/comfy_cli/command/test_validate_command.py b/tests/comfy_cli/command/test_validate_command.py new file mode 100644 index 00000000..094e3bfd --- /dev/null +++ b/tests/comfy_cli/command/test_validate_command.py @@ -0,0 +1,140 @@ +"""Tests for `comfy validate` — frontend-format (UI-export) auto-conversion. + +`comfy validate --workflow ` used to validate vacuously: a +UI-export file's wrapper keys (`nodes`, `links`, `groups`, `config`, …) each +emitted a `non_node_key` warning, zero nodes were checked, and the verdict was +`valid:true`. The command now detects UI format (`is_ui_workflow`) and lowers it +to API format with `convert_ui_to_api` — exactly as `comfy run` does — before +validating, so the verdict reflects the real graph and the payload carries +`converted_from_ui: true` plus the converted node count. + +Offline mode (`--input `) is used throughout so no server is +needed: the same file supplies both the graph and the converter's object_info. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from comfy_cli.cmdline import app + +FIXTURES = Path(__file__).parent.parent / "fixtures" +OBJECT_INFO = FIXTURES / "sd15_object_info.json" +UI_WORKFLOW = FIXTURES / "sd15_ui_workflow.json" + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _write(tmp_path: Path, name: str, obj) -> Path: + p = tmp_path / name + p.write_text(json.dumps(obj), encoding="utf-8") + return p + + +def _envelope(result) -> dict: + """Parse the final JSON envelope line emitted in `--json` mode.""" + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _validate(runner: CliRunner, workflow: Path): + """Invoke `comfy --json validate` offline against the sd15 object_info.""" + return runner.invoke( + app, + ["--json", "validate", "--workflow", str(workflow), "--input", str(OBJECT_INFO)], + env={"COMFY_WHERE": "local"}, + ) + + +def test_ui_export_is_converted_and_validated(runner): + """A UI-export fixture validates against the CONVERTED graph: a truthful + verdict, `converted_from_ui: true`, the converted node count, and zero + `non_node_key` wrapper-key noise.""" + result = _validate(runner, UI_WORKFLOW) + + assert result.exit_code == 0, result.stdout + data = _envelope(result)["data"] + assert data["valid"] is True + assert data["converted_from_ui"] is True + # The sd15 UI workflow lowers to 7 API nodes. + assert data["converted_node_count"] == 7 + # The wrapper keys (nodes/links/groups/config/…) are gone after conversion, + # so none of them can produce the old vacuous-pass warnings. + assert [w for w in data["warnings"] if w.get("code") == "non_node_key"] == [] + + +def test_ui_export_surfaces_real_problems(runner, tmp_path): + """Acceptance: the converted graph is really validated — an unknown node + type surfaces as `valid:false` (not a vacuous pass), while still flagging + the file as UI-converted.""" + bad = { + "nodes": [{"id": 1, "type": "TotallyMadeUpNode", "mode": 0, "inputs": [], "outputs": [], "widgets_values": []}], + "links": [], + } + wf = _write(tmp_path, "bad_ui.json", bad) + + result = _validate(runner, wf) + + assert result.exit_code == 1 + data = _envelope(result)["data"] + assert data["valid"] is False + assert data["converted_from_ui"] is True + assert any(e["code"] == "unknown_class_type" for e in data["errors"]) + + +def test_ui_export_that_converts_to_nothing_is_rejected(runner, tmp_path): + """A UI file whose nodes carry no usable `type` converts to zero executable + nodes → structured `workflow_not_api_format` error, exit 1, message naming + the conversion.""" + empty_convert = {"nodes": [{"id": 1, "mode": 0, "inputs": [], "outputs": []}], "links": []} + wf = _write(tmp_path, "no_exec_ui.json", empty_convert) + + result = _validate(runner, wf) + + assert result.exit_code == 1 + error = _envelope(result)["error"] + assert error["code"] == "workflow_not_api_format" + assert "convert" in error["message"].lower() + + +def test_api_format_unchanged(runner, tmp_path): + """An API-format file behaves exactly as before: validated directly, no + `converted_from_ui` key in the payload.""" + api = {"1": {"class_type": "EmptyLatentImage", "inputs": {"width": 64, "height": 64, "batch_size": 1}}} + wf = _write(tmp_path, "api.json", api) + + result = _validate(runner, wf) + + assert result.exit_code == 0 + data = _envelope(result)["data"] + assert data["valid"] is True + assert "converted_from_ui" not in data + + +def test_non_dict_payload_unchanged(runner, tmp_path): + """A non-dict JSON payload keeps its existing `workflow_not_api_format` + error (the UI-detection branch never runs for it).""" + wf = _write(tmp_path, "list.json", [1, 2, 3]) + + result = _validate(runner, wf) + + assert result.exit_code == 1 + assert _envelope(result)["error"]["code"] == "workflow_not_api_format" + + +def test_empty_dict_payload_unchanged(runner, tmp_path): + """An empty dict is not UI format and is left to the existing validator + (no conversion, no `converted_from_ui` key).""" + wf = _write(tmp_path, "empty.json", {}) + + result = _validate(runner, wf) + + assert result.exit_code == 0 + data = _envelope(result)["data"] + assert "converted_from_ui" not in data From e3aed41635ae6629d84bafc87c50cbd919a4d726 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 17 Jul 2026 14:56:43 -0700 Subject: [PATCH 2/2] docs(cql): document Graph.object_info read-only contract (BE-3359) Per Cursor review: object_info returns the graph's live internal schema state by reference; document that callers must not mutate it (a defensive copy of the ~11KB payload on every access is not worth the cost, and the only consumer, convert_ui_to_api in validate, reads it). --- comfy_cli/cql/engine.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 5af010f8..1487218a 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -438,7 +438,12 @@ def __init__(self) -> None: @property def object_info(self) -> dict[str, Any]: """The raw ``/object_info`` dict this graph was built from (``{}`` if - the graph was constructed without one).""" + the graph was constructed without one). + + Read-only: this is the graph's live internal schema state, returned by + reference to avoid copying a large payload. Callers (e.g. the validate + command handing it to ``convert_ui_to_api``) must not mutate it. + """ return self._raw @classmethod