From 8f7672805b2bc4afa453efa09597c0ef11fcee17 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 2 Sep 2026 17:35:03 -0700 Subject: [PATCH] feat(generate): --emit-ops writes a frontend-format workflow plus a stamped op batch (BE-11131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --emit-workflow writes API format, which the canvas and every edit tool refuse (workflow_not_frontend_format) and which a shared-document consumer cannot attribute. --emit-ops re-expresses the SAME graph (build_workflow stays the single source of the model→node mapping) as add_node/set_widget/connect specs and materializes it through workflow_ops.apply_specs — the machinery every hand edit uses, so widget order, autogrow and positions have one answer. The file on disk becomes frontend format (canvas-editable), and the envelope carries the replace_ops batch exactly like templates fetch --emit-ops, so the consumer folds the replacement in as attributed ops. Also fixes a latent converter bug the round-trip contract exposed: convert_ui_to_api paired widgets positionally from the input DICT's order and ignored input_order, silently swapping neighboring widget values on any re-serialized catalog (GeminiImageNode's prompt/model traded places on an alphabetized fixture). It now honors input_order the way the cql engine's _ordered_names does. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QX1YteLA2BYbfjup13xNg1 --- comfy_cli/command/generate/app.py | 73 +++- comfy_cli/command/generate/emit.py | 127 +++++++ comfy_cli/workflow_to_api.py | 18 +- .../command/generate/test_emit_ops.py | 330 ++++++++++++++++++ 4 files changed, 538 insertions(+), 10 deletions(-) create mode 100644 tests/comfy_cli/command/generate/test_emit_ops.py diff --git a/comfy_cli/command/generate/app.py b/comfy_cli/command/generate/app.py index 1b0f7c161..7dee73dfe 100644 --- a/comfy_cli/command/generate/app.py +++ b/comfy_cli/command/generate/app.py @@ -284,7 +284,19 @@ def _generate_entry( def _separate_meta_flags(extra_args: list[str]) -> tuple[list[str], dict[str, str | bool]]: """Pull run-level flags out of the user's argv tail.""" - meta_names = {"download", "async", "json", "timeout", "api-key", "emit-workflow", "output-prefix", "yes"} + meta_names = { + "download", + "async", + "json", + "timeout", + "api-key", + "emit-workflow", + "emit-ops", + "actor", + "base-version", + "output-prefix", + "yes", + } meta: dict[str, str | bool] = {} remaining: list[str] = [] i = 0 @@ -296,7 +308,7 @@ def _separate_meta_flags(extra_args: list[str]) -> tuple[list[str], dict[str, st if "=" in body: body, raw = body.split("=", 1) if body in meta_names: - if body in {"async", "json", "yes"}: + if body in {"async", "json", "yes", "emit-ops"}: meta[body] = True if raw is None else raw.lower() not in {"false", "0", "no"} i += 1 continue @@ -564,14 +576,53 @@ def _track_error(error_kind: str, exc: BaseException) -> None: hint=f"Run `comfy generate schema {name}` for the full parameter list.", ) + emit_ops_mode = bool(meta.get("emit-ops", False)) + if emit_ops_mode and not emit_path: + get_renderer().error( + code="generate_bad_args", + message="--emit-ops requires --emit-workflow : the op batch describes the workflow written there", + hint="add --emit-workflow workflow.json", + ) + raise typer.Exit(code=1) if emit_path: # Emit a runnable workflow that drives the partner *node* and return # — no proxy call, no API key required. The artifact is the result. name = gen_props["model_alias"] or ep.id prefix = meta.get("output-prefix") if isinstance(meta.get("output-prefix"), str) else "generate" renderer = get_renderer() + ops: list | None = None try: - workflow = emit.write_workflow(name, values, Path(emit_path).expanduser(), output_prefix=prefix) + if emit_ops_mode: + # FRONTEND-format file + a stamped replace_ops batch, so the + # written graph is canvas-editable and a shared-document + # consumer folds it in as attributed ops instead of a + # wholesale replacement — same contract as + # `templates fetch --emit-ops`. The graph loads through the + # same resilient path every workflow edit verb uses + # (COMFY_OBJECT_INFO_FILE honored, cache fallback). + from comfy_cli.command.workflow import _get_graph + + actor = meta.get("actor") if isinstance(meta.get("actor"), str) else "cli" + try: + base_version = int(meta.get("base-version", 0)) + except (TypeError, ValueError): + renderer.error( + code="generate_bad_args", + message=f"--base-version must be an integer, got {meta.get('base-version')!r}", + ) + raise typer.Exit(code=1) from None + graph = _get_graph(None, None, None) + workflow, ops = emit.write_frontend_workflow( + name, + values, + Path(emit_path).expanduser(), + graph, + actor=actor, + base_version=base_version, + output_prefix=prefix, + ) + else: + workflow = emit.write_workflow(name, values, Path(emit_path).expanduser(), output_prefix=prefix) except emit.UnsupportedModelError as e: # Its own code: the remedy is "pick another model", which is # not what the umbrella `emit_workflow_failed` hint says, and @@ -600,14 +651,20 @@ def _track_error(error_kind: str, exc: BaseException) -> None: hint=hint, ) raise typer.Exit(code=1) from e - tracking.track_event("generate:emit", {**gen_props, "node_count": len(workflow)}) + node_count = len(workflow["nodes"]) if emit_ops_mode else len(workflow) + tracking.track_event("generate:emit", {**gen_props, "node_count": node_count}) if renderer.is_pretty(): rprint(f"[bold green]Wrote workflow:[/bold green] {emit_path}") rprint(f" run it: comfy run --workflow {emit_path}") - renderer.emit( - {"out": str(Path(emit_path).expanduser()), "model": name, "nodes": len(workflow)}, - command="generate emit-workflow", - ) + payload = { + "out": str(Path(emit_path).expanduser()), + "model": name, + "nodes": node_count, + "format": "frontend" if emit_ops_mode else "api", + } + if ops is not None: + payload["ops"] = ops + renderer.emit(payload, command="generate emit-workflow") return # Spend gate — a proxy call spends Comfy credits, so consent comes diff --git a/comfy_cli/command/generate/emit.py b/comfy_cli/command/generate/emit.py index 9030aea49..cf8de5803 100644 --- a/comfy_cli/command/generate/emit.py +++ b/comfy_cli/command/generate/emit.py @@ -365,3 +365,130 @@ def write_workflow( path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(workflow, indent=2) + "\n", encoding="utf-8") return workflow + + +# --------------------------------------------------------------------------- +# --emit-ops: the same graph, expressed as the frozen op vocabulary +# --------------------------------------------------------------------------- +# +# ``--emit-workflow`` writes API format, which the canvas and every edit tool +# refuse (workflow_not_frontend_format) and which the CRDT write path cannot +# attribute. Rather than converting API→frontend after the fact — a second +# implementation of widget order and layout — the emitter mints the SAME graph +# as add_node/set_widget/connect specs and lets ``workflow_ops.apply_specs`` +# materialize the frontend workflow: the exact machinery every hand edit +# already uses, so widget ordering, autogrow growth and position assignment +# have one answer. The API graph from :func:`build_workflow` stays the single +# source of the model→node mapping; this is a mechanical re-expression of it. + + +def _is_link_ref(value: Any, node_ids: set[str]) -> bool: + """An API input value of the shape ``[node_id, output_index]``.""" + return ( + isinstance(value, list) + and len(value) == 2 + and str(value[0]) in node_ids + and isinstance(value[1], int) + and not isinstance(value[1], bool) + ) + + +def ops_from_api_workflow(api_wf: dict[str, Any], graph: Any) -> list[dict[str, Any]]: + """Re-express an API-format graph as batch specs for ``apply_specs``. + + Shape: every ``add_node`` first (each with a batch-local alias), then every + ``set_widget`` (non-dotted keys before dotted ones, so a dynamic-combo + selection lands before the sub-widgets it exposes), then every ``connect`` + — an order in which every referenced endpoint already exists. + + ``graph`` is accepted for parity with the applier's signature and future + schema-aware canonicalization; the current mapping is purely structural. + """ + del graph # structural mapping today; see docstring + node_ids = {str(k) for k in api_wf} + + def alias(nid: Any) -> str: + return f"gen{nid}" + + adds: list[dict[str, Any]] = [] + widgets: list[dict[str, Any]] = [] + connects: list[dict[str, Any]] = [] + for nid in sorted(api_wf, key=str): + node = api_wf[nid] + # allow_deprecated: the model→node mapping is curated (and pinned by + # test_emit's endpoint invariant), so a class the catalog has since + # flagged deprecated is still the intended target — the gate exists to + # stop a GUESSED class, not a mapped one. + adds.append({"op": "add_node", "class_type": node["class_type"], "as": alias(nid), "allow_deprecated": True}) + inputs = node.get("inputs") or {} + keys = sorted(inputs, key=lambda k: (k.count("."), list(inputs).index(k))) + for key in keys: + value = inputs[key] + if _is_link_ref(value, node_ids): + connects.append( + { + "op": "connect", + "from": f"${alias(value[0])}.{value[1]}", + "to": f"${alias(nid)}.{key}", + } + ) + else: + widgets.append({"op": "set_widget", "node": f"${alias(nid)}", "widget": key, "value": value}) + return adds + widgets + connects + + +_EMPTY_FRONTEND: dict[str, Any] = { + "nodes": [], + "links": [], + "version": 0.4, + "last_node_id": 0, + "last_link_id": 0, +} + + +def write_frontend_workflow( + model: str, + values: dict[str, Any], + path: Path, + graph: Any, + *, + actor: str = "cli", + base_version: int = 0, + output_prefix: str = "generate", +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Build the workflow for ``model`` as a FRONTEND-format graph and write it + to ``path``; return ``(workflow, ops)`` where ``ops`` is the stamped + ``replace_ops`` batch that turns whatever ``path`` previously held into the + new graph (empty previous ⇒ no delete half), ready for the envelope exactly + like ``templates fetch --emit-ops``. + + Raises ``EmitError``/``UnsupportedModelError`` like :func:`write_workflow`; + an applier failure surfaces as ``EmitError`` (the request itself was + expressible — a failure here is a schema/catalog mismatch worth reporting). + """ + from comfy_cli import workflow_ops + + api = build_workflow(model, values, output_prefix=output_prefix) + specs = ops_from_api_workflow(api, graph) + try: + workflow, _ops, _aliases = workflow_ops.apply_specs( # noqa: F841 — wf is the product; batch below is replace-shaped + json.loads(json.dumps(_EMPTY_FRONTEND)), graph, specs, actor=actor, base_version=base_version + ) + except (ValueError, KeyError) as e: + raise EmitError(f"could not materialize the {model!r} workflow as canvas ops: {e}") from e + + previous: dict[str, Any] = {} + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict) and isinstance(loaded.get("nodes"), list): + previous = loaded + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + previous = {} + + try: + ops = workflow_ops.replace_ops(previous, workflow, actor=actor, base_version=base_version) + except workflow_ops.NotExpressibleError as e: # can't-happen for our own built graph; fail loudly if it does + raise EmitError(f"the {model!r} workflow cannot be expressed as ops: {e}") from e + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(workflow, indent=2) + "\n", encoding="utf-8") + return workflow, ops diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index df17b9fbe..ac0c59bb9 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1237,13 +1237,27 @@ def consume(name: str, spec: Any, depth: int = 0, next_spec: Any = None) -> None ): vidx += 1 - # Flatten required+optional first so each input knows its successor's schema. + # Flatten required+optional first so each input knows its successor's + # schema. Within each section, honor ``input_order`` the way the cql + # engine's ``_ordered_names`` does (listed names first, leftovers in dict + # order): the input DICT's own order is only trustworthy on a catalog that + # was never re-serialized, and pairing widgets positionally from a sorted + # dict silently swaps neighboring values (observed: GeminiImageNode's + # prompt/model traded places on an alphabetized fixture). + input_order = schema.get("input_order") if isinstance(schema, dict) else None + if not isinstance(input_order, dict): + input_order = {} ordered: list[tuple[str, Any]] = [] for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue - ordered.extend(section_def.items()) + section_order = input_order.get(section) + names = list(section_def.keys()) + if isinstance(section_order, list): + listed = [n for n in section_order if n in section_def] + names = listed + [n for n in names if n not in listed] + ordered.extend((n, section_def[n]) for n in names) for i, (input_name, input_spec) in enumerate(ordered): consume(input_name, input_spec, 0, next_widget_spec(ordered, i + 1)) return pairs diff --git a/tests/comfy_cli/command/generate/test_emit_ops.py b/tests/comfy_cli/command/generate/test_emit_ops.py new file mode 100644 index 000000000..22ff0f771 --- /dev/null +++ b/tests/comfy_cli/command/generate/test_emit_ops.py @@ -0,0 +1,330 @@ +"""``generate --emit-ops``: the emitter expressed as the frozen op vocabulary. + +BE-11131: ``--emit-workflow`` writes an API-format file, which the canvas and +every edit tool refuse (``workflow_not_frontend_format`` — 48 refusals in one +staging day), and which the CRDT write path cannot attribute (no ops). Instead +of converting API→frontend after the fact — a second implementation of widget +order and layout — the emitter mints the graph as add_node/set_widget/connect +specs and lets ``workflow_ops.apply_specs`` materialize the frontend workflow, +exactly the machinery every hand edit already uses. One answer, not two. + +The round-trip test is the contract: lowering the materialized frontend +workflow back to API format must reproduce the semantics of the API graph +``build_workflow`` has always emitted. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from comfy_cli import workflow_ops +from comfy_cli.command.generate import emit +from comfy_cli.cql.engine import Graph +from comfy_cli.workflow_to_api import convert_ui_to_api + +PARTNER_OBJECT_INFO = json.loads( + (Path(__file__).parent / "fixtures" / "partner_nodes_object_info.json").read_text(encoding="utf-8") +) + +# Core nodes build_workflow relies on that are not partner nodes. Minimal but +# faithful shapes: LoadImage's image is an upload-backed combo (so a local +# filename passes the enum gate), ImageBatch is the 2-input folder. +CORE_OBJECT_INFO = { + "LoadImage": { + "input": {"required": {"image": [["example.png"], {"image_upload": True}]}}, + "input_order": {"required": ["image"]}, + "output": ["IMAGE", "MASK"], + "output_name": ["IMAGE", "MASK"], + "name": "LoadImage", + "display_name": "Load Image", + "category": "image", + }, + "SaveImage": { + "input": { + "required": { + "images": ["IMAGE"], + "filename_prefix": ["STRING", {"default": "ComfyUI"}], + } + }, + "input_order": {"required": ["images", "filename_prefix"]}, + "output": [], + "output_name": [], + "name": "SaveImage", + "display_name": "Save Image", + "category": "image", + }, + "SaveVideo": { + "input": { + "required": { + "video": ["VIDEO"], + "filename_prefix": ["STRING", {"default": "video/ComfyUI"}], + "format": [["auto", "mp4"], {"default": "auto"}], + "codec": [["auto", "h264"], {"default": "auto"}], + } + }, + "input_order": {"required": ["video", "filename_prefix", "format", "codec"]}, + "output": [], + "output_name": [], + "name": "SaveVideo", + "display_name": "Save Video", + "category": "image/video", + }, + "ImageBatch": { + "input": {"required": {"image1": ["IMAGE"], "image2": ["IMAGE"]}}, + "input_order": {"required": ["image1", "image2"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "name": "ImageBatch", + "display_name": "Batch Images", + "category": "image", + }, +} + + +def _object_info() -> dict: + merged = dict(CORE_OBJECT_INFO) + merged.update(PARTNER_OBJECT_INFO) + return merged + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +EMPTY_WF: dict = {"nodes": [], "links": [], "version": 0.4, "last_node_id": 0, "last_link_id": 0} + + +def _apply(specs: list[dict]) -> dict: + wf, _ops, _aliases = workflow_ops.apply_specs( + json.loads(json.dumps(EMPTY_WF)), _graph(), specs, actor="test", base_version=0 + ) + return wf + + +def _api_by_class(api_wf: dict) -> dict[str, dict]: + """Index an API-format graph by class_type (unique per class in these + graphs), normalizing link refs to the SOURCE class and autogrow element + keys (``base.image_1``) to their base — the two representations that may + legitimately differ between the legacy flat emitter and the schema-driven + op path.""" + by_id = {nid: n["class_type"] for nid, n in api_wf.items()} + out: dict[str, dict] = {} + for n in api_wf.values(): + inputs: dict[str, object] = {} + for key, value in (n.get("inputs") or {}).items(): + base = key.split(".")[0] if key.count(".") and key.rsplit(".", 1)[-1].split("_")[-1].isdigit() else key + if isinstance(value, list) and len(value) == 2 and str(value[0]) in by_id: + inputs[base] = ("link", by_id[str(value[0])]) + else: + inputs[base] = value + out[n["class_type"]] = inputs + return out + + +# ─── unit: ops_from_api_workflow ────────────────────────────────────────── + + +def test_ops_shape_for_an_image_edit_model(): + api = emit.build_workflow("nano-banana", {"prompt": "add sunglasses", "image": "cat.png"}) + specs = emit.ops_from_api_workflow(api, _graph()) + + kinds = [s["op"] for s in specs] + assert kinds == sorted(kinds, key=["add_node", "set_widget", "connect"].index), ( + "adds, then widgets, then connects — every endpoint exists before it is referenced" + ) + adds = [s for s in specs if s["op"] == "add_node"] + assert {s["class_type"] for s in adds} == {"LoadImage", "GeminiImageNode", "SaveImage"} + assert all(s.get("as") for s in adds), "every add declares an alias for later specs to reference" + prompts = [s for s in specs if s["op"] == "set_widget" and s["widget"] == "prompt"] + assert len(prompts) == 1 and prompts[0]["value"] == "add sunglasses" + connects = [s for s in specs if s["op"] == "connect"] + assert len(connects) == 2, "loader→partner and partner→save" + + +def test_ops_apply_to_a_frontend_workflow_that_lowers_back_to_the_same_api_graph(): + api = emit.build_workflow("nano-banana", {"prompt": "add sunglasses", "image": "cat.png"}) + wf = _apply(emit.ops_from_api_workflow(api, _graph())) + + assert isinstance(wf.get("nodes"), list), "the materialized workflow is FRONTEND format" + lowered = convert_ui_to_api(wf, _object_info()) + got, want = _api_by_class(lowered), _api_by_class(api) + assert set(got) == set(want) + for cls in want: + for key, value in want[cls].items(): + assert got[cls].get(key) == value, f"{cls}.{key}: emitted {got[cls].get(key)!r}, want {value!r}" + + +def test_ops_roundtrip_for_a_video_model(): + api = emit.build_workflow("seedance", {"prompt": "drift", "image": "frame.png", "duration": 8}) + wf = _apply(emit.ops_from_api_workflow(api, _graph())) + lowered = convert_ui_to_api(wf, _object_info()) + got, want = _api_by_class(lowered), _api_by_class(api) + assert got["SaveVideo"]["video"] == ("link", "ByteDanceImageToVideoNode") + assert got["ByteDanceImageToVideoNode"].get("duration") == want["ByteDanceImageToVideoNode"]["duration"] + + +def test_ops_roundtrip_with_no_image_params(): + api = emit.build_workflow("flux-2", {"prompt": "a fox", "width": 512, "height": 768}) + wf = _apply(emit.ops_from_api_workflow(api, _graph())) + lowered = convert_ui_to_api(wf, _object_info()) + got = _api_by_class(lowered) + assert got["Flux2ProImageNode"]["prompt"] == "a fox" + assert got["Flux2ProImageNode"]["width"] == 512 + assert got["SaveImage"]["images"] == ("link", "Flux2ProImageNode") + + +def test_ops_fold_multiple_images_through_image_batch(): + api = emit.build_workflow("nano-banana", {"prompt": "merge", "image": ["a.png", "b.png"]}) + wf = _apply(emit.ops_from_api_workflow(api, _graph())) + lowered = convert_ui_to_api(wf, _object_info()) + got = _api_by_class(lowered) + assert got["GeminiImageNode"]["images"] == ("link", "ImageBatch") + assert got["ImageBatch"]["image1"] == ("link", "LoadImage") + + +# ─── write path: frontend file + replace_ops envelope batch ─────────────── + + +def test_write_frontend_workflow_writes_frontend_and_returns_replace_batch(tmp_path): + out = tmp_path / "workflow.json" + wf, ops = emit.write_frontend_workflow( + "nano-banana", + {"prompt": "add sunglasses", "image": "cat.png"}, + out, + _graph(), + actor="agent", + base_version=3, + ) + on_disk = json.loads(out.read_text()) + assert isinstance(on_disk.get("nodes"), list), "the file on disk is frontend format" + assert on_disk == wf + assert ops, "the envelope batch expresses the replacement as attributed ops" + assert all(o.get("op_id") for o in ops), "ops are fully minted (dual-shape, replayable)" + assert all(o.get("actor") == "agent" for o in ops) + + +def test_write_frontend_workflow_emits_delete_half_over_a_previous_graph(tmp_path): + out = tmp_path / "workflow.json" + _wf1, _ops1 = emit.write_frontend_workflow( + "flux-2", {"prompt": "first"}, out, _graph(), actor="agent", base_version=0 + ) + _wf2, ops2 = emit.write_frontend_workflow( + "nano-banana", {"prompt": "second", "image": "cat.png"}, out, _graph(), actor="agent", base_version=1 + ) + deletes = [o for o in ops2 if o["op"] == "delete_node"] + assert deletes, "replacing an existing canvas must delete what it replaces, like templates fetch" + + +def test_write_frontend_workflow_unsupported_model_still_raises(tmp_path): + with pytest.raises(emit.UnsupportedModelError): + emit.write_frontend_workflow("no-such-model", {}, tmp_path / "w.json", _graph()) + + +# ─── regression: the converter honors input_order like every other surface ── + + +def test_convert_ui_to_api_honors_input_order_over_dict_order(): + """A re-serialized object_info can alphabetize the input dicts; the + ``input_order`` block exists to carry the real declaration order, and the + cql engine already honors it. The converter pairing widgets positionally + from DICT order silently swaps neighboring widget values (observed: + prompt/model traded places on GeminiImageNode). One order, every surface.""" + object_info = { + "Reordered": { + # Dict order is alphabetical (b_model before a_prompt would sort + # differently — use names whose sort order INVERTS input_order). + "input": { + "required": { + "alpha": ["STRING", {"default": ""}], + "beta": [["x", "y"], {"default": "x"}], + } + }, + "input_order": {"required": ["beta", "alpha"]}, + "output": [], + "output_name": [], + "name": "Reordered", + "display_name": "Reordered", + "category": "test", + } + } + ui = { + "nodes": [ + { + "id": 1, + "type": "Reordered", + "pos": [0, 0], + "size": [200, 100], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": {}, + # Positional per input_order: beta first, then alpha. + "widgets_values": ["y", "hello"], + } + ], + "links": [], + "version": 0.4, + "last_node_id": 1, + "last_link_id": 0, + } + api = convert_ui_to_api(ui, object_info) + node = api["1"] + assert node["inputs"]["beta"] == "y", f"beta took {node['inputs'].get('beta')!r} — dict-order pairing" + assert node["inputs"]["alpha"] == "hello" + + +# ─── CLI: the flag end to end ───────────────────────────────────────────── + + +def test_cli_emit_ops_writes_frontend_and_envelope_ops(tmp_path, monkeypatch, runner=None): + from typer.testing import CliRunner + + from comfy_cli.cmdline import app + + oi_path = tmp_path / "object_info.json" + oi_path.write_text(json.dumps(_object_info()), encoding="utf-8") + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(oi_path)) + out = tmp_path / "workflow.json" + + result = CliRunner().invoke( + app, + [ + "--json", + "generate", + "nano-banana", + "--prompt", + "add sunglasses", + "--image", + "cat.png", + "--emit-workflow", + str(out), + "--emit-ops", + "--actor", + "agent-user", + "--base-version", + "7", + ], + ) + assert result.exit_code == 0, result.output + envelope = json.loads(result.output.strip().splitlines()[-1]) + assert envelope["ok"] is True + assert envelope["data"]["format"] == "frontend" + ops = envelope["data"]["ops"] + assert ops and all(o.get("actor") == "agent-user" for o in ops) + on_disk = json.loads(out.read_text()) + assert isinstance(on_disk.get("nodes"), list), "the written file is canvas-editable frontend format" + + +def test_cli_emit_ops_without_emit_workflow_is_an_error(tmp_path, monkeypatch): + from typer.testing import CliRunner + + from comfy_cli.cmdline import app + + result = CliRunner().invoke(app, ["--json", "generate", "nano-banana", "--prompt", "x", "--emit-ops"]) + assert result.exit_code != 0 + assert "generate_bad_args" in result.output