From f795e39334408aa643dd8eb81b3e3dfb2f6f3d28 Mon Sep 17 00:00:00 2001 From: kishore Date: Mon, 13 Jul 2026 22:48:50 -0700 Subject: [PATCH 01/53] =?UTF-8?q?feat(workflow):=20agent-first=20workflow?= =?UTF-8?q?=20editing=20=E2=80=94=20CRDT=20edit=20primitives,=20recipes,?= =?UTF-8?q?=20offline=20catalog,=20cloud=20run=20association?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the agent-workflow track into one change on top of main. It gives agents a structured, convergence-safe way to build and edit ComfyUI workflows, a reuse model that replaces raw fragments, an offline node catalog so edits and validation work without a live server, and the cloud plumbing to associate and run those workflows against Comfy Cloud. Structured (CRDT-ready) workflow edits - New workflow_ops.py: convergence-safe op model for graph mutation, with the converge-or-flag invariant and canonical-form soundness proven in tests. - New `workflow edit` command surface (workflow_edit.py) over those primitives; set-widget resolves subgraph promoted inputs the same way it resolves slots. - edit/recipe commands registered in COMMAND_SCHEMAS for discovery. Recipes replace fragments - Parameterized recipes + capture as the reuse path; `foreach` bulk-instantiates a recipe over N param-sets. - Legacy fragments un-surfaced from agents; comfy-fragments SKILL removed and skills docs point at recipes as the supported path. Offline node catalog + validation - COMFY_OBJECT_INFO_FILE provides a default offline node catalog, honored in the shared CQL loader (not per-command) with a cache-first TTL for object_info. - validate lowers frontend/canvas graphs to API before validating; generate emits complete node inputs and validate flags missing required inputs. - Rejected values get actionable feedback: normalize mangled model values and suggest the nearest COMBO; suggest the real id/address on a not-found edit; drop the seed control_after_generate marker for partner nodes. Cloud run association - `comfy run --workflow-id` associates a cloud job with a workflow. - Accept a forwarded Bearer token via COMFY_CLOUD_AUTH_TOKEN. - New `assets library ls` / `ensure`; shared cloud-HTTP helpers extracted to cloud_http.py. - Cloud object_info loads route through the offline catalog. Agentic run ergonomics + perf - Suppress the detached watcher for agentic callers (COMFY_NO_WATCH / --no-watch). - Kill the telemetry exit hang and defer heavy imports at startup. - preview renders headless via bundled ffmpeg. Co-Authored-By: Claude Opus 4.8 (1M context) --- comfy_cli/cmdline.py | 70 +- comfy_cli/comfy_client.py | 9 + comfy_cli/command/assets_library.py | 111 ++ comfy_cli/command/cloud_http.py | 105 ++ comfy_cli/command/code_search.py | 7 +- comfy_cli/command/generate/emit.py | 48 +- comfy_cli/command/install.py | 14 +- comfy_cli/command/models/models.py | 7 +- comfy_cli/command/preview.py | 54 +- comfy_cli/command/project.py | 5 + comfy_cli/command/run/__init__.py | 18 +- comfy_cli/command/run/watcher.py | 24 +- comfy_cli/command/workflow.py | 24 +- comfy_cli/command/workflow_edit.py | 537 ++++++ comfy_cli/cql/engine.py | 227 ++- comfy_cli/cql/loader.py | 87 +- comfy_cli/credentials.py | 33 +- comfy_cli/discovery.py | 11 + comfy_cli/env_checker.py | 5 +- comfy_cli/error_codes.py | 11 + comfy_cli/file_utils.py | 7 +- comfy_cli/registry/api.py | 10 +- comfy_cli/schemas/assets_library.json | 32 + comfy_cli/skills/__init__.py | 4 - comfy_cli/skills/comfy-director/SKILL.md | 4 +- comfy_cli/skills/comfy-fragments/SKILL.md | 657 -------- comfy_cli/skills/comfy-relay/SKILL.md | 35 +- comfy_cli/skills/comfy/SKILL.md | 150 +- comfy_cli/skills/command.py | 1 - comfy_cli/standalone.py | 8 +- comfy_cli/tracking.py | 56 +- comfy_cli/ui.py | 26 +- comfy_cli/utils.py | 5 +- comfy_cli/where.py | 14 +- comfy_cli/workflow_ops.py | 1112 +++++++++++++ comfy_cli/workflow_to_api.py | 70 +- comfy_cli/workspace_manager.py | 5 +- pyproject.toml | 4 + .../fixtures/partner_nodes_object_info.json | 674 ++++++++ tests/comfy_cli/command/generate/test_emit.py | 70 + tests/comfy_cli/command/github/test_pr.py | 8 +- tests/comfy_cli/command/test_code_search.py | 8 +- tests/comfy_cli/command/test_run.py | 31 + tests/comfy_cli/command/test_run_watcher.py | 67 + tests/comfy_cli/command/test_workflow_edit.py | 1452 +++++++++++++++++ .../command/test_workflow_edit_cloud.py | 151 ++ tests/comfy_cli/conftest.py | 17 + tests/comfy_cli/cql/test_engine.py | 141 +- tests/comfy_cli/cql/test_loader_resilient.py | 6 + tests/comfy_cli/cql/test_loader_ttl.py | 237 +++ tests/comfy_cli/cql/test_object_info_env.py | 64 + tests/comfy_cli/skills/test_installer.py | 11 +- tests/comfy_cli/test_credentials.py | 45 + tests/comfy_cli/test_env_checker.py | 10 +- tests/comfy_cli/test_standalone.py | 20 +- tests/comfy_cli/test_tracking.py | 12 +- tests/comfy_cli/test_tracking_providers.py | 81 + tests/comfy_cli/test_utils.py | 2 +- tests/comfy_cli/test_validate_lowers_ui.py | 247 +++ tests/comfy_cli/test_workflow_to_api.py | 200 +++ tests/e2e/verify_tracking_live.py | 3 +- uv.lock | 18 +- 62 files changed, 6342 insertions(+), 840 deletions(-) create mode 100644 comfy_cli/command/assets_library.py create mode 100644 comfy_cli/command/cloud_http.py create mode 100644 comfy_cli/command/workflow_edit.py create mode 100644 comfy_cli/schemas/assets_library.json delete mode 100644 comfy_cli/skills/comfy-fragments/SKILL.md create mode 100644 comfy_cli/workflow_ops.py create mode 100644 tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json create mode 100644 tests/comfy_cli/command/test_run_watcher.py create mode 100644 tests/comfy_cli/command/test_workflow_edit.py create mode 100644 tests/comfy_cli/command/test_workflow_edit_cloud.py create mode 100644 tests/comfy_cli/cql/test_loader_ttl.py create mode 100644 tests/comfy_cli/cql/test_object_info_env.py create mode 100644 tests/comfy_cli/test_validate_lowers_ui.py diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 4662b7882..5604df95d 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -5,7 +5,6 @@ import webbrowser from typing import Annotated -import questionary import typer from rich.console import Console @@ -787,11 +786,35 @@ def run( ), ), ] = False, + workflow_id: Annotated[ + str | None, + typer.Option( + "--workflow-id", + show_default=False, + help="Cloud workflow entity id to associate this run with (enables draft auto-save on run).", + ), + ] = None, + no_watch: Annotated[ + bool, + typer.Option( + "--no-watch", + show_default=False, + help=( + "Suppress the detached background watcher subprocess for non-blocking " + "runs (equivalent to setting COMFY_NO_WATCH=1). Agentic callers with " + "their own job-wait loop don't need a second process polling in the " + "background; it just holds onto credentials after the parent exits." + ), + ), + ] = False, ): # Snapshot kwargs before the body mutates api_key/host/port — analytics should record what user actually supplied. _track_props = tracking.filter_command_kwargs(dict(locals())) tracking.track_event("execution_start", _track_props, mixpanel_name="run") + if no_watch: + os.environ["COMFY_NO_WATCH"] = "1" + try: if api_key: api_key = api_key.strip() or None @@ -857,6 +880,7 @@ def run( timeout=timeout, notify=effective_notify, print_prompt=print_prompt, + workflow_id=workflow_id, preloaded=preloaded, ) return @@ -902,13 +926,15 @@ def run( @app.command( - help="Validate an API-format workflow without submitting. Checks class_types, input shapes, enum values, and edge wiring." + help="Validate a workflow without submitting. Accepts API-format or a frontend/canvas " + "graph (auto-converted to API first). Checks class_types, required inputs, input shapes, " + "enum values, and edge wiring." ) @tracking.track_command() def validate( workflow: Annotated[ str, - typer.Option(help="Path to the API-format workflow JSON file."), + typer.Option(help="Path to the workflow JSON file (API format or a frontend/canvas graph)."), ], where: Annotated[ str | None, @@ -930,6 +956,8 @@ def validate( from pathlib import Path from comfy_cli.cql.engine import Graph, LoadError + from comfy_cli.cql.loader import resilient_load_object_info + from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api, is_api_format renderer = get_renderer() @@ -972,6 +1000,38 @@ def validate( ) raise typer.Exit(code=1) from e + # `validate_workflow` only inspects the API/prompt shape + # ({id: {class_type, inputs}}) — it iterates node inputs and checks wiring, + # required inputs, enums, and shapes. A frontend/canvas graph + # ({nodes: [...], links: [...]}) never gets its nodes examined: every + # top-level key is treated as a non-node and the result comes back + # valid:true even when the wiring is structurally broken. So a canvas + # workflow MUST be lowered to API format FIRST, using the SAME converter + # (and the SAME object_info resolution) the `run` path uses, so validate + # inspects exactly what the server would execute. + if not is_api_format(wf_data): + try: + object_info = resilient_load_object_info( + mode=mode, input_path=input_path, host=host or "127.0.0.1", port=port or 8188 + ) + except LoadError as e: + renderer.error( + code="cql_no_graph", + message=str(e), + hint=e.details.get("hint", "pass --input , or start the server"), + details=e.details, + ) + raise typer.Exit(code=1) from e + try: + wf_data = convert_ui_to_api(wf_data, object_info) + except WorkflowConversionError as e: + renderer.error( + code="conversion_error", + message=str(e), + hint="check that every node's required inputs are connected", + ) + raise typer.Exit(code=1) from e + result = graph.validate_workflow(wf_data) payload = { @@ -1510,6 +1570,10 @@ def feedback( else str(usability_satisfaction_score), }, ) + # Imported lazily: questionary pulls in prompt_toolkit (~50ms) and is only + # needed on this interactive feedback path. + import questionary + if ( sent and questionary.confirm("Do you want to provide additional feature-specific feedback on our GitHub page?").ask() diff --git a/comfy_cli/comfy_client.py b/comfy_cli/comfy_client.py index 02b8c8eb8..bdd23e320 100644 --- a/comfy_cli/comfy_client.py +++ b/comfy_cli/comfy_client.py @@ -324,10 +324,15 @@ def submit_prompt( *, timeout: float | None = None, extra_data: dict | None = None, + workflow_id: str | None = None, ) -> SubmitResult: """POST {prefix}/prompt — submit a workflow for execution. Caller may pass ``extra_data`` (merged into the request, not overwritten). + For cloud submissions, ``workflow_id`` (the cloud workflow entity id) is + forwarded as a top-level ``workflow_id`` field so the server can associate + the job with an existing workflow and auto-promote a draft on run. Omitted + from the body entirely when unset. For cloud submissions, the user's OAuth token is injected as ``auth_token_comfy_org`` so partner-API nodes (BFL Flux Pro, Gemini Nano Banana, etc.) can call out to comfy.org — matching what the web @@ -352,6 +357,10 @@ def payload() -> dict[str, Any]: merged_extra.setdefault("api_key_comfy_org", self.target.api_key) if merged_extra: request_payload["extra_data"] = merged_extra + # Cloud workflow entity id: associate this job with an existing + # workflow (auto-promotes a draft on run). Only sent when provided. + if workflow_id: + request_payload["workflow_id"] = workflow_id return request_payload resp = self._request("POST", ("prompt",), body_factory=payload, timeout=timeout) diff --git a/comfy_cli/command/assets_library.py b/comfy_cli/command/assets_library.py new file mode 100644 index 000000000..3c1f7be62 --- /dev/null +++ b/comfy_cli/command/assets_library.py @@ -0,0 +1,111 @@ +"""``comfy assets library`` — browse and borrow assets from Comfy Cloud's +asset library. + +Mirrors the cloud-saved-workflow subcommands in ``workflow.py`` (``list``, +``get``, ...): thin Typer commands over ``cloud_http``'s shared helpers, +emitting a JSON envelope via the renderer. Cloud-only — there is no local +``/api/assets`` surface. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +import typer + +from comfy_cli import tracking +from comfy_cli.command.cloud_http import ( + cloud_target_or_local_error, + handle_cloud_http_error, + http_request, +) +from comfy_cli.output.renderer import get_renderer + +app = typer.Typer(help="Browse your Comfy Cloud asset library (list, borrow).") + + +@app.command("ls", help="List your assets on Comfy Cloud.") +@tracking.track_command("assets") +def ls_cmd( + name: Annotated[ + str | None, + typer.Option("--name", show_default=False, help="Case-insensitive substring match on asset name."), + ] = None, + tags: Annotated[ + str | None, + typer.Option("--tags", show_default=False, help="Comma-separated tags; assets must have ALL of them (e.g. input,output)."), + ] = None, + limit: Annotated[int, typer.Option("--limit", help="Cap rows returned (max 500).")] = 20, + where: Annotated[str | None, typer.Option("--where", show_default=False)] = None, +): + import urllib.error + import urllib.parse + + renderer = get_renderer() + target = cloud_target_or_local_error(where, renderer) + + params: list[tuple[str, Any]] = [("limit", min(max(limit, 1), 500))] + if name: + params.append(("name_contains", name)) + for t in (tags.split(",") if tags else []): + t = t.strip() + if t: + params.append(("include_tags", t)) + url = target.url("assets") + "?" + urllib.parse.urlencode(params) + + try: + _, body = http_request(url, target) + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e: + raise handle_cloud_http_error(renderer, e, operation="list") from e + + rows = (body or {}).get("assets") or [] + payload = { + "count": len(rows), + "assets": [ + { + "id": r.get("id"), + "name": r.get("name"), + "hash": r.get("hash"), + "mime_type": r.get("mime_type"), + "size": r.get("size"), + "tags": r.get("tags"), + "preview_url": r.get("preview_url"), + "job_id": r.get("job_id"), + "created_at": r.get("created_at"), + } + for r in rows + if isinstance(r, dict) + ], + } + renderer.emit(payload, command="assets library ls", where="cloud") + + +@app.command("ensure", help="Ensure you own an asset by content hash (borrows public/shared bytes, no re-upload).") +@tracking.track_command("assets") +def ensure_cmd( + hash: Annotated[str, typer.Option("--hash", help="Asset content hash (as returned by `assets library ls`).")], + tags: Annotated[ + str, + typer.Option("--tags", help="Comma-separated tags to attach (>=1 required by the API)."), + ] = "input", + where: Annotated[str | None, typer.Option("--where", show_default=False)] = None, +): + import urllib.error + + renderer = get_renderer() + target = cloud_target_or_local_error(where, renderer) + + tag_list = [t.strip() for t in tags.split(",") if t.strip()] or ["input"] + url = target.url("assets/from-hash") + try: + status, body = http_request(url, target, method="POST", body={"hash": hash, "tags": tag_list}) + except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e: + raise handle_cloud_http_error(renderer, e, operation="ensure") from e + + b = body or {} + payload = { + "id": b.get("id"), + "hash": b.get("hash", hash), + "created_new": status == 201, + } + renderer.emit(payload, command="assets library ensure", where="cloud") diff --git a/comfy_cli/command/cloud_http.py b/comfy_cli/command/cloud_http.py new file mode 100644 index 000000000..e36538e50 --- /dev/null +++ b/comfy_cli/command/cloud_http.py @@ -0,0 +1,105 @@ +"""Shared cloud-HTTP helpers used by ``comfy workflow``'s cloud-saved-workflow +subcommands (and by other commands that talk to Comfy Cloud's ``/api/*`` +surface, e.g. ``comfy assets library``). + +Extracted verbatim from ``comfy_cli/command/workflow.py`` so call sites in +multiple command modules can share one implementation instead of importing +underscore-prefixed privates across module boundaries. +""" + +from __future__ import annotations + +import json + +import typer + + +def cloud_target_or_local_error(where: str | None, renderer): + """Resolve a cloud Target, or emit ``cloud_only_command`` for a non-cloud target.""" + from comfy_cli.target import resolve_target + + target = resolve_target(where=where) + if not target.is_cloud: + renderer.error( + code="cloud_only_command", + message="This command requires Comfy Cloud; there is no local equivalent.", + hint="sign in with `comfy cloud login` and re-run with `--where cloud`", + ) + raise typer.Exit(code=1) + return target + + +def _authed_request( + url: str, target, *, method: str = "GET", data: bytes | None = None, content_type: str | None = None +): + """Build an authenticated urllib Request. The return type is annotated + loosely to keep urllib out of the module's top-level imports.""" + import urllib.request + + req = urllib.request.Request(url, data=data, method=method) + if target.api_key: + req.add_header("X-API-Key", target.api_key) + elif target.auth_token: + req.add_header("Authorization", f"Bearer {target.auth_token}") + if content_type: + req.add_header("Content-Type", content_type) + return req + + +def http_request( + url: str, target, *, method: str = "GET", body: dict | None = None, timeout: float = 30.0 +) -> tuple[int, dict | None]: + """Authed HTTP call returning (status, parsed_json_or_none). Raises + urllib errors verbatim so callers can surface the right error code.""" + import urllib.request + + data = json.dumps(body).encode("utf-8") if body is not None else None + ct = "application/json" if data is not None else None + req = _authed_request(url, target, method=method, data=data, content_type=ct) + with urllib.request.urlopen(req, timeout=timeout) as resp: + status = resp.status + raw = resp.read(64 * 1024 * 1024) # 64 MiB cap + if not raw: + return status, None + try: + return status, json.loads(raw) + except json.JSONDecodeError: + return status, None + + +def handle_cloud_http_error(renderer, e, *, operation: str, workflow_id: str | None = None) -> typer.Exit: + """Map HTTP failures to envelope codes. Returns an Exit to ``raise from``.""" + import urllib.error + + if isinstance(e, urllib.error.HTTPError): + body = (e.read() or b"")[:1000].decode("utf-8", "replace") + if e.code == 404: + renderer.error( + code="workflow_not_found", + message=f"no saved workflow with id {workflow_id!r}" + if workflow_id + else f"workflow not found ({operation})", + hint="list available workflows via `comfy --json workflow list`", + details={"workflow_id": workflow_id, "operation": operation}, + ) + elif e.code in (401, 403): + renderer.error( + code="cloud_unauthorized", + message=f"HTTP {e.code} during {operation}", + hint="re-run `comfy cloud login`", + details={"status": e.code}, + ) + else: + renderer.error( + code="cloud_http_error", + message=f"HTTP {e.code} during {operation}", + hint="check `details.body` for the server's message", + details={"status": e.code, "body": body, "operation": operation}, + ) + else: + renderer.error( + code="cloud_http_error", + message=f"{operation} failed: {e}", + hint="check network / `comfy auth whoami`", + ) + return typer.Exit(code=1) diff --git a/comfy_cli/command/code_search.py b/comfy_cli/command/code_search.py index 5dd3c6edc..2b925e669 100644 --- a/comfy_cli/command/code_search.py +++ b/comfy_cli/command/code_search.py @@ -6,7 +6,6 @@ from typing import Annotated from urllib.parse import quote -import requests import typer from rich.console import Console from rich.text import Text @@ -40,6 +39,10 @@ def _build_query(query: str, repo: str | None, count: int) -> str: def _fetch_results(query: str) -> dict: + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + response = requests.get(API_URL, params={"query": query}, timeout=REQUEST_TIMEOUT) response.raise_for_status() return response.json() @@ -168,6 +171,8 @@ def code_search( ] = False, ): """Search code across ComfyUI repositories.""" + import requests # deferred; see _fetch_results + built_query = _build_query(query, repo, count) try: diff --git a/comfy_cli/command/generate/emit.py b/comfy_cli/command/generate/emit.py index 6ff0b1521..55f8d1b64 100644 --- a/comfy_cli/command/generate/emit.py +++ b/comfy_cli/command/generate/emit.py @@ -39,6 +39,21 @@ class NodeSpec: constant (defaults the node requires but that ``generate`` doesn't surface). ``output`` selects the save node (IMAGE → SaveImage, VIDEO → SaveVideo) and the partner node's output port that carries the media. + + COMPLETENESS CONTRACT for ``fixed``: it must supply a default for EVERY + widget (non-link) input of the node — the *optional* schema section + included — unless the value always arrives via ``param_map``/``image_params``. + A schema-``optional`` input is not necessarily optional at execution time: + V3 nodes may declare an input ``optional=True`` while their ``execute()`` + signature has no Python default (ByteDanceImageToVideoNode's + ``seed``/``camera_fixed``/``watermark`` do exactly this), so a workflow + that omits it validates cleanly but crashes at run time with + "missing N required positional arguments". The ComfyUI frontend always + serializes every widget value, which is why UI-exported workflows never + hit this — the emitter must match that behavior. + ``tests/comfy_cli/command/generate/test_emit.py`` enforces the contract + against a recorded object_info snapshot + (``fixtures/partner_nodes_object_info.json``). """ node_class: str @@ -63,7 +78,23 @@ class NodeSpec: "aspect_ratio": "aspect_ratio", }, image_params={"image": "images", "images": "images"}, - fixed={"model": "gemini-2.5-flash-image", "seed": 42}, + fixed={ + "model": "gemini-2.5-flash-image", + "seed": 42, + "aspect_ratio": "auto", + "response_modalities": "IMAGE+TEXT", + # Schema default (snapshot 2026-07); the node's execute() falls back + # to "" but the UI serializes this steering prompt, so match it. + "system_prompt": ( + "You are an expert image-generation engine. You must ALWAYS produce an image.\n" + "Interpret all user input—regardless of format, intent, or abstraction—as literal " + "visual directives for image composition.\n" + "If a prompt is conversational or lacks specific visual details, you must creatively " + "invent a concrete visual scenario that depicts the concept.\n" + "Prioritize generating the visual representation above any text, formatting, or " + "conversational requests." + ), + }, output="IMAGE", ), # ByteDance Seedance image-to-video. Node: ByteDanceImageToVideoNode. @@ -73,9 +104,15 @@ class NodeSpec: "prompt": "prompt", "model": "model", "resolution": "resolution", + # The proxy flag is --ratio; the node input is aspect_ratio. + "ratio": "aspect_ratio", "aspect_ratio": "aspect_ratio", "duration": "duration", "seed": "seed", + # Proxy flag --camerafixed; node input camera_fixed. + "camerafixed": "camera_fixed", + "watermark": "watermark", + "generate_audio": "generate_audio", }, image_params={"image": "image"}, fixed={ @@ -83,6 +120,13 @@ class NodeSpec: "resolution": "720p", "aspect_ratio": "16:9", "duration": 5, + # Schema-optional but positionally REQUIRED by execute() — omitting + # any of these fails the run with "missing required positional + # arguments" (observed live on cloud). See the NodeSpec docstring. + "seed": 0, + "camera_fixed": False, + "watermark": False, + "generate_audio": False, }, output="VIDEO", ), @@ -179,7 +223,7 @@ def build_workflow(model: str, values: dict[str, Any], *, output_prefix: str = " raw = values.get(flag) if raw is None: continue - if isinstance(raw, (list, tuple)): + if isinstance(raw, list | tuple): raise EmitError( f"--{flag} received multiple files, but emit-workflow currently " "maps this input to a single LoadImage node." diff --git a/comfy_cli/command/install.py b/comfy_cli/command/install.py index ed5a0c275..7683a719c 100755 --- a/comfy_cli/command/install.py +++ b/comfy_cli/command/install.py @@ -6,8 +6,6 @@ from typing import TypedDict from urllib.parse import urlparse -import git -import requests import semver import typer from rich.console import Console @@ -192,6 +190,10 @@ def execute( sys.exit(1) elif not check_comfy_repo(repo_dir)[0]: + # Imported lazily: GitPython costs ~90ms to import and is only needed + # on this error-reporting path. + import git + # Get actual remote URL for better error message try: repo = git.Repo(repo_dir) @@ -602,6 +604,10 @@ def get_latest_release(repo_owner: str, repo_name: str) -> GithubRelease | None: if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + try: response = requests.get(url, headers=headers, timeout=5) @@ -679,6 +685,8 @@ def fetch_pr_info(repo_owner: str, repo_name: str, pr_number: int) -> PRInfo: if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" + import requests # deferred; see fetch_github_release_data + try: response = requests.get(url, headers=headers, timeout=10) @@ -714,6 +722,8 @@ def find_pr_by_branch(repo_owner: str, repo_name: str, username: str, branch: st if github_token := os.getenv("GITHUB_TOKEN"): headers["Authorization"] = f"Bearer {github_token}" + import requests # deferred; see fetch_github_release_data + try: response = requests.get(url, headers=headers, params=params, timeout=10) response.raise_for_status() diff --git a/comfy_cli/command/models/models.py b/comfy_cli/command/models/models.py index a654a18ed..5410efe6f 100644 --- a/comfy_cli/command/models/models.py +++ b/comfy_cli/command/models/models.py @@ -5,7 +5,6 @@ from typing import Annotated from urllib.parse import parse_qs, unquote, urlparse -import requests import typer from rich.markup import escape @@ -152,6 +151,10 @@ def check_civitai_url(url: str) -> tuple[bool, bool, int | None, int | None]: def request_civitai_model_version_api(version_id: int, headers: dict | None = None): + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + # Make a request to the CivitAI API to get the model information response = requests.get( f"https://civitai.com/api/v1/model-versions/{version_id}", @@ -171,6 +174,8 @@ def request_civitai_model_version_api(version_id: int, headers: dict | None = No def request_civitai_model_api(model_id: int, version_id: int = None, headers: dict | None = None): + import requests # deferred; see request_civitai_model_version_api + # Make a request to the CivitAI API to get the model information response = requests.get(f"https://civitai.com/api/v1/models/{model_id}", headers=headers, timeout=10) response.raise_for_status() # Raise an error for bad status codes diff --git a/comfy_cli/command/preview.py b/comfy_cli/command/preview.py index 90680ac5e..91389df59 100644 --- a/comfy_cli/command/preview.py +++ b/comfy_cli/command/preview.py @@ -110,9 +110,9 @@ def build_preview_cmd( return base + ["-frames:v", "1", "-vf", f"scale='min({width},iw)':-1", out_path] -def _ffprobe(path: Path) -> dict: +def _ffprobe(path: Path, ffprobe_bin: str = "ffprobe") -> dict: proc = subprocess.run( - ["ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(path)], + [ffprobe_bin, "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(path)], capture_output=True, text=True, ) @@ -121,6 +121,32 @@ def _ffprobe(path: Path) -> dict: return json.loads(proc.stdout or "{}") +def _resolve_ffmpeg() -> str | None: + """System ``ffmpeg``, else the static binary bundled with imageio-ffmpeg + (if installed). Lets `comfy preview` render on a box with no system ffmpeg.""" + found = shutil.which("ffmpeg") + if found: + return found + try: + import imageio_ffmpeg + + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + return None + + +_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"} +_AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"} + + +def _classify_by_ext(path: Path) -> dict: + """Fallback classification when ffprobe is unavailable (e.g. only the + imageio-ffmpeg static ffmpeg is present): pick kind from the extension.""" + ext = path.suffix.lower() + kind = "image" if ext in _IMAGE_EXTS else "audio" if ext in _AUDIO_EXTS else "video" + return {"kind": kind, "width": None, "height": None, "fps": None, "duration": None, "has_audio": None} + + @tracking.track_command("preview") def preview_cmd( file: Annotated[Path, typer.Argument(help="Image, video, or audio file to preview.")], @@ -137,19 +163,26 @@ def preview_cmd( if not file.is_file(): renderer.error(code="preview_input_not_found", message=f"File not found: {file}", hint="check the path") raise typer.Exit(code=1) - if not (shutil.which("ffmpeg") and shutil.which("ffprobe")): + ffmpeg_bin = _resolve_ffmpeg() + if not ffmpeg_bin: renderer.error( code="ffmpeg_unavailable", - message="ffmpeg/ffprobe not found on PATH — `comfy preview` needs them.", - hint="install ffmpeg (e.g. `brew install ffmpeg` / `apt install ffmpeg`)", + message="ffmpeg not found — `comfy preview` needs it to render.", + hint="install ffmpeg (`brew install ffmpeg` / `apt install ffmpeg`) or `pip install imageio-ffmpeg`", ) raise typer.Exit(code=1) - try: - info = classify_streams(_ffprobe(file)) - except (RuntimeError, json.JSONDecodeError) as e: - renderer.error(code="preview_failed", message=f"Could not probe {file}: {e}") - raise typer.Exit(code=1) from e + ffprobe_bin = shutil.which("ffprobe") + if ffprobe_bin: + try: + info = classify_streams(_ffprobe(file, ffprobe_bin)) + except (RuntimeError, json.JSONDecodeError) as e: + renderer.error(code="preview_failed", message=f"Could not probe {file}: {e}") + raise typer.Exit(code=1) from e + else: + # ffmpeg present (likely the imageio-ffmpeg static build) but no ffprobe: + # classify by extension and let ffmpeg render the preview anyway. + info = _classify_by_ext(file) if info["kind"] == "unknown": renderer.error( code="preview_unsupported_media", @@ -168,6 +201,7 @@ def preview_cmd( cmd = build_preview_cmd( info["kind"], str(file), str(out_path), grid=(cols, rows), width=width, duration=info["duration"] ) + cmd[0] = ffmpeg_bin # use the resolved binary (system or imageio-ffmpeg's static build) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0 or not out_path.is_file(): renderer.error( diff --git a/comfy_cli/command/project.py b/comfy_cli/command/project.py index cb6f19dd1..11ed5a391 100644 --- a/comfy_cli/command/project.py +++ b/comfy_cli/command/project.py @@ -87,6 +87,11 @@ def _assets_callback(): """ +from comfy_cli.command.assets_library import app as _assets_library_app # noqa: E402 + +assets_app.add_typer(_assets_library_app, name="library") + + # The marker `comfy project init` writes — deliberately literal and minimal. # The where default is resolved at init time (flag, else auto-detect) so a # local-only machine never gets a project that routes every command to cloud. diff --git a/comfy_cli/command/run/__init__.py b/comfy_cli/command/run/__init__.py index fc230d006..142494e3d 100644 --- a/comfy_cli/command/run/__init__.py +++ b/comfy_cli/command/run/__init__.py @@ -493,6 +493,7 @@ def execute_cloud( timeout: int = 600, notify: bool = False, print_prompt: bool = False, + workflow_id: str | None = None, preloaded: tuple[dict, str, bool] | None = None, ): """Run a workflow against Comfy Cloud via the stored OAuth session. @@ -521,12 +522,15 @@ def execute_cloud( # exporter and `comfy templates fetch`) have to be lowered to the API # shape before submit. We do it client-side using the cloud snapshot # of object_info — the cloud server has no /workflow/convert endpoint. - from comfy_cli.cql.engine import _load_from_target + # Routed through resilient_load_object_info so COMFY_OBJECT_INFO_FILE + # (a pre-warmed/baked catalog, e.g. from an agent host) is honored + # before falling back to a live multi-MB /object_info fetch. + from comfy_cli.cql.loader import resilient_load_object_info if renderer.is_pretty(): pprint("[yellow]Detected UI-format workflow, converting to API format…[/yellow]") try: - object_info = _load_from_target(mode="cloud") + object_info = resilient_load_object_info(mode="cloud") except Exception as e: # noqa: BLE001 renderer.error( code="cql_no_graph", @@ -586,10 +590,12 @@ def execute_cloud( # Pre-submit validation via pure-Python CQL engine. # Cloud path uses cached/bundled object_info (no live server needed). + # resilient_load_object_info honors COMFY_OBJECT_INFO_FILE first (the + # baked/offline catalog an agent host provides) before a live fetch. try: - from comfy_cli.cql.engine import _load_from_target + from comfy_cli.cql.loader import resilient_load_object_info - cloud_object_info = _load_from_target(mode="cloud") + cloud_object_info = resilient_load_object_info(mode="cloud") except Exception: # noqa: BLE001 cloud_object_info = {} @@ -617,9 +623,9 @@ def execute_cloud( try: if not wait and renderer.is_pretty(): with renderer.console().status("[cyan]Submitting to Comfy Cloud…", spinner="dots"): - submit = client.submit_prompt(parsed_workflow, client_id) + submit = client.submit_prompt(parsed_workflow, client_id, workflow_id=workflow_id) else: - submit = client.submit_prompt(parsed_workflow, client_id) + submit = client.submit_prompt(parsed_workflow, client_id, workflow_id=workflow_id) except Unauthenticated as e: renderer.error(code="cloud_unauthorized", message=str(e), hint="run: comfy cloud login") raise typer.Exit(code=1) from e diff --git a/comfy_cli/command/run/watcher.py b/comfy_cli/command/run/watcher.py index 9a8d3c4cd..87b03121c 100644 --- a/comfy_cli/command/run/watcher.py +++ b/comfy_cli/command/run/watcher.py @@ -8,6 +8,7 @@ from __future__ import annotations +import os import subprocess import sys @@ -15,6 +16,24 @@ from comfy_cli.output import rprint as pprint +def _no_watch_requested() -> bool: + """``COMFY_NO_WATCH=1`` (or any other truthy value) suppresses the + detached watcher subprocess entirely. + + This is the env kill switch for agentic callers: the cloud agent has its + own native job-wait (Redis pub/sub + a reconcile GET) and has no use for + a second, credential-holding, ``start_new_session=True`` process that + outlives the parent and polls the jobs API for up to 6h. Checked as an + env var (rather than threaded through every call site) so a host can set + it once in the subprocess's environment without touching argv; ``comfy + run --no-watch`` sets the same variable for interactive use. + """ + value = os.environ.get("COMFY_NO_WATCH") + if value is None: + return False + return value.strip().lower() not in {"", "0", "false", "no", "off"} + + def _tail_state_file(prompt_id: str, *, seconds: float = 8.0) -> None: """Pretty-mode only: poll the state file for up to ``seconds`` showing live status transitions, then return. The background watcher keeps @@ -84,8 +103,11 @@ def _spawn_watcher( callers can find it there if needed. Returns ``True`` on success, ``False`` if the subprocess could not be - spawned. + spawned — or if suppressed entirely via ``COMFY_NO_WATCH=1`` + (see ``_no_watch_requested``). """ + if _no_watch_requested(): + return False argv = [sys.executable, "-m", "comfy_cli", "_watch", "_watch-job", prompt_id, "--where", where] if host: argv += ["--host", host] diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index d953c10e0..c571000ed 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -72,7 +72,7 @@ def _load_workflow_or_fail(renderer, path: str) -> tuple[Path, dict[str, Any]]: return p, data -def _get_graph(input_path: str | None, host: str | None, port: int | None, on_stale=None): +def _get_graph(input_path: str | None, host: str | None, port: int | None, on_stale=None, where: str | None = None): """Build a Graph from the resolved object_info source. The live (non-``--input``) fetch goes through ``resilient_load_object_info``, @@ -93,7 +93,9 @@ def _get_graph(input_path: str | None, host: str | None, port: int | None, on_st # Live fetch: resolve mode from global routing chain, then use resilient loader. from comfy_cli import where as where_module - decision = where_module.resolve_default() + # Honor an explicit --where (threaded from the agent edit commands) via + # the convenience wrapper, which folds in the config/project precedence. + decision = where_module.resolve_default(where) mode = "cloud" if decision.target is where_module.WhereTarget.CLOUD else "local" from comfy_cli.cql.loader import resilient_load_object_info @@ -1164,3 +1166,21 @@ def delete_cmd( help="Project a workflow (template or API JSON) into a reusable fragment — the inverse of compose.", )(_wfrag.decompose_cmd) app.add_typer(_wfrag.fragment_app, name="fragment") + + +# --------------------------------------------------------------------------- +# Structured, CRDT-ready edit primitives (add-node / connect / set-widget / +# delete). Implemented in workflow_edit.py; mounted here so the surface stays +# under `comfy workflow`. Each emits a replayable op in `data.op`. +# --------------------------------------------------------------------------- + +from comfy_cli.command import workflow_edit as _wedit # noqa: E402 + +app.command("add-node", help="Add a node to the graph; emits an add_node op.")(_wedit.add_node_cmd) +app.command("connect", help="Wire an output slot to an input slot; emits a connect op.")(_wedit.connect_cmd) +app.command("set-widget", help="Set a widget by name (`.`); emits a set_widget op.")(_wedit.set_widget_cmd) +app.command("delete-node", help="Delete a node and its links; emits a delete_node op.")(_wedit.delete_cmd) +app.command("ls-nodes", help="List nodes (id/type/title) in a workflow file.")(_wedit.ls_nodes_cmd) +app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")(_wedit.apply_cmd) +app.command("capture", help="Project a workflow into a reusable recipe (the op-batch that rebuilds it).")(_wedit.capture_cmd) +app.command("foreach", help="Instantiate a recipe over N param-sets → N workflows (bulk generation).")(_wedit.foreach_cmd) diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py new file mode 100644 index 000000000..8aa476580 --- /dev/null +++ b/comfy_cli/command/workflow_edit.py @@ -0,0 +1,537 @@ +"""``comfy workflow add-node/connect/set-widget/delete`` — structured, +CRDT-ready edit primitives for frontend-format ComfyUI workflows. + +Each command mutates the workflow file in place (or ``--stdout``) AND emits a +replayable operation in the envelope's ``data.op``. The op is what a CRDT/merge +consumer (the cloud agent) applies; the file write is the single-writer local +path. Both come from the same ``comfy_cli.workflow_ops`` core, so a local edit +and a server-side merge stay in lock-step. + +Node/link identity is leaderless (random 53-bit ints) so concurrent edits never +collide; widgets are addressed by name, not array index. See ``workflow_ops``. +""" + +from __future__ import annotations + +import json +from typing import Annotated, Any + +import typer + +from comfy_cli import tracking, workflow_ops +from comfy_cli.command.workflow import ( + _atomic_write_text, + _get_graph, + _load_workflow_or_fail, + _parse_value, +) +from comfy_cli.output import get_renderer, rprint + + +def _split_addr(addr: str, renderer) -> tuple[Any, str]: + """Split ``.`` → (node_id, name). node_id is int when numeric.""" + if "." not in addr: + renderer.error( + code="workflow_edit_invalid", + message=f"expected `.`, got {addr!r}", + hint="example: `3.steps` — run `comfy workflow slots ` to list node ids", + ) + raise typer.Exit(code=1) + node_str, _, name = addr.partition(".") + node_str = node_str.strip() + node_id: Any = int(node_str) if node_str.lstrip("-").isdigit() else node_str + return node_id, name.strip() + + +def _finish(renderer, p, workflow: dict, op: dict, base_version: int, stdout: bool, command: str) -> None: + """Serialize the mutated workflow (file or stdout) and emit the op envelope.""" + workflow_ops.strip_internal(workflow) + serialized = json.dumps(workflow, indent=2) + wrote: str | None = None + if stdout: + import sys + + sys.stdout.write(serialized) + sys.stdout.write("\n") + else: + _atomic_write_text(p, serialized) + wrote = str(p) + payload = { + "workflow": str(p), + "op": op, + "base_version": base_version, + "version": base_version + 1, + "wrote": wrote, + } + if op.get("warnings"): + payload["warnings"] = op["warnings"] + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] {op['op']} → [dim]{p}[/dim]") + renderer.emit(payload, command=command, changed=True) + + +def _graph_or_exit(input_path, host, port, renderer, where=None): + return _get_graph(input_path, host, port, where=where) + + +# --------------------------------------------------------------------------- +# add-node +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def add_node_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + class_type: Annotated[str, typer.Argument(help="Node class_type, e.g. KSampler.")], + at: Annotated[ + str | None, + typer.Option("--at", show_default=False, help="Canvas position 'x,y' for the new node."), + ] = None, + actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", + base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, + stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, + input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, +): + renderer = get_renderer() + renderer.command = "workflow add-node" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + pos = None + if at: + try: + pos = [float(x) for x in at.split(",", 1)] + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=f"--at must be 'x,y': {e}") + raise typer.Exit(code=1) from e + try: + workflow, op = workflow_ops.add_node( + workflow, graph, class_type, pos=pos, actor=actor, base_version=base_version + ) + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=str(e), hint="run `comfy nodes types` to list class_types") + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow add-node") + + +# --------------------------------------------------------------------------- +# set-widget +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def set_widget_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + addr: Annotated[str, typer.Argument(help="Widget address `.`.")], + value: Annotated[str, typer.Argument(help="New value (parsed as JSON, else literal string).")], + actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", + base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, + stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, + input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, +): + renderer = get_renderer() + renderer.command = "workflow set-widget" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + node_id, widget = _split_addr(addr, renderer) + # Subgraph addresses — a promoted input on a subgraph instance (flat + # ``57.text``, exactly what `slots` advertises) or an interior node (nested + # ``57/27.text``) — are resolved inside ``workflow_ops.set_widget`` against + # the same CQL resolver `slots` uses, and emit a replayable op that writes + # back into the subgraph definition. + try: + workflow, op = workflow_ops.set_widget( + workflow, graph, node_id, widget, _parse_value(value), actor=actor, base_version=base_version + ) + except ValueError as e: + renderer.error( + code="workflow_edit_invalid", + message=str(e), + hint="run `comfy workflow slots ` to list widget addresses", + ) + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow set-widget") + + +# --------------------------------------------------------------------------- +# connect +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def connect_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + source: Annotated[str, typer.Argument(help="Source `.` (slot name or index).")], + target: Annotated[str, typer.Argument(help="Target `.` (slot name or index).")], + actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", + base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, + stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, + input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, +): + renderer = get_renderer() + renderer.command = "workflow connect" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + from_node, from_slot = _split_addr(source, renderer) + to_node, to_slot = _split_addr(target, renderer) + try: + workflow, op = workflow_ops.connect( + workflow, graph, from_node, from_slot, to_node, to_slot, actor=actor, base_version=base_version + ) + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow connect") + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def delete_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + node: Annotated[str, typer.Argument(help="Node id to delete.")], + actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", + base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, + stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, + input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, +): + renderer = get_renderer() + renderer.command = "workflow delete-node" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + node_id: Any = int(node) if node.lstrip("-").isdigit() else node + try: + workflow, op = workflow_ops.delete_node(workflow, graph, node_id, actor=actor, base_version=base_version) + except ValueError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + _finish(renderer, p, workflow, op, base_version, stdout, "workflow delete") + + +# --------------------------------------------------------------------------- +# ls-nodes — recover node ids/types (so an agent can address minted nodes) +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def ls_nodes_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], +): + renderer = get_renderer() + renderer.command = "workflow ls-nodes" + p, workflow = _load_workflow_or_fail(renderer, file) + rows = [] + for n in workflow.get("nodes") or []: + if not isinstance(n, dict): + continue + rows.append( + { + "id": n.get("id"), + "type": n.get("type"), + "title": n.get("title") or (n.get("properties") or {}).get("Node name for S&R"), + } + ) + payload = {"workflow": str(p), "count": len(rows), "nodes": rows} + if renderer.is_pretty(): + from rich.table import Table + + tbl = Table(show_header=True, header_style="bold") + tbl.add_column("id", no_wrap=True) + tbl.add_column("type") + tbl.add_column("title", style="dim") + for r in rows: + tbl.add_row(str(r["id"]), str(r["type"]), str(r["title"] or "")) + renderer.console().print(tbl) + renderer.emit(payload, command="workflow ls-nodes") + + +# --------------------------------------------------------------------------- +# capture — project a graph into a reusable recipe (the decompose analog) +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def capture_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON to capture.")], + name: Annotated[str | None, typer.Option("--name", show_default=False, help="Recipe name.")] = None, + param: Annotated[ + list[str] | None, + typer.Option( + "--param", + show_default=False, + help="Lift a widget to a recipe param: `.=` (repeatable).", + ), + ] = None, + out: Annotated[ + str | None, + typer.Option("--out", "-o", show_default=False, help="Write the recipe JSON here (else stdout)."), + ] = None, + input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, +): + """Project a workflow into a reusable recipe — the op-batch that rebuilds it. + `apply` that recipe onto an empty graph to reproduce the workflow; edit a value + to a `${param}` to make it parameterized.""" + from pathlib import Path + + renderer = get_renderer() + renderer.command = "workflow capture" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + lift: dict[tuple[Any, str], str] = {} + for spec in param or []: + if "=" not in spec or "." not in spec.split("=", 1)[0]: + renderer.error( + code="workflow_edit_invalid", + message=f"--param must be `.=`, got {spec!r}", + ) + raise typer.Exit(code=1) + target, _, pname = spec.partition("=") + node_str, _, widget = target.partition(".") # node id has no dot; widget may (model.resolution) + node_id: Any = int(node_str) if node_str.lstrip("-").isdigit() else node_str + lift[(node_id, widget)] = pname.strip() + try: + recipe = workflow_ops.capture_recipe(workflow, graph, name=name or p.stem, lift=lift) + except workflow_ops.RecipeError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + + serialized = json.dumps(recipe, indent=2) + wrote: str | None = None + if out: + out_path = Path(out).expanduser() + out_path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_text(out_path, serialized) + wrote = str(out_path) + elif renderer.is_pretty(): + import sys + + sys.stdout.write(serialized + "\n") + payload = { + "recipe": recipe["recipe"], + "op_count": len(recipe["ops"]), + "out": wrote or "stdout", + "recipe_doc": recipe, + } + if renderer.is_pretty() and wrote: + rprint(f"[bold green]✓[/bold green] captured {len(recipe['ops'])} ops → [dim]{wrote}[/dim]") + renderer.emit(payload, command="workflow capture") + + +# --------------------------------------------------------------------------- +# apply — batch: one object_info load, many edits, aliases for just-made nodes +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def apply_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + ops_file: Annotated[ + str, + typer.Option("--ops", help="Recipe file (JSON array of ops, or {params, ops}), or '-' for stdin."), + ], + param: Annotated[ + list[str] | None, + typer.Option("--param", show_default=False, help="Recipe param as key=value; repeatable."), + ] = None, + actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", + base_version: Annotated[int, typer.Option("--base-version", help="Draft version this batch is based on.")] = 0, + stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, + input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, +): + """Apply a batch of edits in one pass — the catalog loads once, and an + `add_node` spec may set `"as": ""` so later specs reference the + minted node by alias instead of a captured id.""" + renderer = get_renderer() + renderer.command = "workflow apply" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + + if ops_file == "-": + import sys + + raw = sys.stdin.read() + else: + from pathlib import Path + + try: + raw = Path(ops_file).expanduser().read_text(encoding="utf-8") + except OSError as e: + renderer.error(code="workflow_edit_invalid", message=f"cannot read --ops file: {e}") + raise typer.Exit(code=1) from e + try: + doc = json.loads(raw) + except json.JSONDecodeError as e: + renderer.error(code="workflow_edit_invalid", message=f"--ops is not valid JSON: {e}") + raise typer.Exit(code=1) from e + + # A recipe is `{params?, ops}` (or a bare op list); `${param}` holes are filled + # from --param with strict validation (no silent blanks). + provided: dict[str, str] = {} + for kv in param or []: + if "=" not in kv: + renderer.error(code="workflow_edit_invalid", message=f"--param must be key=value, got {kv!r}") + raise typer.Exit(code=1) + k, _, v = kv.partition("=") + provided[k.strip()] = v + try: + specs, params_decl = workflow_ops.parse_recipe(doc) + params = workflow_ops.resolve_params(params_decl, provided) + specs = workflow_ops.substitute_params(specs, params) + except workflow_ops.RecipeError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + + try: + workflow, ops, aliases = workflow_ops.apply_specs( + workflow, graph, specs, actor=actor, base_version=base_version + ) + except (ValueError, KeyError) as e: + # Atomic batch: nothing is written if any spec fails. + renderer.error(code="workflow_edit_invalid", message=f"batch failed: {e}") + raise typer.Exit(code=1) from e + + workflow_ops.strip_internal(workflow) + serialized = json.dumps(workflow, indent=2) + wrote: str | None = None + if stdout: + import sys + + sys.stdout.write(serialized + "\n") + else: + _atomic_write_text(p, serialized) + wrote = str(p) + payload = { + "workflow": str(p), + "count": len(ops), + "ops": ops, + "aliases": aliases, + "base_version": base_version, + "version": base_version + len(ops), + "wrote": wrote, + } + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] applied {len(ops)} edit(s) → [dim]{p}[/dim]") + renderer.emit(payload, command="workflow apply", changed=True) + + +# --------------------------------------------------------------------------- +# foreach — instantiate a recipe over N param-sets → N ready-to-run workflows +# --------------------------------------------------------------------------- + + +def _load_param_sets(raw: str, renderer) -> list[dict]: + """Param-sets are a JSON array of objects, a single object, or JSONL.""" + raw = raw.strip() + try: + doc = json.loads(raw) + return doc if isinstance(doc, list) else [doc] + except json.JSONDecodeError: + pass + sets: list[dict] = [] + for ln in raw.splitlines(): + ln = ln.strip() + if not ln: + continue + try: + sets.append(json.loads(ln)) + except json.JSONDecodeError as e: + renderer.error(code="workflow_edit_invalid", message=f"--params line is not JSON: {e}") + raise typer.Exit(code=1) from e + return sets + + +@tracking.track_command("workflow") +def foreach_cmd( + recipe_file: Annotated[str, typer.Argument(help="Recipe file: {params, ops}.")], + params_file: Annotated[ + str, + typer.Option("--params", help="Param-sets: a JSON array of objects, one object, or JSONL; '-' for stdin."), + ], + out_dir: Annotated[str, typer.Option("--out-dir", help="Directory to write the N materialized workflows.")], + actor: Annotated[str, typer.Option("--actor")] = "cli", + base_version: Annotated[int, typer.Option("--base-version")] = 0, + input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, +): + """Instantiate a recipe over N param-sets → N ready-to-run workflows (bulk). + Run them with `comfy run --workflow --where cloud`.""" + import sys + from pathlib import Path + + renderer = get_renderer() + renderer.command = "workflow foreach" + graph = _graph_or_exit(input_path, host, port, renderer, where) + try: + doc = json.loads(Path(recipe_file).expanduser().read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + renderer.error(code="workflow_edit_invalid", message=f"cannot read recipe: {e}") + raise typer.Exit(code=1) from e + raw = sys.stdin.read() if params_file == "-" else _read_text_or_exit(renderer, params_file) + param_sets = _load_param_sets(raw, renderer) + if not param_sets: + renderer.error(code="workflow_edit_invalid", message="--params yielded no param-sets") + raise typer.Exit(code=1) + + try: + specs_template, params_decl = workflow_ops.parse_recipe(doc) + except workflow_ops.RecipeError as e: + renderer.error(code="workflow_edit_invalid", message=str(e)) + raise typer.Exit(code=1) from e + + name = (doc.get("recipe") if isinstance(doc, dict) else None) or Path(recipe_file).expanduser().stem + out = Path(out_dir).expanduser() + out.mkdir(parents=True, exist_ok=True) + written: list[str] = [] + try: + for i, pset in enumerate(param_sets): + if not isinstance(pset, dict): + raise workflow_ops.RecipeError(f"param-set #{i} must be a JSON object") + params = workflow_ops.resolve_params(params_decl, {k: str(v) for k, v in pset.items()}) + specs = workflow_ops.substitute_params(specs_template, params) + wf: dict = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, _ops, _aliases = workflow_ops.apply_specs(wf, graph, specs, actor=actor, base_version=base_version) + workflow_ops.strip_internal(wf) + target = out / f"{name}_{i:03d}.json" + _atomic_write_text(target, json.dumps(wf, indent=2)) + written.append(str(target)) + except (workflow_ops.RecipeError, ValueError, KeyError) as e: + renderer.error(code="workflow_edit_invalid", message=f"foreach failed: {e}") + raise typer.Exit(code=1) from e + + payload = {"recipe": name, "count": len(written), "out_dir": str(out), "written": written} + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] materialized {len(written)} workflow(s) → [dim]{out}[/dim]") + rprint("[dim]run each: comfy run --workflow --where cloud[/dim]") + renderer.emit(payload, command="workflow foreach", changed=bool(written)) + + +def _read_text_or_exit(renderer, path: str) -> str: + from pathlib import Path + + try: + return Path(path).expanduser().read_text(encoding="utf-8") + except OSError as e: + renderer.error(code="workflow_edit_invalid", message=f"cannot read --params file: {e}") + raise typer.Exit(code=1) from e diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index a8068aaad..e8ffcb2c0 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -11,12 +11,12 @@ import copy import difflib +import hashlib as _hashlib import json import logging import urllib.error import urllib.parse import urllib.request -import uuid as _uuid from collections import defaultdict from dataclasses import dataclass, field from typing import Any @@ -40,6 +40,10 @@ class PortOptions: multiline: bool = False control_after_generate: bool = False force_input: bool = False + # For COMFY_DYNAMICCOMBO_V3: the raw options list ({key, inputs} dicts) so the + # engine can expand key-dependent sub-widgets (e.g. model → model.resolution), + # matching the converter. None for ordinary inputs. + dynamic_options: list | None = None @dataclass @@ -65,6 +69,52 @@ def autogrow_slot_example(self) -> str: stem = self.name[:-1] if self.name.endswith("s") else self.name return f"{self.name}.{stem}0, {self.name}.{stem}1, …" + def canonical_combo(self, value: Any) -> Any | None: + """Map a *mangled* COMBO value to the real option it clearly means, or + None if it can't be resolved unambiguously. + + A model name is one of these enum options, but an LLM tends to rebuild it + from memory — adding a directory prefix (``checkpoints/foo.safetensors`` + when the option is bare ``foo.safetensors``), dropping a subfolder, or + drifting case. The filename is almost always right, so we match by + basename (case-insensitive) and, only when EXACTLY ONE option matches, + return it. Ambiguous or unmatched values return None so the caller still + surfaces ``unknown_enum_value``. Exact values return None (nothing to do). + """ + if self.type != "COMBO" or not self.enum_values: + return None + opts = [str(e) for e in self.enum_values] + s = str(value) + if s in opts: + return None + base = s.rsplit("/", 1)[-1].lower() + matches = [o for o in opts if o.rsplit("/", 1)[-1].lower() == base] + if len(matches) == 1: + return matches[0] + ci = [o for o in opts if o.lower() == s.lower()] + if len(ci) == 1: + return ci[0] + return None + + def suggest_combo(self, value: Any, *, limit: int = 5) -> list[str]: + """Closest real options to a rejected COMBO value, for a ``did_you_mean`` + hint — so an unavailable model points at the nearest available one the + agent can substitute or offer, instead of a dead value.""" + if self.type != "COMBO" or not self.enum_values: + return [] + import difflib + + opts = [str(e) for e in self.enum_values] + base = str(value).rsplit("/", 1)[-1] + bases = [o.rsplit("/", 1)[-1] for o in opts] + out: list[str] = [] + for g in difflib.get_close_matches(base, bases, n=limit, cutoff=0.5): + for o in opts: + if o.rsplit("/", 1)[-1] == g and o not in out: + out.append(o) + break + return out[:limit] + def validate_shape(self, value: Any) -> str | None: """Hard-reject on JSON-shape mismatch. Returns error message or None.""" if self.type == "INT": @@ -107,14 +157,17 @@ def validate_catalog(self, value: Any) -> list[dict]: candidates.add(str(int(value))) enum_str = {str(e) for e in self.enum_values} if not (candidates & enum_str): - warnings.append( - { - "code": "unknown_enum_value", - "field": self.name, - "message": f"{value!r} not in {len(self.enum_values)} known options for {self.name}", - "valid_options": list(self.enum_values), - } - ) + warning = { + "code": "unknown_enum_value", + "field": self.name, + "message": f"{value!r} not in {len(self.enum_values)} known options for {self.name}", + "valid_options": list(self.enum_values), + } + suggestions = self.suggest_combo(value) + if suggestions: + warning["did_you_mean"] = suggestions + warning["message"] += f" — closest: {', '.join(suggestions)}" + warnings.append(warning) if self.type in ("INT", "FLOAT", "NUMBER") and isinstance(value, int | float): if self.options.min is not None and value < self.options.min: warnings.append( @@ -267,6 +320,13 @@ def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions]: # `duration`) must stay [4, 8, 12], not ["4","8","12"], so `nodes # show` is truthful and agents pass the type the cloud accepts. return first, True, list(options), port_opts + # Dynamic combo (COMFY_DYNAMICCOMBO_V3): options are {key, inputs} dicts. + # It IS a widget (the frontend renders a selector + key-dependent + # sub-widgets); the selector's choices are the keys. Capture the tree so + # widget_order can expand the sub-widgets — matching the API converter. + if isinstance(options, list) and options and all(isinstance(v, dict) and "key" in v for v in options): + port_opts.dynamic_options = options + return first, True, [v["key"] for v in options], port_opts return first, False, [], port_opts if isinstance(first, list): @@ -276,6 +336,34 @@ def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions]: return "UNKNOWN", False, [], port_opts +def _dynamic_sub_widget_names(base: str, options: list) -> list[str]: + """Sub-widget names a dynamic combo expands to, from the first (default) key — + e.g. ``model`` → ``["model.resolution"]``. Static mirror of the converter's + value-driven ``_dynamic_combo_sub_inputs`` (uses the first key, not a selection).""" + return [name for name, _ in _dynamic_sub_widget_defaults(base, options).items()] + + +def _dynamic_sub_widget_defaults(base: str, options: list) -> dict[str, Any]: + """``{f"{base}.{sub}": default}`` for the first key's sub-inputs.""" + if not options or not isinstance(options[0], dict): + return {} + sub_def = options[0].get("inputs") + if not isinstance(sub_def, dict): + return {} + out: dict[str, Any] = {} + for section in ("required", "optional"): + section_def = sub_def.get(section) or {} + if not isinstance(section_def, dict): + continue + for sub_name, spec in section_def.items(): + _t, _e, enum_values, opts = _parse_input_spec(spec) + default = opts.default + if default is None and enum_values: + default = enum_values[0] + out[f"{base}.{sub_name}"] = default + return out + + def _is_scalar_choice(v: Any) -> bool: """A combo option is enumerable only if it's a scalar. Dynamic combos (COMFY_DYNAMICCOMBO_V3) carry dict options describing sub-inputs — those @@ -688,10 +776,36 @@ def widget_order(self, class_name: str) -> list[str]: if p.is_link: continue order.append(p.name) + if p.options.dynamic_options: + order.extend(_dynamic_sub_widget_names(p.name, p.options.dynamic_options)) if p.options.control_after_generate: order.append("control_after_generate") return order + def widget_defaults(self, class_name: str) -> dict[str, Any]: + """Default value per widget-order name — including dynamic-combo selectors + (first key), their sub-widgets, and control_after_generate. Used by + ``add-node`` so a fresh node is runtime-valid, aligned with the converter.""" + m = self._nodes.get(class_name) + if m is None: + return {} + out: dict[str, Any] = {} + for p in m.inputs: + if p.is_link: + continue + if p.options.dynamic_options: + out[p.name] = p.enum_values[0] if p.enum_values else None # selected key + out.update(_dynamic_sub_widget_defaults(p.name, p.options.dynamic_options)) + elif p.options.default is not None: + out[p.name] = p.options.default + elif p.enum_values: + out[p.name] = p.enum_values[0] + else: + out[p.name] = None + if p.options.control_after_generate: + out["control_after_generate"] = "fixed" + return out + # -- Validation -- def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: @@ -868,6 +982,33 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: errors.extend(_check_autogrow_required(node_id, autogrow_ports, autogrow_seen, node_data)) + # Required inputs must be PRESENT, not just well-typed when given. + # The server rejects a node whose required input is absent + # ("Required input is missing"), so a validate pass that only + # inspects the inputs the workflow happens to contain is a false + # green. Autogrow ports are covered by the slot check above. + provided = set((node_data.get("inputs") or {}).keys()) + for port in m.inputs: + if not port.required or port.is_autogrow or port.name in provided: + continue + hint = f"set {port.name!r} to a {port.type} value" + if port.options.default is not None: + hint += f" (default: {port.options.default!r})" + elif port.enum_values: + hint += f" (e.g. {port.enum_values[0]!r})" + errors.append( + { + "node_id": node_id, + "field": port.name, + "code": "missing_required_input", + "message": ( + f"required input {port.name!r} of {class_type} is missing — " + f"the server will reject this node" + ), + "hint": hint, + } + ) + return { "valid": len(errors) == 0, "errors": errors, @@ -1488,19 +1629,85 @@ def _isolate_shared_subgraph(workflow: dict, instance: dict, defs_by_id: dict[st """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. + + The fork id is DERIVED DETERMINISTICALLY from ``(definition id, instance id)`` + — never a random UUID — so two replicas replaying the same op produce + byte-identical graphs (a convergence requirement of the op model in + :mod:`comfy_cli.workflow_ops`). """ def_id = str(instance.get("type", "")) sg = defs_by_id.get(def_id) if sg is None or _count_instances(workflow, def_id) <= 1: return new_sg = copy.deepcopy(sg) - new_id = str(_uuid.uuid4()) + new_id = _deterministic_fork_id(def_id, instance.get("id")) new_sg["id"] = new_id workflow.setdefault("definitions", {}).setdefault("subgraphs", []).append(new_sg) instance["type"] = new_id +def _deterministic_fork_id(def_id: str, instance_id: Any) -> str: + """A stable id for the isolated copy of ``def_id`` owned by ``instance_id``. + Deterministic across processes (``hashlib``, not the salted builtin ``hash``) + so replaying the same op anywhere yields the same id.""" + seed = f"{def_id}\x00{instance_id}".encode() + return "sg-" + _hashlib.sha1(seed).hexdigest()[:32] + + +def _suggest_slots_for_input(workflow: dict, input_name: str, graph: Graph, *, limit: int = 6) -> list[str]: + """Real slot addresses whose widget name matches ``input_name``. + + Turns an unresolvable address into an actionable correction: an agent that + named the right widget but the wrong node or separator (e.g. + ``285/288.vae_name`` or ``285:288.vae_name`` when the VAELoader is ``285/29``) + is pointed at the address that actually carries ``vae_name``. Best-effort — + any extraction failure yields no suggestions rather than masking the error. + """ + if not input_name: + return [] + try: + slots = _extract_frontend_slots(workflow, graph) + except Exception: + return [] + out: list[str] = [] + for s in slots: + if s.get("name") == input_name: + addr = s.get("address") or "" + node_type = s.get("node_type") or "" + out.append(f"{addr} ({node_type})" if node_type else addr) + if len(out) >= limit: + break + return out + + def _apply_one_slot(workflow: dict, addr: str, value: Any, graph: Graph) -> list[dict]: + """Apply one slot override, enriching *not-found* errors with real address + suggestions so a mistargeted edit self-corrects in one step. + + An LLM that reconstructs an interior address from memory (rather than copying + it from ``slots``) tends to hit a real *sibling* node — e.g. writing + ``285/288.vae_name`` (a CLIPLoader) when the VAELoader is ``285/29``. The + intended widget name is almost always right, so on a not-found failure we + scan the workflow for the address that actually carries that widget and name + it in the error. Shape/enum errors (the target resolved fine) pass through + unchanged. + """ + try: + return _apply_one_slot_impl(workflow, addr, value, graph) + except ValueError as e: + if "not found" not in str(e): + raise + input_name = addr.split(".", 1)[1] if "." in addr else "" + suggestions = _suggest_slots_for_input(workflow, input_name, graph) + if not suggestions: + raise + raise ValueError( + f"{e}. Did you mean: {'; '.join(suggestions)}? " + "Copy the address verbatim from `comfy workflow slots` — never rebuild it." + ) from e + + +def _apply_one_slot_impl(workflow: dict, addr: str, value: Any, graph: Graph) -> list[dict]: """Apply a single slot override. Returns warnings. Raises ValueError on hard errors. Address forms (see ``_extract_frontend_slots`` / ``_SUBGRAPH_PATH_SEP``): diff --git a/comfy_cli/cql/loader.py b/comfy_cli/cql/loader.py index c3d0e4b15..925cde8f0 100644 --- a/comfy_cli/cql/loader.py +++ b/comfy_cli/cql/loader.py @@ -22,6 +22,7 @@ import json import os import sys +import time import urllib.error import urllib.parse import urllib.request @@ -266,9 +267,12 @@ def _from_api_workflow(data: dict[str, Any]) -> dict[str, Any]: # raw object_info path didn't leverage it, and there was no offline fallback. # # ``resilient_load_object_info`` wraps the engine's network fetch with: -# 1. auto-cache of every successful fetch (per host), -# 2. one refresh-and-retry on failure, and -# 3. a stale-cache fallback (with a clear stderr warning) when the retry +# 1. a cache-first TTL gate: a cache entry younger than the TTL (default +# 10 minutes, ``COMFY_OBJECT_INFO_TTL`` seconds to override, ``0`` to +# always fetch) is served without any network call, +# 2. auto-cache of every successful fetch (per host), +# 3. one refresh-and-retry on failure, and +# 4. a stale-cache fallback (with a clear stderr warning) when the retry # still fails — only raising the original error when no cache exists. # # An explicit ``--input `` always wins and is never cached. @@ -340,6 +344,51 @@ def read_object_info_cache(host_key: str) -> dict[str, Any] | None: return data if isinstance(data, dict) else None +# Cache-first TTL policy. A cache entry younger than this is served without a +# network call; the entry's age is its file mtime (``write_object_info_cache`` +# writes via tmp + ``os.replace``, so mtime == fetch time). +DEFAULT_OBJECT_INFO_TTL_SECONDS = 600.0 +OBJECT_INFO_TTL_ENV = "COMFY_OBJECT_INFO_TTL" + + +def object_info_cache_ttl() -> float: + """TTL (seconds) for the cache-first object_info gate. + + Reads ``COMFY_OBJECT_INFO_TTL``; unset/blank/unparseable values fall back + to the 10-minute default. ``0`` (or any value <= 0) disables the + cache-first gate entirely — every call fetches live, restoring the + pre-TTL behavior (the stale-cache *failure* fallback still applies). + """ + raw = os.environ.get(OBJECT_INFO_TTL_ENV) + if raw is None or not raw.strip(): + return DEFAULT_OBJECT_INFO_TTL_SECONDS + try: + ttl = float(raw) + except ValueError: + return DEFAULT_OBJECT_INFO_TTL_SECONDS + return max(ttl, 0.0) + + +def read_fresh_object_info_cache(host_key: str, ttl: float) -> dict[str, Any] | None: + """Return the cached dump for ``host_key`` iff it is younger than ``ttl``. + + Freshness is judged by the cache file's mtime. Returns ``None`` when the + TTL gate is disabled (``ttl <= 0``), the file is missing/unreadable, the + entry has expired, or the mtime is in the future (clock skew — treat as + expired rather than trusting a timestamp we can't reason about). + """ + if ttl <= 0: + return None + path = object_info_cache_path(host_key) + try: + age = time.time() - path.stat().st_mtime + except OSError: + return None + if age < 0 or age >= ttl: + return None + return read_object_info_cache(host_key) + + def _resolve_host_key(mode: str, host: str, port: int) -> str: """Resolve the cache key (the target base URL) without doing any I/O. @@ -365,19 +414,25 @@ def resilient_load_object_info( _warn=None, on_stale=None, ) -> dict[str, Any]: - """Fetch ``object_info`` with cache + refresh-retry + stale fallback. + """Fetch ``object_info`` cache-first, with refresh-retry + stale fallback. Resolution order: 1. ``input_path`` — explicit offline dump always wins; never cached. - 2. Live fetch via the engine. On success, write the per-host cache. - 3. On failure: attempt ``ensure_fresh_session`` and retry the fetch ONCE. + 2. Cache-first TTL gate: a per-host cache entry younger than the TTL + (default 10 minutes; ``COMFY_OBJECT_INFO_TTL`` seconds to override, + ``0`` to always fetch live) is returned with NO network call. + 3. Live fetch via the engine. On success, write the per-host cache. + 4. On failure: attempt ``ensure_fresh_session`` and retry the fetch ONCE. On success, write the cache. - 4. Still failing: fall back to the cached dump (if any) with a clear - stderr WARNING that it may be stale. - 5. No cache: re-raise the original ``LoadError`` (callers map it to the + 5. Still failing: fall back to the cached dump (if any, regardless of + age) with a clear stderr WARNING that it may be stale. + 6. No cache: re-raise the original ``LoadError`` (callers map it to the ``cql_no_graph`` envelope with their existing hint). + The cache key is the resolved target base URL, so local vs cloud — and + distinct base URLs — never share an entry. + ``_warn`` is an injectable sink for the stale-cache warning (defaults to stderr); tests pass their own to assert on it. """ @@ -388,8 +443,22 @@ def resilient_load_object_info( # already pinning a known-good file. return _load_from_file(input_path) + # Offline default catalog: COMFY_OBJECT_INFO_FILE is honored exactly like an + # explicit --input dump, so EVERY object_info consumer routed through this + # loader — workflow edits, `nodes show`/`find`, `validate`, fragments — + # resolves the node schema from a pre-warmed / baked file with no network + # fetch and no cloud credential. A host (e.g. a server-side agent) sets it + # once instead of threading --input through each command. + env_dump = os.environ.get("COMFY_OBJECT_INFO_FILE") + if env_dump: + return _load_from_file(env_dump) + host_key = _resolve_host_key(mode, host, port) + fresh = read_fresh_object_info_cache(host_key, object_info_cache_ttl()) + if fresh is not None: + return fresh + try: data = _load_from_target(mode=mode, host=host, port=port) write_object_info_cache(host_key, data) diff --git a/comfy_cli/credentials.py b/comfy_cli/credentials.py index ace6908e0..4f05f969f 100644 --- a/comfy_cli/credentials.py +++ b/comfy_cli/credentials.py @@ -41,6 +41,15 @@ # (Re-exported by ``comfy_cli.target`` for back-compat.) CLOUD_API_KEY_PROVIDER = "comfy-cloud-api-key" +# Env var carrying a pre-obtained Comfy Cloud Bearer token (a Firebase/Cloud +# JWT). Unlike ``COMFY_CLOUD_API_KEY`` (sent as ``X-API-Key``), this is sent as +# ``Authorization: Bearer``. It exists so a trusted caller that already holds +# the user's validated token — e.g. the cloud agent forwarding the request's +# ``X-Comfy-Token`` — can authenticate as that user without an interactive +# ``comfy cloud login`` session. It is NOT refreshed client-side; the server +# validates it at request time (and a 401 surfaces normally). +CLOUD_BEARER_ENV_VAR = "COMFY_CLOUD_AUTH_TOKEN" + Purpose = Literal["cloud", "partner"] # purpose → (env var, stored-key provider, strip ambient values?) @@ -117,6 +126,18 @@ def find_api_key(*, purpose: Purpose) -> Credential | None: return None +def cloud_bearer_env_token() -> str | None: + """Return a forwarded Comfy Cloud Bearer token from the environment, or None. + + Reads ``COMFY_CLOUD_AUTH_TOKEN`` (see :data:`CLOUD_BEARER_ENV_VAR`). Cloud-only + — it authenticates as the token's user via ``Authorization: Bearer``. + """ + import os + + tok = os.environ.get(CLOUD_BEARER_ENV_VAR) + return tok.strip() if tok and tok.strip() else None + + def resolve_cloud_credential( *, purpose: Purpose, @@ -135,8 +156,11 @@ def resolve_cloud_credential( ``base_url`` is given, a session minted for a *different* base URL is skipped (replay-guard: never send a token to a host the user didn't authenticate against). - 3. The purpose's env var (``COMFY_CLOUD_API_KEY`` / ``COMFY_API_KEY``). - 4. The stored ``comfy-cloud-api-key`` key (``comfy cloud set-key``). + 3. (cloud only) A forwarded Bearer token in ``COMFY_CLOUD_AUTH_TOKEN``, + sent as ``Authorization: Bearer`` — the trusted-caller path (see + :data:`CLOUD_BEARER_ENV_VAR`). + 4. The purpose's env var (``COMFY_CLOUD_API_KEY`` / ``COMFY_API_KEY``). + 5. The stored ``comfy-cloud-api-key`` key (``comfy cloud set-key``). """ explicit_key = explicit.strip() if isinstance(explicit, str) else "" if explicit_key: @@ -151,4 +175,9 @@ def resolve_cloud_credential( ): return Credential(kind="oauth", value=session.access_token, source="session") + if purpose == "cloud": + env_bearer = cloud_bearer_env_token() + if env_bearer: + return Credential(kind="oauth", value=env_bearer, source=f"env:{CLOUD_BEARER_ENV_VAR}") + return find_api_key(purpose=purpose) diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index f98e9493c..dae397d92 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -53,6 +53,15 @@ "comfy workflow slots": "workflow", "comfy workflow set-slot": "workflow", "comfy workflow vary": "workflow", + # structured edit primitives + recipes (CRDT op-based authoring) + "comfy workflow add-node": "workflow", + "comfy workflow connect": "workflow", + "comfy workflow set-widget": "workflow", + "comfy workflow delete-node": "workflow", + "comfy workflow ls-nodes": "workflow", + "comfy workflow apply": "workflow", + "comfy workflow capture": "workflow", + "comfy workflow foreach": "workflow", # workflow cloud CRUD + fragment composition "comfy workflow list": "workflow", "comfy workflow get": "workflow", @@ -94,6 +103,8 @@ "comfy project init": "project", "comfy project status": "project", "comfy assets push": "assets", + "comfy assets library ls": "assets_library", + "comfy assets library ensure": "assets_library", # config "comfy set-default": "set_default", "comfy version": "version", diff --git a/comfy_cli/env_checker.py b/comfy_cli/env_checker.py index 2aea4cbd5..485efc2e8 100644 --- a/comfy_cli/env_checker.py +++ b/comfy_cli/env_checker.py @@ -5,7 +5,6 @@ import os import sys -import requests from rich.console import Console from comfy_cli.config_manager import ConfigManager @@ -42,6 +41,10 @@ def check_comfy_server_running(port=8188, host="localhost", timeout: float = 5.0 Returns: bool: True if the Comfy server is running, False otherwise. """ + # Imported lazily: requests costs ~30ms to import and is only needed when + # actually probing the server, not for every CLI invocation. + import requests + try: response = requests.get(f"http://{host}:{port}/history", timeout=timeout) return response.status_code == 200 diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index 4cba21d37..6d1e58ea3 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -246,6 +246,11 @@ class ErrorCode: "`comfy models show` needs the cloud asset catalog; local servers don't have one.", "for local filename listing use `comfy models list-folder `", ), + ErrorCode( + "cloud_only_command", + "The command requires Comfy Cloud (e.g. `comfy assets library`); there is no local equivalent.", + "sign in with `comfy cloud login` and re-run with `--where cloud`", + ), ErrorCode( "template_fetch_failed", "Fetching the per-template workflow JSON from `Comfy-Org/workflow_templates` failed.", @@ -407,6 +412,12 @@ class ErrorCode: "A slot override failed validation (bad shape, unknown address, etc.).", "see `details` — addresses follow `.`", ), + ErrorCode( + "workflow_edit_invalid", + "A structured edit (add-node/connect/set-widget/delete-node) failed: " + "unknown class_type, missing node, bad slot/widget name, or malformed address.", + "run `comfy workflow slots ` for widget addresses or `comfy nodes types` for class_types", + ), # --- workflow fragments / compose --------------------------------------- ErrorCode( "fragment_invalid", diff --git a/comfy_cli/file_utils.py b/comfy_cli/file_utils.py index 3a84027cd..148fee99c 100644 --- a/comfy_cli/file_utils.py +++ b/comfy_cli/file_utils.py @@ -7,7 +7,6 @@ from http import HTTPStatus import httpx -import requests from pathspec import PathSpec from comfy_cli import constants, ui @@ -57,6 +56,10 @@ def check_unauthorized(url: str, headers: dict | None = None) -> bool: Returns: bool: True if the response status code is 401, False otherwise. """ + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + try: response = requests.get(url, headers=headers, allow_redirects=True, stream=True) return response.status_code == 401 @@ -462,6 +465,8 @@ def should_ignore(rel_path: str) -> bool: def upload_file_to_signed_url(signed_url: str, file_path: str): + import requests # deferred; see check_unauthorized + with open(file_path, "rb") as f: headers = {"Content-Type": "application/zip"} response = requests.put(signed_url, data=f, headers=headers) diff --git a/comfy_cli/registry/api.py b/comfy_cli/registry/api.py index 928b36062..328744095 100644 --- a/comfy_cli/registry/api.py +++ b/comfy_cli/registry/api.py @@ -2,8 +2,6 @@ import logging import os -import requests - # Reduced global imports from comfy_cli.registry from comfy_cli.registry.types import ( License, @@ -79,6 +77,10 @@ def publish_node_version( headers = {"Content-Type": "application/json"} body = request_body + # Imported lazily: requests costs ~30ms to import and this module is + # on the import path of every CLI invocation. + import requests + response = requests.post(url, headers=headers, data=json.dumps(body)) if response.status_code == 201: @@ -97,6 +99,8 @@ def list_all_nodes(self): Returns: list: A list of Node instances. """ + import requests # deferred; see publish_node_version + url = f"{self.base_url}/nodes" response = requests.get(url) if response.status_code == 200: @@ -116,6 +120,8 @@ def install_node(self, node_id, version=None): Returns: NodeVersion: Node version data or error message. """ + import requests # deferred; see publish_node_version + if version is None: url = f"{self.base_url}/nodes/{node_id}/install" else: diff --git a/comfy_cli/schemas/assets_library.json b/comfy_cli/schemas/assets_library.json new file mode 100644 index 000000000..4c3aa529d --- /dev/null +++ b/comfy_cli/schemas/assets_library.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://comfy.org/schemas/assets_library.json", + "title": "comfy assets library --json data payload", + "description": "Union schema for `assets library ls` and `assets library ensure`.", + "type": "object", + "additionalProperties": true, + "properties": { + "count": { "type": "integer" }, + "assets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "hash": { "type": ["string", "null"] }, + "mime_type": { "type": ["string", "null"] }, + "size": { "type": ["integer", "null"] }, + "tags": { "type": ["array", "null"] }, + "preview_url": { "type": ["string", "null"] }, + "job_id": { "type": ["string", "null"] }, + "created_at": { "type": ["string", "null"] } + } + } + }, + "id": { "type": ["string", "null"] }, + "hash": { "type": ["string", "null"] }, + "created_new": { "type": ["boolean", "null"] } + } +} diff --git a/comfy_cli/skills/__init__.py b/comfy_cli/skills/__init__.py index 78f22c42a..f2b022c58 100644 --- a/comfy_cli/skills/__init__.py +++ b/comfy_cli/skills/__init__.py @@ -18,8 +18,6 @@ contract, routing, discovery, execution, and all domain patterns: image, video, audio, cloud, edit, condition, pipeline) -- ``comfy-fragments``— typed reusable workflow fragments + YAML blueprint - composition (build large pipelines from small pieces) - ``comfy-debug`` — debugging skill for when workflows fail or jobs hang - ``comfy-relay`` — what to put in chat while driving the CLI """ @@ -49,7 +47,6 @@ # name). The bundled skills must satisfy their own convention. BUNDLED_SKILLS: tuple[tuple[str, str], ...] = ( ("comfy", "comfy"), - ("comfy-fragments", "comfy-fragments"), ("comfy-debug", "comfy-debug"), ("comfy-relay", "comfy-relay"), ("comfy-director", "comfy-director"), @@ -600,7 +597,6 @@ def _write_claude_skill(path: Path, content: str) -> None: def _cursor_description_for(skill_name: str) -> str: return { "comfy": "comfy CLI for ComfyUI workflows, models, node-graph queries, image/video/audio generation, cloud, and pipeline orchestration.", - "comfy-fragments": "Typed reusable workflow fragments + YAML blueprint composition: build large pipelines from small tested pieces via comfy CLI.", "comfy-debug": "Debugging skill for the comfy CLI: failed workflows, hung jobs, error envelopes.", "comfy-relay": "What to put in chat while driving the comfy CLI: show artifacts, surface results, truncation rules.", }.get(skill_name, f"comfy CLI skill: {skill_name}") diff --git a/comfy_cli/skills/comfy-director/SKILL.md b/comfy_cli/skills/comfy-director/SKILL.md index be627d65a..b758cbf82 100644 --- a/comfy_cli/skills/comfy-director/SKILL.md +++ b/comfy_cli/skills/comfy-director/SKILL.md @@ -9,8 +9,8 @@ Story first, pictures second. Multi-shot AI films fail in the edit, not the render: beats that don't cause each other read as a montage wearing a story's clothes, no matter how good each clip looks. -**REQUIRED BACKGROUND:** the `comfy` skill (CLI mechanics, workflow hierarchy) -and `comfy-fragments` (composing multi-stage graphs). On any failed job, +**REQUIRED BACKGROUND:** the `comfy` skill (CLI mechanics + the recipe path — +`apply`/`capture`/`foreach` — for reusable multi-stage graphs). On any failed job, `comfy-debug`. ## Order of operations diff --git a/comfy_cli/skills/comfy-fragments/SKILL.md b/comfy_cli/skills/comfy-fragments/SKILL.md deleted file mode 100644 index afeece481..000000000 --- a/comfy_cli/skills/comfy-fragments/SKILL.md +++ /dev/null @@ -1,657 +0,0 @@ ---- -name: comfy-fragments -description: Compose large Comfy workflows from small reusable fragment pieces — each a self-contained workflow JSON with declared inputs, outputs, and parameters. Use when iterating on complex workflows, when patterns repeat, or when a workflow JSON grows past ~200 lines. ---- - -This skill is the **composition layer** on top of `comfy`. Where -the core skill teaches you to build large single-graph workflows that -ComfyUI can parallelize, this skill teaches you how to **assemble those -large graphs from smaller reusable pieces** — like functions in code. - -It assumes `comfy` (core CLI) is loaded. Pair it with the domain skills -(image, video, audio, editing domains in the core `comfy` skill). Fragments -are how you avoid rebuilding the same 8-node IPAdapter block five times. - ---- - -## When to use fragments - -Default to fragments + blueprints for workflows that may be extended. A small -workflow often becomes tomorrow's multi-shot, multi-seed, or multi-provider -pipeline; starting with a named fragment and a blueprint keeps that next step -cheap. - -Use fragments when **any of**: - -- A simple template or node chain might later need another shot, seed sweep, - provider swap, refiner, ControlNet, LoRA, or final save variant. -- A sub-region of a workflow (a ControlNet stack, an IPAdapter block, a - refiner pass, a save+thumbnail group) is reused across two or more - workflows — extract it once, instantiate twice. -- A workflow JSON is creeping past ~200 lines and you're losing the - mental model of which node ID does what. -- You're about to copy-paste a pattern from another workflow (text cards, - inpaint passes, upscale finishers, LLM-driven prompt direction). -- You're iterating on a piece and small edits keep cascading through the - whole JSON. -- Multiple model providers are chained (Ideogram + Reve + Flux + Magnific) - and the chain wiring is the hard part. - -Avoid fragments only when: - -- A workflow is a truly throwaway one-shot and the user explicitly values speed - over future extension. -- You only need to tweak values inside an existing workflow — use - `comfy workflow slots / set-slot / vary` instead. -- The whole graph is under ~10-15 nodes, will not be varied or reused, and has - no natural named sub-region. - ---- - -## The mental model - -A fragment is a **function with named inputs, outputs, and parameters**: - -``` -inpaint_region( - image: IMAGE, # input - mask: MASK, # input - prompt: str, # param - guidance: float = 30.0, # param with default - seed: int = 2000, # param with default -) -> IMAGE -``` - -Inside the fragment, the implementation can be 1 node or 30 — the caller -doesn't care. They pass arguments and get a typed result they can pipe -into the next step. - -A **blueprint** chains fragments end-to-end (typically YAML), and a small -**composer** tool emits the final monolithic workflow JSON ready for -`comfy run`. - ---- - -## 1. The fragment file format - -A fragment is one `.json` file in a `fragments/` directory. It has a -`_fragment` metadata header that declares the fragment's typed interface, -followed by the interior ComfyUI nodes (API-format, just like a workflow). - -```json -{ - "_fragment": { - "name": "image_blend", - "version": "1", - "description": "Blend two images with a configurable mode and factor.", - "terminal": false, - "inputs": { - "image1": {"type": "IMAGE", "binds": "10.image1"}, - "image2": {"type": "IMAGE", "binds": "10.image2"} - }, - "outputs": { - "image": {"type": "IMAGE", "from": "10", "port": 0} - }, - "params": { - "blend_factor": {"type": "FLOAT", "binds": "10.blend_factor", "default": 0.5}, - "blend_mode": {"type": "COMBO", "binds": "10.blend_mode", "default": "normal"} - } - }, - - "10": { - "class_type": "ImageBlend", - "_meta": {"title": "blend two passes"}, - "inputs": { - "image1": "PLACEHOLDER", - "image2": "PLACEHOLDER", - "blend_factor": 0.5, - "blend_mode": "normal" - } - } -} -``` - -### Metadata fields - -| field | required | meaning | -|---|---|---| -| `name` | yes | Stable identifier. Blueprints reference fragments by this name. | -| `version` | no (default `"1"`) | String version, semver-ish. Bump when the interface changes. | -| `description` | recommended | One-line human description. | -| `terminal` | optional (default `false`) | `true` if the fragment contains its own `SaveImage`/`SaveVideo`. Stops the composer from appending another save. | -| `inputs` | no (default `{}`) | Each input has a `type` — any UPPER_SNAKE_CASE socket type (`IMAGE`, `MASK`, `AUDIO`, `VIDEO`, `STRING`, `MODEL`, `CONDITIONING`, `LATENT`, `VAE`, `CLIP`, custom types…) — and a `binds: "."` pointing at the actual node-field this input feeds. Path-loadable types (`IMAGE`/`MASK`/`AUDIO`/`VIDEO`) accept file paths — the composer injects a loader node. All other socket types must be fed by a cross-step ref (`$alias.output`), never a path. | -| `outputs` | no (default `{}`) | Each output has a `type` and `from: ""` plus optional `port` (default `0`). | -| `params` | optional | Settable values (text, seed, strength, model name, etc.). Each has `type` ∈ {`STRING`, `INT`, `FLOAT`, `BOOLEAN`, `COMBO`} (the node-schema vocabulary, exactly as `nodes show` prints it), a `binds`, and optionally a `default`. | - -### Conventions for interior nodes - -- Use simple integer IDs (`"10"`, `"11"`, …). The composer remaps them - globally so collisions across fragments don't matter. -- Use the literal string `"PLACEHOLDER"` for any input that will be filled - by the composer at instantiation. (Defaults from `params` overwrite it.) -- Internal edges (`["10", 0]`) are preserved and renumbered automatically. - ---- - -## 2. The blueprint DSL - -A blueprint is a YAML file describing one composed workflow. The composer reads -the blueprint, instantiates each listed fragment, wires inputs/params, and -writes one API-format workflow JSON. - -```yaml -output_prefix: outputs/my_pipeline - -pipeline: - - fragment: text_card # name (looked up in ./fragments/) - alias: headline # unique handle for downstream refs - inputs: - destination_image: $asset.base.png # project asset → resolved via the push lock - source_mask: $asset.mask_top.png # type MASK → LoadImage + ImageToMask - params: - text_prompt: "BREAKING NEWS" - comp_x: 140 - comp_y: 30 - - - fragment: text_card - alias: subhead - inputs: - destination_image: $headline.image # ← previous step's output - source_mask: $asset.mask_sub.png - params: - text_prompt: "...details..." -``` - -### The `$`-reference algebra - -Four reference kinds, each with ONE resolution source, all resolved at -compose time. **Whole-value only**: a `$`-ref must be the ENTIRE string — -`"a $asset.x b"` is plain text, there is no interpolation/templating. - -| Reference | Resolves from | Where it works | -|---|---|---| -| `$alias.output` | a prior step's named output → `[node_id, port]` wire | inputs | -| `$item.field` | the current `foreach` item (see foreach below) | inputs + params | -| `$asset.` | the project push lock → server-side filename | inputs + params + item field values | -| `$var.` | the project comfy.yaml `vars:` block | inputs + params + item field values | - -- **`$asset.`** — a file under the governing project's - `assets/` dir (project/1 — see the core `comfy` skill), resolved through - the push lock (`comfy assets push`) to the server-side filename. On an - input it is then materialized like a path (loader injected); on a param - the resolved filename lands as the widget value. Compose fails closed - with `asset_not_pushed` / `asset_stale` when the file was never pushed or - changed since — the hint says exactly what to run. -- **`$var.`** — a project constant from a top-level `vars:` mapping - in `comfy.yaml` (scalars: str/int/float/bool). Resolves to the RAW scalar, - so an `INT` param fed `$var.steps` stays an int. Undefined name → - `var_not_defined` (add it under `vars:`). Referenced vars are snapshotted - into the compiled JSON's `_meta.vars` for provenance. Use it for the - style/prompt constants every scene shares: - - ```yaml - # comfy.yaml - vars: - house_style: ", golden hour" - # blueprint params — every scene appends the same style, edited in ONE place: - # params: {prompt: $var.house_style} - ``` - -- In a `foreach`, an item FIELD value may itself be a `$asset.`/`$var.` ref: - `$item.first` substitutes the field first, then the resulting whole-value - string resolves per item. - -Besides refs, an `inputs:` entry also accepts: - -- **A path string** — for `IMAGE`, `MASK`, `AUDIO`, `VIDEO` inputs the composer - injects the appropriate loader (`LoadImage` / `LoadAudio` / `LoadVideo`, - plus `ImageToMask` for `MASK`). The value must be a filename the *server* - can see in its input dir — in a project, prefer `$asset` so push and - resolution are handled for you. For `STRING` inputs the value passes through - as a literal. -- **A literal** — for `STRING` inputs only. Non-string literals for non-STRING - types are rejected. - -### Cross-step refs work across any output type - -`$alias.image`, `$alias.conditioning`, `$alias.mask`, `$alias.audio`, -`$alias.video` — whatever the fragment declared as outputs. The composer -errors clearly if the alias or output name doesn't exist. - -### Final save behavior - -If the **last** step's fragment has `terminal: true`, the composer leaves the -workflow alone (your fragment handles saving). Otherwise it appends a -`SaveImage` or `SaveVideo` (auto-detected from the final step's first -`IMAGE`/`VIDEO` output) using `output_prefix` as the filename prefix. - ---- - -## 3. The command surface - -Fragment composition is built into the `comfy` CLI: - -```bash -# Compose a blueprint into a single workflow JSON -comfy workflow compose blueprints/my_pipeline.yaml # → blueprints/my_pipeline.compiled.json - -# Specify a custom fragments directory (default: ./fragments) or output path -comfy workflow compose blueprints/my_pipeline.yaml --lib ./my_fragments -o pipeline.json - -# Project a workflow INTO a fragment — the inverse of compose -comfy workflow decompose ref.json --name restyle # → ./fragments/restyle.json - -# List fragments in a library -comfy --json workflow fragment ls [--lib DIR] - -# Show a fragment's metadata, ports, and interior node count -comfy --json workflow fragment show - -# Validate a fragment file is well-formed -comfy --json workflow fragment validate - -# Then submit the composed workflow -comfy run --workflow blueprints/my_pipeline.compiled.json --wait -``` - -`--lib` defaults to `./fragments` relative to cwd. Default output is -`.compiled.json`, next to the blueprint. - -### `decompose` — turn an existing workflow into source - -`compose` builds fragments → a workflow; `decompose` is the **inverse**: -it projects a workflow JSON (a fetched template, or any API/frontend graph) -back into a fragment so you edit *source*, never the compiled artifact. From -the graph alone (nothing hardcoded) it: - -- **strips each loader** (`LoadImage`/`LoadAudio`/`LoadVideo`) and exposes the - consumer input it fed as a typed **input** — so compose can re-inject a loader - for a path, or wire a `$alias.output` ref in its place (keeping the original - loader would double-load); -- **strips the terminal save** and exposes its producer as a typed **output**, - leaving a composable, non-terminal building block; -- surfaces every remaining **scalar widget** as a **named param** defaulting to - its current value — the buried prompt that needed `jq '…widgets_values[0]'` - becomes `params: {…_prompt: "…"}` you set in the blueprint. - -```bash -comfy workflow decompose workflows/restyle.json --name restyle # API format: no server needed -comfy workflow decompose template.json --name lulz --input object_info.json # frontend/subgraph: needs schema -``` - -Frontend-format (UI) and subgraph templates are flattened to API format first, -which needs `object_info` — from a running/cloud server, or an offline -`--input object_info.json` dump. Already-API workflows need neither. The result -always round-trips through `fragment validate`. - -**Use it — don't hand-edit.** When you fetch a template or have a workflow whose -values you need to change, `decompose` it and edit named params in a blueprint. -**Never** `jq`/`sed`/edit a workflow's `widgets_values`/`inputs` or hunt nodes by -id (`select(.id==128)`) — that's the anti-pattern decompose exists to kill. The -only exception is a throwaway run you won't reuse: `slots`/`set-slot`/`vary` then -`run`. - -### Self-documenting by construction - -Both sides of the compile carry their own provenance, so a future agent (or you, -later) can edit safely without re-deriving intent: - -- **A decomposed fragment** records `_fragment.source` (where it came from) and a - `_fragment.description` that says how to edit it ("…edit params in a blueprint - and rebuild with `comfy workflow compose` — do not hand-edit"). `comfy workflow - fragment show ` prints the description plus every param's `binds` + - default — so each value documents which node/field it controls. -- **A compiled workflow** embeds `_meta` (`schema: compose/1`) naming the - `blueprint` that produced it and, for `foreach`, an `item_map` of which nodes - belong to which item. `comfy run` strips `_meta` before submit. So the artifact - always points back at its source; to change it, edit that blueprint and - recompile — never the compiled JSON. - -Compose embeds `_meta` (`schema: compose/1`) provenance in the compiled -JSON — the blueprint path and, for `foreach`, which nodes belong to which -item (also `item_map` in the envelope). `comfy run` strips it before -submit (old servers unaffected) and uses the map to report -`outputs_by_item` and to name downloaded files `_.` — -never identify fan-out outputs by array order. - -With `chunk: N` in a `foreach` blueprint, compose splits items into -N-item batches and writes one numbered file per batch (`.000.json`, -`.001.json`, …). The envelope then reports `out: null` (there is no -single runnable file) plus `graphs` (count) and `written[]` (all paths) — -script against `data.written`, not `data.out`, and note any stale -unnumbered `.compiled.json` from a previous non-chunked compose is -deleted automatically. - -All commands emit JSON envelopes under `comfy --json`. The composer -exits non-zero on validation errors with structured error codes -(`fragment_invalid`, `blueprint_invalid`, `blueprint_not_found`, -`fragment_lib_not_found`) — caught at compose time, not after cloud spend. -`fragment_lib_not_found` is raised by `workflow fragment ls` when the -library directory (explicit `--lib`, or the default `./fragments`) -doesn't exist yet — create it when you author your first fragment. A -missing fragment during `compose` surfaces as `fragment_invalid` instead. - ---- - -## 4. End-to-end example - -Project layout (project/1 — `comfy project init`): - -``` -my-project/ - comfy.yaml # schema: project/1 + defaults.where - fragments/ - text_encode.json - sampler.json - save_still.json - blueprints/ - portrait.yaml - assets/ - seed_photo.png # referenced as $asset.seed_photo.png -``` - -Push, compose, submit: - -```bash -cd my-project -comfy --json assets push # upload changed assets, update the lock -comfy workflow compose blueprints/portrait.yaml -comfy run --workflow blueprints/portrait.compiled.json --wait -``` - -That's the full agent loop. The fragment library is reusable across blueprints; -blueprints are small and obvious; the composed workflow is a normal API JSON -that submits like any other. - ---- - -## 5. Real-world blueprint shape - -A typical production pipeline for a single piece: - -```yaml -pipeline: - - fragment: subject_generator # base photoreal scene - alias: subject - ... - - - fragment: text_card # branded text card 1 - alias: card_a - inputs: {destination_image: $subject.image, source_mask: ...} - ... - - - fragment: text_card # branded text card 2 - alias: card_b - inputs: {destination_image: $card_a.image, source_mask: ...} - ... - - - fragment: inpaint_region # surgical fix to a problem area - alias: fix_face - inputs: {image: $card_b.image, mask: ...} - ... - - - fragment: vision_verify # in-graph QA gate (optional) - alias: qa - inputs: {image: $fix_face.image} - - - fragment: magnific_finish # 4x upscale to print - alias: final - inputs: {image: $fix_face.image} -``` - -A 30-40 line blueprint expands to a 200-500 node workflow. Compose-time -validation catches the typical mistakes (missing inputs, bad alias -references, type mismatches) before you spend cloud compute on a -broken job. - ---- - -## 6. How to create a fragment - -**The typical flow** — discover the node, wrap it in a fragment, use it -from a blueprint: - -1. Discover the node: `comfy --json nodes show ` — check its - inputs, outputs, and valid parameter values -2. Write `fragments/.json` with: - - `_fragment` header (name, inputs, outputs, params with binds) - - Interior nodes (1-15) in standard API format - - `"PLACEHOLDER"` for inputs that the blueprint will supply - - Reasonable defaults for optional params -3. Validate: `comfy --json workflow fragment validate ` -4. Use from a blueprint and compose to verify it works end-to-end - -**Refactoring path** — if you already have a working raw JSON workflow -and want to extract reusable pieces: - -1. Identify the sub-region you'll reuse (5-15 nodes that form a logical unit) -2. Copy those nodes into `fragments/.json`, add a `_fragment` header -3. Replace concrete values with `"PLACEHOLDER"` -4. Validate + compose + test - -Always test a new fragment by composing a blueprint and submitting the -result before relying on it. - ---- - -## 7. Picking input types - -| Input type | Use for | The composer does | -|---|---|---| -| `IMAGE` | Photos, generated images, reference frames | Injects `LoadImage` when the blueprint value is a path; passes through when the value is `$alias.image` | -| `MASK` | Binary/alpha masks | Injects `LoadImage` + `ImageToMask` (channel: red) for paths | -| `AUDIO` | WAV/MP3/FLAC | Injects `LoadAudio` for paths | -| `VIDEO` | MP4/WebM | Injects `LoadVideo` for paths | -| `STRING` | Prompts, model names, captions, any literal | Pass-through. No loader injection. | - -Use the type that matches what the interior node actually consumes. -`CONDITIONING` (and `MODEL`, `CLIP`, `VAE`, `LATENT`) are first-class input -types — declare `type: CONDITIONING` and wire it with a cross-step ref like -`conditioning: $encode.conditioning`. Only path-loadable types (`IMAGE`, -`MASK`, `AUDIO`, `VIDEO`) accept file paths; all other socket types must -come from a prior step via `$alias.output_name`. - ---- - -## 8. Starter pattern library - -Build these once and reuse forever. - -### `subject_generator` — LLM-directed base generation - -`ClaudeNode` (positive) + `ClaudeNode` (negative) + `Flux Dev` + LoRA -stack → IMAGE. Sweep on the Claude seed for genuine interpretation -variance, not just noise variance. - -### `text_card` — typography card via Ideogram + composite - -`IdeogramV3` → `ImageScale` → `ImageCompositeMasked`. Use this whenever -brand text or specific phrases must be legible — Ideogram is reliable -at text where Flux is not. - -### `inpaint_region` — context-aware content replacement - -`FluxProFillNode` with detailed prompt. Use for replacing a masked -region with new content that integrates with scene lighting. **Note: -this is REPLACE-ONLY — see gotchas section.** - -### `magnific_finish` — production upscale - -`MagnificImageUpscalerCreativeNode` (Sparkle engine). Defaults to -preserve mode (`creativity=0`, `resemblance=10`) for final delivery. -Bump `creativity` to 2-3 for mild detail enhancement. - -### `vision_verify` — in-graph QA gate - -`ClaudeNode` with `images` input + a checklist system_prompt. Returns -a structured PASS/FAIL critique. (Note: capturing the TEXT output of -ClaudeNode for retrieval requires a `SaveString` node — its in-graph -output is consumable by downstream nodes but not always exposed by the -job-status API.) - ---- - -## 9. Gotchas baked into fragment defaults - -Each of these tripped someone up during real production. Encoding them -into the fragment defaults means they can't be forgotten: - -### `ImageCompositeMasked` — mask MUST match SOURCE size - -The mask input gets bilinear-upscaled to the source image's dimensions. -If you pass a destination-sized mask (e.g., 1536×1024) with a small -source (e.g., 330×180), the small white-rectangle inside the big mask -shrinks to almost-black and the composite renders almost nothing. - -The `text_card` fragment requires you to supply a mask **already sized -to your `scale_width × scale_height` params**. The composer documents -this expectation in error messages. - -### `COMFY_DYNAMICCOMBO_V3` inputs use dotted keys - -Nodes like `ClaudeNode`, `ReveImageCreateNode` declare a `model` input -with type `COMFY_DYNAMICCOMBO_V3` — when you select a model, that model -brings its own required sub-params (`max_tokens`, `temperature`, etc.). -In API workflow JSON these are flat dotted keys, NOT nested: - -```json -// ✅ correct -"inputs": { - "model": "Opus 4.6", - "model.max_tokens": 800, - "model.temperature": 0.95 -} - -// ❌ wrong — fails validation -"inputs": { - "model": ["Opus 4.6", {"max_tokens": 800, "temperature": 0.95}] -} -``` - -### `SAM3Grounding` outputs MASK directly - -Looks like a "find boxes" node but actually returns a `MASK`. Wire it -straight into mask-consuming nodes. `SAM3Segmentation` is only needed -when you've built boxes yourself via `SAM3CreateBox` + -`SAM3CombineBoxes`. - -### `FluxProFillNode` is REPLACE-ONLY - -It has no denoise / strength parameter. Whatever pixels are inside the -mask get fully regenerated from the prompt. **Never use it to "refine" -existing composited content** — it will overwrite that content. - -For true refinement (preserve most of the masked region, only smooth -edges and lighting), use **KSampler + VAEEncode + SetLatentNoiseMask** -with `denoise=0.15–0.25`, or run `MagnificImageUpscalerCreativeNode` -with `creativity=2-3` at upscale time. - -### `MagnificImageRelightNode` `style="smooth"` drains color - -Counter-intuitively, the "smooth" relight style produces a sepia / -monochromatic image. For warming light without color loss, use -`style="brighter"` or `style="clean"`. Always test on a small image -before committing it to a pipeline. - -### `MagnificImageUpscalerCreativeNode` parameter ranges - -| Param | Range | Note | -|---|---|---| -| `creativity` | 0–10 | 0 = preserve, 4+ = noticeable reinterpretation | -| `resemblance` | -10–10 | NOT 0–100. 10 = max preservation. | -| `hdr` | 0–10 | small values are fine for most scenes | - -### Text rendering: use Ideogram, not Flux - -Flux and SDXL/SD3 cannot reliably render specific text. If your piece -has brand wordmarks, specific phrases, or proper names that MUST be -spelled correctly, the right tool is: - -1. **Ideogram V3** in-graph (`IdeogramV3` node) — Ideogram is the - text-master model in this stack -2. PIL composite externally (post-processing) — guaranteed but layered -3. **Don't** trust Flux Pro Fill to render text inside an inpaint - — it will produce garbled glyphs every time - -The `text_card` fragment uses Ideogram for this reason. Don't substitute -Flux into it. - ---- - -## 10. When the pattern breaks down - -Honest limits: - -- **One-shot exploration is faster without the indirection** — if - you're just trying things, write raw workflow JSON or use - `comfy workflow vary`. -- **External (non-Comfy) steps don't compose as cleanly** — Python - post-processing like PIL composites or external file conversions - need a separate step type in the composer. Keep them outside the - Comfy graph. -- **Comfy version drift** — if ComfyUI's native subgraph support - (v0.3+) stabilizes for API workflow JSON export, eventually migrate - to native subgraphs. The JSON-composition approach is portable but - reinvents what Comfy itself wants to provide. -- **Debugging composed workflows** — when something fails, you're - looking at a generated workflow JSON, not your hand-written one. - Keep the composer's intermediate output (`blueprint.compiled.json`) - for inspection. Log the blueprint + fragment versions per run. - ---- - -## 11. What NOT to do - -- **Don't put model loading inside every fragment.** Load `CheckpointLoaderSimple` - once in the blueprint's first step and pass `model`/`clip`/`vae` outputs by - cross-step ref. Fragments are about reusable sub-regions; the shared model - state belongs at the top. -- **Don't author huge fragments.** If a fragment has more than ~15 interior - nodes, it's probably two fragments. Same for params — 15+ params means - you should split. -- **Don't hide critical model choices in defaults.** If swapping - `flux1-dev` for `sd3.5_large` would silently change the output - character, expose it as a required param. -- **Don't compose at runtime via shell scripts.** The Python composer - catches errors at compose time. Shell glue catches them after cloud spend. -- **Don't reach into another fragment's internals.** If you need access - to a node deep inside a fragment, that node should be promoted to - an output of the fragment's public interface, or the fragment should - be split. -- **Don't skip validation** before submitting a composed workflow that - uses a new fragment. Run `comfy workflow fragment validate ` - first — it catches missing `binds` targets, malformed metadata, and - orphan interior nodes locally. -- **Don't reuse aliases across steps.** Aliases must be unique within a - blueprint; the composer rejects duplicates. - ---- - -## 12. Failure modes and what they mean - -| code | what's wrong | what to fix | -|---|---|---| -| `fragment_invalid` | The fragment file itself is malformed (bad `_fragment` header, missing fields, dangling `binds`) | Read the error message; fix the fragment JSON | -| `fragment_lib_not_found` | The library directory passed to `fragment ls` (explicit `--lib`, or the default `./fragments`) doesn't exist | Create `./fragments/` and author a fragment, or pass a valid `--lib ` | -| `blueprint_not_found` | The blueprint YAML path doesn't exist | Check the path | -| `blueprint_invalid_yaml` | The blueprint file isn't valid YAML | Run it through `yamllint` | -| `blueprint_invalid` | The blueprint semantically fails (missing fragment, missing input, unknown input key, duplicate alias) | Read the error — it names the offending step alias | -| `asset_not_pushed` | A `$asset.` ref has no entry in `.comfy/assets.lock.json` (or the file vanished from `assets/`) | `comfy assets push`, then re-compose | -| `asset_stale` | The file under `assets/` changed since its last push (sha256 mismatch with the lock) | `comfy assets push`, then re-compose | -| `var_not_defined` | A `$var.` ref names nothing under `vars:` in the project's comfy.yaml | Add the name under `vars:`, then re-compose | - ---- - -## Summary - -| Without fragments | With fragments | -|---|---| -| 1500-line workflow JSON | 40-line blueprint + N small fragments | -| Edits hunt through node IDs | Edits change one blueprint param | -| Errors caught at cloud submission | Errors caught at compose time | -| Patterns get copy-pasted between projects | Patterns become reusable units | -| Gotchas re-discovered each project | Gotchas baked into fragment defaults | - -Fragments treat workflows the way good code treats logic: small named -units, typed interfaces, defaults that encode wisdom, and a composer -that wires them up. diff --git a/comfy_cli/skills/comfy-relay/SKILL.md b/comfy_cli/skills/comfy-relay/SKILL.md index 1469013c1..26b7d21ee 100644 --- a/comfy_cli/skills/comfy-relay/SKILL.md +++ b/comfy_cli/skills/comfy-relay/SKILL.md @@ -8,8 +8,8 @@ and passing tests. Creative work is different: the user steers by **seeing the work**. Your whole job in chat is to make them see it and react. **The image is the message — text about the image is not.** -This skill is the presentation/interaction layer. It is paired with `comfy` -(the surface) and `comfy-fragments` (the compile model). +This skill is the presentation/interaction layer. It is paired with the `comfy` +skill (the CLI surface + recipe authoring: `apply`/`capture`/`foreach`). --- @@ -32,15 +32,22 @@ You can't play a clip in chat. Surface it visually instead — **one command doe it:** ```bash -comfy --json preview clip.mp4 # → clip.preview.png (a contact sheet) + duration/fps/has_audio +comfy --json preview clip.mp4 # → clip.preview.png (a contact sheet) # then Read clip.preview.png ``` `comfy preview` handles all three modalities: **video → contact sheet** (a grid across the whole timeline, best for judging pacing/arc), **image → thumbnail**, -**audio → waveform** (so you can *see* the dynamics you can't hear). It also -reports the facts frames can't show — **duration, fps, and whether there's -audio** — in the envelope. +**audio → waveform** (so you can *see* the dynamics you can't hear). The +contact-sheet/thumbnail PNG **always renders** — the CLI ships a bundled ffmpeg, +so no system install is needed. + +It also *tries* to report the facts frames can't show — **duration, fps, has_audio** — +but those come from `ffprobe`, which is **not** bundled: without a system `ffprobe` +on PATH those envelope fields are `null` (and the contact sheet spans only the +first second, since it can't read the duration). Install system ffmpeg +(`brew install ffmpeg` / `apt install ffmpeg`) when you need accurate timeline +sampling or the metadata; the PNG itself works without it. Prefer it over hand-rolling ffmpeg. (If you need a custom grid: `--grid 6x4`; custom width: `--width 720`.) For a key-moments read instead of a grid, extract @@ -79,7 +86,7 @@ lot of wall-clock you should never spend blocked. Same for seed/variant sweeps (`workflow vary`). 3. **Dispatch a subagent per long, self-contained job.** When you have subagents - available, a whole shot/clip/pipeline — survey → compose → run → wait → + available, a whole shot/clip/pipeline — survey → apply → run → wait → assemble — is minutes of work and many steps. Hand each to a **background subagent**: it drives the CLI end-to-end and reports back the artifact path + a short build log, while the main session stays responsive and other @@ -87,7 +94,7 @@ lot of wall-clock you should never spend blocked. piece** — a shot, the music, the title card — then the orchestrator assembles the returned clips. - Brief it like a creative director: the concept/shot, the technique, what to - **reuse** (existing fragments/clips), and to report the path + any friction. + **reuse** (existing recipes/clips), and to report the path + any friction. - It returns the *conclusion* (the clip + log), not its 100-step transcript — so the orchestrator's context stays clean and it can run many in parallel. - Keep one piece = one subagent so a re-roll re-runs only that piece. @@ -110,17 +117,17 @@ questions. ## Rule 6 — Lead with the visual, then show the source -After the preview, show the **source that made it** — the blueprint YAML, the -prompt, the params — so the user can tweak the *inputs*, not hunt through -compiled JSON. The compiled workflow is a build artifact; keep it out of chat. +After the preview, show the **source that made it** — the recipe, the prompt, the +params — so the user can tweak the *inputs*, not hunt through the graph JSON. The +workflow file is a build artifact; keep it out of chat. | Moment | Lead with | Then show | |---|---|---| -| Generated an image | the image (`Read` it) | the prompt / blueprint that made it | -| Rendered a clip | a contact sheet + duration/audio | the blueprint | +| Generated an image | the image (`Read` it) | the prompt / recipe that made it | +| Rendered a clip | a contact sheet + duration/audio | the recipe | | A creative fork | the candidate frames | your recommendation | | Iterating one value | the new result | `param: old → new` (one line) | -| Composing a workflow | the blueprint YAML (10–30 lines) | the `compose` summary — never the 100-node JSON | +| Building a workflow | the recipe (params + ops) | the `apply` summary (`data.ops`) — never the 100-node JSON | | Editing one slot | the re-rendered result | `addr: old → new` (one line) | ## Rule 7 — Be honest about what you can't perceive diff --git a/comfy_cli/skills/comfy/SKILL.md b/comfy_cli/skills/comfy/SKILL.md index 6f9a36494..a65f84dff 100644 --- a/comfy_cli/skills/comfy/SKILL.md +++ b/comfy_cli/skills/comfy/SKILL.md @@ -17,7 +17,6 @@ halves are independent — you can scan only what's relevant to the task. **This is one of a skill family — skim the siblings before a big task so you know what exists, and reach for the right one rather than improvising its job:** -`comfy-fragments` (compose large graphs from reusable, validated pieces), `comfy-director` (multi-shot narrative video — story, continuity, conform), `comfy-debug` (any failed job: error code → fix), `comfy-relay` (surface a workflow/result in chat, never leave it in /tmp). When a task spans several, @@ -191,15 +190,12 @@ mechanism by complexity and reuse — not as a quality ranking: Re-typing a fragment from a node schema re-introduces those as transcription bugs you only discover when the cloud rejects the job. Start from what works. - Only author `./fragments/.json` by hand when there is **no** working - graph to project from (you're building a shape that doesn't exist yet): 1-15 - API-format nodes wrapped with a `_fragment` header declaring typed inputs, - outputs, and params (caller-supplied values marked `"PLACEHOLDER"`). Make - EVERY asset/model name a required param with no default. Load the - `comfy-fragments` skill for the format, then check your work: - ```bash - comfy --json workflow fragment validate - ``` + When there is **no** working graph to project from (you're building a shape + that doesn't exist yet), author a **recipe** instead (see the recipe section + above): build it once with the structured-edit primitives, `capture` it, and + lift the asset/model/prompt fields to `${param}`. Recipes are UI-format, + mergeable, and reusable — prefer them over the legacy fragment/blueprint + authoring described below. **d. Compose + run** — a YAML blueprint in `blueprints/.yaml` wires your fragments together; cross-step refs use `$alias.output_name`, @@ -256,7 +252,12 @@ mechanism by complexity and reuse — not as a quality ranking: blueprint.** Even for smaller workflows, prefer fragments if any part could be extended, repeated, or reused. -## The compile model — edit source, never the artifact (REQUIRED) +## The compile model — edit source, never the artifact (fragments; legacy) + +> **Legacy.** Fragments/blueprints/`compose` produce **API format**, which can't live +> on the canvas or merge (CRDT). For reuse/composition prefer **recipes** (`apply +> --param` + `capture`, above). This section remains for the existing project/blueprint +> convention and headless batch compiles; new authoring should use recipes. The folders are **source**; the workflow JSON is a **build artifact**. `fragments/` + `blueprints/` are what you edit; `compose` is the compiler; @@ -283,9 +284,120 @@ This is a hard contract, not a style preference: **Red flag — STOP:** you typed `jq`/`sed`/`Edit` against a workflow's `widgets_values` or `inputs`, or you're hunting for a node by numeric id -(`select(.id==128)`). That means the source representation failed. `decompose` -it and set a named param. (This rule exists because that exact jq-on-`id==128` -hand-edit is the anti-pattern `decompose` was built to kill.) +(`select(.id==128)`). That means the source representation failed. For **reusable** +work, `decompose` it and set a named param. For a **programmatic structured edit** +of a live graph, use the structured-edit primitives below — never raw `jq`/`sed`. +(This rule exists because that exact jq-on-`id==128` hand-edit is the anti-pattern +`decompose` — and these primitives — were built to kill.) + +## Structured graph edits — `add-node` / `connect` / `set-widget` / `delete-node` + +The **sanctioned** way to mutate a graph's *structure* from code — the +alternative to `jq`/`sed` on `nodes`/`links`/`widgets_values`. Each edit is +validated against `object_info` (node class, widget name, widget value **shape**, +and connection **type** are hard-checked; unknown COMBO values / out-of-range +numbers come back as soft `warnings`) and emits a replayable **operation** in +`data.op`. + +**When to use which editing path:** +- **Reusable / human-authored workflow** → fragments + blueprint (above). *Default.* +- **Throwaway value tweak on a template** → `slots` → `set-slot`/`vary`. +- **Programmatic structured edit of a live/draft graph** (add or wire or remove + nodes; the in-app agent's path; any edit that must merge with a concurrent + human editor) → the primitives here. + +> **Live co-editing / CRDT:** only the structured-edit primitives (`add-node`/ +> `connect`/`set-widget`/`delete-node`/`apply`) emit a mergeable **op** in +> `data.op`/`data.ops` (`op_id` + `actor` + `base_version` + `stamp`). Fragments + +> `compose` produce a **whole-document** graph — fine for authoring a *fresh* +> draft (the base), but it does **not** emit ops and will clobber a concurrent +> editor if used to re-generate an existing draft. **Any edit that must merge with +> a human's canvas MUST go through the primitives, not a recompose.** + +```bash +# Catalog source: `--where cloud|local` (default routing if omitted), or an +# offline `--input object_info.json` dump. `--where` and `--input` are the only +# catalog flags on these commands. +CAT="--where cloud" + +# Start from an existing graph, or an empty one: +echo '{"nodes":[],"links":[],"last_node_id":0,"last_link_id":0}' > wf.json + +comfy --json workflow add-node wf.json KSampler --at 400,200 $CAT # → data.op.node_id (minted) +comfy --json workflow connect wf.json 7.LATENT 3.samples $CAT # source out-slot → target in-slot +comfy --json workflow set-widget wf.json 3.steps 35 $CAT # widget by NAME; op carries {old,value} +comfy --json workflow delete-node wf.json 7 $CAT # removes node + its links +comfy --json workflow ls-nodes wf.json # id / type / title (no catalog needed) +``` + +**Building more than one or two nodes? Use `apply` — one batch, one catalog load, +and `as` aliases so you never capture a minted id by hand:** + +```bash +cat > ops.json <<'JSON' +[ {"op":"add_node","class_type":"CheckpointLoaderSimple","as":"ckpt"}, + {"op":"add_node","class_type":"KSampler","as":"ks"}, + {"op":"connect","from":"ckpt.MODEL","to":"ks.model"}, + {"op":"set_widget","node":"ks","widget":"steps","value":30} ] +JSON +comfy --json workflow apply wf.json --ops ops.json $CAT # or --ops - to read stdin +# → data.ops[] (all minted ids), data.aliases{ckpt,ks}. Atomic: nothing writes if any spec fails. +``` + +**Reusable recipes (the reuse path — prefer this over fragments/compose).** A recipe +is an ops file with a `params` header and `${param}` holes: + +```jsonc +{ "recipe":"t2i", + "params": { "positive": {"type":"string"}, // required (no default) + "steps": {"type":"int", "default":20} }, // type: string|int|float|bool + "ops": [ …, {"op":"set_widget","node":"ks","widget":"steps","value":"${steps}"} ] } +``` + +`apply --param k=v` fills the holes — **typed and strict**: a value exactly `${x}` +takes the param's real value; a missing required param, an unknown param, or a bad +type all error (never a silent blank). + +```bash +# capture a working graph into a recipe, PARAMETERIZING the fields you'll vary: +comfy --json workflow capture wf.json --name t2i --param 6.text=positive --param 3.seed=seed -o t2i.recipe.json $CAT +comfy --json workflow apply fresh.json --ops t2i.recipe.json --param positive="a fox" --param seed=42 $CAT +``` + +`capture --param .=` lifts that widget to a `${name}` hole +(current value becomes its default) — use it for the fields you want to vary, since +plain `capture` omits widgets left at their default. Recipes are UI-format op-batches +— mergeable and canvas-native. `compose`/`decompose` (the fragment/blueprint path) +are **legacy**: they emit API format and can't co-edit; use recipes for anything +you'll reuse or edit on the canvas. + +**Run it and get the image back.** Inside a project, outputs land in `outputs/`. +Outside one (a bare `wf.json`), submit and pipe the result into `download`: + +```bash +comfy --json run --workflow wf.json --where cloud --wait > run.json # blocks until done; data.prompt_id + output refs +comfy --json download --out-dir ./out < run.json # pull the produced image(s) to ./out +``` + +- **Addresses:** `.`. Connection slots accept a **name** + (source output like `LATENT`, target input like `samples`) or an index; widgets + are addressed **by name**. Discover them with `comfy --json nodes show ` + (input/output slot names) and `comfy --json workflow slots ` (widget + names — note `slots` lists **widgets only**, not connection slots). +- **Identity:** `add-node` mints a **large random integer** id (leaderless, + collision-free) and returns it in `data.op.node_id`; **capture it** to wire the + new node (e.g. `id=$(comfy --json workflow add-node … | jq -r .data.op.node_id)`). + Do not assume small/sequential ids. `add-node` fills widget defaults (COMBO → + first choice), so a new node is runtime-valid without extra `set-widget` calls. +- **The op** (`data.op`): `{op, op_id, node_id/link_id, actor, base_version, stamp}` + — a structured, idempotent, mergeable record of the change (`set_widget` also + carries `old`/`value`). `--actor ` and `--base-version ` stamp it for + concurrent/CRDT consumers; `--stdout` prints the new graph instead of writing + in place. +- **`delete-node` ≠ `delete`:** `delete-node` removes a *node from the graph file*; + `comfy workflow delete` deletes a *saved workflow from Comfy Cloud*. Do not confuse them. +- These operate on **top-level** nodes of frontend-format graphs. For values + *inside a subgraph*, use `set-slot`'s nested address (`10/9.prompt`) or decompose. --- @@ -345,6 +457,12 @@ comfy --json nodes ls --pack core --produces MASK --limit 5 If no local server is running and you're not signed into cloud, pass `--input ` to query against a saved dump. +**Dynamic-combo gotcha:** some partner nodes declare a `COMFY_DYNAMICCOMBO_V3` +widget (e.g. a Kling/Grok `model` or `model.resolution`) whose **`choices` come +back empty from `nodes show`** — the options are resolved at runtime. To learn the +valid values, `comfy templates fetch ` for that node and read the +widget values it ships (e.g. `model="kling-v3"`, `model.resolution="720p"`). + ## Models — find what's installed, with metadata On **cloud**, `comfy models search` hits the live asset catalog @@ -1004,8 +1122,8 @@ names files `_.`. Read outputs by item, never by array order. Avoid the old `PIDS=()` shell-loop pattern — it duplicates scheduling the engine already does and gives you N jobs to babysit instead of one. (For a pure prompt/seed sweep over the *same* graph, `comfy -workflow vary` is the right tool; see the `comfy-fragments` skill for the -full blueprint syntax.) +workflow vary` is the right tool; for reusable parameterized generation use a +recipe with `workflow foreach`.) **The exception — when fan-out across separate jobs IS right.** A multi-shot film built on **partner-API video/avatar nodes** (KlingAvatar, Kling i2v, Sora, diff --git a/comfy_cli/skills/command.py b/comfy_cli/skills/command.py index dd91ca217..edf9ceac2 100644 --- a/comfy_cli/skills/command.py +++ b/comfy_cli/skills/command.py @@ -9,7 +9,6 @@ - ``comfy`` — the consolidated driver skill (command surface, output contract, routing, discovery, execution, image, video, audio, cloud, edit, condition, pipeline) - - ``comfy-fragments`` — typed reusable workflow fragments + YAML blueprint composition - ``comfy-debug`` — debugging when workflows fail or jobs hang - ``comfy-relay`` — what to put in chat while driving the CLI - ``comfy-director`` — narrative multi-shot video production (screenplay, diff --git a/comfy_cli/standalone.py b/comfy_cli/standalone.py index 9c89bde52..41b42fa80 100644 --- a/comfy_cli/standalone.py +++ b/comfy_cli/standalone.py @@ -4,8 +4,6 @@ import subprocess from pathlib import Path -import requests - from comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, OS, PROC from comfy_cli.typing import PathLike from comfy_cli.utils import create_tarball, download_url, extract_tarball, get_os, get_proc @@ -34,6 +32,10 @@ def _resolve_python_version(asset_url_prefix: str, minor_version: str) -> str: Downloads the SHA256SUMS file (~45 KB) from the release and parses it to find the available patch version for the requested minor series (e.g. "3.12" -> "3.12.13"). """ + # Imported lazily: requests costs ~30ms to import and this module is on + # the import path of every CLI invocation. + import requests + sha256sums_url = f"{asset_url_prefix.rstrip('/')}/SHA256SUMS" response = requests.get(sha256sums_url) response.raise_for_status() @@ -67,6 +69,8 @@ def download_standalone_python( ) -> PathLike: """grab a pre-built distro from the python-build-standalone project. See https://gregoryszorc.com/docs/python-build-standalone/main/""" + import requests # deferred; see _resolve_python_version + platform = get_os() if platform is None else platform proc = get_proc() if proc is None else proc target = _platform_targets[(platform, proc)] diff --git a/comfy_cli/tracking.py b/comfy_cli/tracking.py index 580bceb19..aa1c0e25d 100644 --- a/comfy_cli/tracking.py +++ b/comfy_cli/tracking.py @@ -10,10 +10,8 @@ from typing import Any, Protocol import typer -from mixpanel import Mixpanel -from posthog import Posthog -from comfy_cli import constants, logging, ui +from comfy_cli import constants, logging from comfy_cli.config_manager import ConfigManager from comfy_cli.workspace_manager import WorkspaceManager @@ -139,7 +137,15 @@ def flush(self) -> None: ... class MixpanelProvider: def __init__(self, token: str): - self.client = Mixpanel(token) if token else None + if token: + # Imported lazily: mixpanel (and its urllib3 dependency) is only + # needed once an event is actually sent, and importing it at + # module import slows down every CLI invocation. + from mixpanel import Mixpanel + + self.client = Mixpanel(token) + else: + self.client = None self.enabled = self.client is not None def track(self, event_name: str, distinct_id: str | None, properties: dict[str, Any]) -> None: @@ -165,8 +171,16 @@ def __init__(self, token: str, host: str): self.enabled = False if not token: return + # Imported lazily (see MixpanelProvider) — posthog costs ~100ms to import. + from posthog import Posthog + # disable_geoip=False lets PostHog enrich events with IP-derived location. - self.client = Posthog(project_api_key=token, host=host, disable_geoip=False) + # flush_interval is passed explicitly because the client's atexit join + # waits out the full interval even on an empty queue; the library + # default varies by version (0.5s in 7.18, 5.0s in 7.21) and would add + # that much dead time to every CLI exit. 0.2s bounds the exit cost while + # the explicit flush() in _flush_all_providers still drains real events. + self.client = Posthog(project_api_key=token, host=host, disable_geoip=False, flush_interval=0.2) self.enabled = True def track(self, event_name: str, distinct_id: str | None, properties: dict[str, Any]) -> None: @@ -186,10 +200,24 @@ def flush(self) -> None: self.client.flush() -PROVIDERS: list[TelemetryProvider] = [ - MixpanelProvider(MIXPANEL_TOKEN), - PostHogProvider(POSTHOG_TOKEN, POSTHOG_HOST), -] +# Providers are constructed lazily on the first event dispatch, NOT at module +# import. Building them eagerly costs ~100ms of import time and starts +# PostHog's consumer thread, whose atexit join stalls every CLI exit — even +# for fully opted-out invocations that never send anything. ``None`` means +# "not constructed yet"; tests may patch in a ready-made list. +PROVIDERS: list[TelemetryProvider] | None = None + + +def _get_providers() -> list[TelemetryProvider]: + """Return the telemetry providers, constructing them on first use.""" + global PROVIDERS + if PROVIDERS is None: + PROVIDERS = [ + MixpanelProvider(MIXPANEL_TOKEN), + PostHogProvider(POSTHOG_TOKEN, POSTHOG_HOST), + ] + return PROVIDERS + app = typer.Typer() @@ -215,7 +243,7 @@ def _dispatch( passive telemetry, env-only for feedback). """ properties = {**properties, "cli_version": cli_version, "tracing_id": tracing_id} - for provider in PROVIDERS: + for provider in _get_providers(): provider_event_name = ( mixpanel_name if (mixpanel_name is not None and isinstance(provider, MixpanelProvider)) else event_name ) @@ -376,6 +404,10 @@ def prompt_tracking_consent(skip_prompt: bool = False, default_value: bool = Fal pass return + # Imported lazily: ui pulls in questionary/prompt_toolkit (~50ms) and is + # only needed on this interactive consent path. + from comfy_cli import ui + enable_tracking = ui.prompt_confirm_action("Do you agree to enable tracking to improve the application?", False) init_tracking(enable_tracking) @@ -408,6 +440,10 @@ def init_tracking(enable_tracking: bool): def _flush_all_providers() -> None: + # Never construct providers here: if none were built, no event was ever + # dispatched this process, so there is nothing to flush. + if PROVIDERS is None: + return for provider in PROVIDERS: try: provider.flush() diff --git a/comfy_cli/ui.py b/comfy_cli/ui.py index 74087776c..5e0f025d6 100644 --- a/comfy_cli/ui.py +++ b/comfy_cli/ui.py @@ -1,15 +1,22 @@ +from __future__ import annotations + from enum import Enum -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar -import questionary import typer -from questionary import Choice from rich.console import Console from rich.progress import Progress from rich.table import Table from comfy_cli.workspace_manager import WorkspaceManager +# questionary pulls in prompt_toolkit (~50ms of import time), so it is +# imported lazily inside each prompting function rather than at module import. +if TYPE_CHECKING: + from questionary import Choice + + ChoiceType = str | Choice | dict[str, Any] + console = Console() workspace_manager = WorkspaceManager() @@ -35,9 +42,6 @@ def show_progress(iterable, total, description="Downloading..."): progress.update(task, advance=len(chunk)) -ChoiceType = str | Choice | dict[str, Any] - - def prompt_autocomplete( question: str, choices: list[ChoiceType], default: ChoiceType = "", force_prompting: bool = False ) -> ChoiceType | None: @@ -55,6 +59,8 @@ def prompt_autocomplete( """ if workspace_manager.skip_prompting and not force_prompting: return None + import questionary + return questionary.autocomplete(question, choices=choices, default=default).ask() @@ -75,6 +81,8 @@ def prompt_select( """ if workspace_manager.skip_prompting and not force_prompting: return None + import questionary + return questionary.select(question, choices=choices, default=default).ask() @@ -96,6 +104,8 @@ def prompt_select_enum(question: str, choices: list[E], force_prompting: bool = if workspace_manager.skip_prompting and not force_prompting: return None + import questionary + choice_map = {choice.value: choice for choice in choices} display_choices = list(choice_map.keys()) @@ -120,6 +130,8 @@ def prompt_input(question: str, default: str = "", force_prompting: bool = False """ if workspace_manager.skip_prompting and not force_prompting: return default + import questionary + return questionary.text(question, default=default).ask() @@ -134,6 +146,8 @@ def prompt_multi_select(prompt: str, choices: list[str]) -> list[str]: Returns: List[str]: A list of the selected items. """ + import questionary + selections = questionary.checkbox(prompt, choices=choices).ask() # returns list of selected items return selections if selections else [] diff --git a/comfy_cli/utils.py b/comfy_cli/utils.py index e5c582e23..03f518ada 100644 --- a/comfy_cli/utils.py +++ b/comfy_cli/utils.py @@ -12,7 +12,6 @@ from typing import BinaryIO, cast import psutil -import requests import typer from rich import progress from rich.live import Live @@ -118,6 +117,10 @@ def download_url( ) -> PathLike: """download url to local file fname and show a progress bar. See https://stackoverflow.com/q/37573483""" + # Imported lazily: requests costs ~30ms to import and utils is on the + # import path of every CLI invocation; only downloads need it. + import requests + cwd = Path(cwd).expanduser().resolve() fpath = cwd / fname diff --git a/comfy_cli/where.py b/comfy_cli/where.py index cb2c87d22..963734b07 100644 --- a/comfy_cli/where.py +++ b/comfy_cli/where.py @@ -124,8 +124,10 @@ def _has_cloud_credentials() -> bool: is clearly the configured backend; preflight surfaces the expiry), so this deliberately doesn't use ``resolve_cloud_credential``. """ - from comfy_cli.credentials import find_api_key, get_session + from comfy_cli.credentials import cloud_bearer_env_token, find_api_key, get_session + if cloud_bearer_env_token() is not None: + return True if find_api_key(purpose="cloud") is not None: return True return get_session(refresh=False) is not None @@ -154,6 +156,7 @@ def cloud_preflight() -> CloudError | None: """Return an error envelope payload if the cloud path can't proceed. Accepts either auth path: + - ``COMFY_CLOUD_AUTH_TOKEN`` env var (forwarded Bearer token), OR - ``COMFY_CLOUD_API_KEY`` env var, OR - persisted ``comfy-cloud-api-key`` provider record, OR - active OAuth session (valid + non-expired). @@ -162,10 +165,13 @@ def cloud_preflight() -> CloudError | None: - Nothing configured → ``cloud_not_configured`` - OAuth session expired → ``cloud_unauthorized`` """ - from comfy_cli.credentials import find_api_key, get_session + from comfy_cli.credentials import cloud_bearer_env_token, find_api_key, get_session - # API key path — no expiry check, key is either valid or it isn't (server - # tells us at request time). + # Forwarded Bearer token (trusted-caller path) or ambient API key — no + # expiry check, the value is either valid or it isn't (server tells us at + # request time). + if cloud_bearer_env_token() is not None: + return None if find_api_key(purpose="cloud") is not None: return None diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py new file mode 100644 index 000000000..f22392f41 --- /dev/null +++ b/comfy_cli/workflow_ops.py @@ -0,0 +1,1112 @@ +"""CRDT-ready structured edit operations over frontend-format ComfyUI graphs. + +This is the op-model the agent (and a human via the CLI) uses to mutate a +workflow. Every primitive returns ``(workflow, op)`` where ``op`` is a +self-describing, replayable operation. The same op stream feeds both a +single-writer file edit (locally) and a merge consumer (cloud) — the CLI never +merges; it emits ops that *converge under replay* and, for the residual cases a +leaderless writer cannot decide alone, flags a conflict rather than silently +diverging. + +Design (settled by the identity spike): + +* **Identity is leaderless & collision-free.** New node/link ids are random + 53-bit integers (``mint_id``): no shared counter, no coordination, and still + ``int``-typed so the API converter (which gates link ids on ``isinstance(int)``) + and an int-keyed frontend keep working. ``last_node_id``/``last_link_id`` are + kept only as advisory high-water marks, never as allocators. +* **Widgets are name-addressed, never index-addressed.** ``set_widget`` carries + the widget *name*; ``apply_op`` resolves name → ``widgets_values`` index against + the live schema at apply time, so an op survives widget-layout drift. + +Convergence guarantees ``apply_op`` upholds, so any replay order reaches the same +``canonical`` graph (proved by the P8..P11 order-independence tests): + +* **Idempotent** — an op whose ``op_id`` was already applied is a no-op. +* **Total** — a write (``set_widget``/``connect``) to a node that was + concurrently deleted is a no-op: delete wins. Apply never raises on a + since-removed target, so a merge consumer can replay a delete and an edge/edit + in either order. +* **Last-writer-wins on widgets** — two concurrent writes to the same widget + converge on the value with the higher causal ``stamp`` ``[base_version, actor]`` + (``op_id`` breaks exact ties into a total order), independent of apply order. + The winning stamp per target is tracked in ``_widget_stamps`` (apply-only + bookkeeping, stripped before serialization). +* **Deterministic structure** — forking a shared subgraph definition mints an id + derived from ``(definition, instance)`` (not a random UUID), so two replicas + replaying the same ops produce byte-identical graphs. +* **Non-clobbering autogrow** — a ``COMFY_AUTOGROW_V3`` connect never overwrites a + slot already wired to another link; it grows a fresh slot keyed by ``grow_id`` + (the link id). Two concurrent autogrow connects both survive and ``canonical`` + compares grown slots by ``grow_id``, not by list position. + +The one thing a leaderless writer genuinely *cannot* converge is a *sequence +decision*: the human-visible ordering/numbering of concurrently-grown autogrow +slots (a batch's element order) and of concurrent interior writes to the same +shared subgraph definition. Those are surfaced by :func:`detect_conflict` for the +merge consumer / ask-to-merge to resolve — the ops still never lose data, and +``canonical`` treats the order as immaterial, so the semantic graph converges even +while the display order does not. +""" + +from __future__ import annotations + +import copy +import json +import random +import re +import uuid +from typing import Any + +# New ids live in [2**40, 2**53): always large (never collides with small +# frontend counter ids), always inside JS Number.MAX_SAFE_INTEGER. +_ID_FLOOR = 1 << 40 + + +def mint_id() -> int: + """A leaderless, collision-free, int-typed identity for a node or link.""" + return _ID_FLOOR | random.getrandbits(52) + + +def _new_op(kind: str, actor: str, base_version: int, **fields: Any) -> dict[str, Any]: + return { + "op": kind, + "op_id": uuid.uuid4().hex, + "actor": actor, + "base_version": base_version, + "stamp": [base_version, actor], + **fields, + } + + +def _find(workflow: dict, node_id: Any) -> dict | None: + for n in workflow.get("nodes") or []: + if isinstance(n, dict) and n.get("id") == node_id: + return n + return None + + +def _require(workflow: dict, node_id: Any) -> dict: + n = _find(workflow, node_id) + if n is None: + raise ValueError(f"node {node_id} not found in workflow") + return n + + +def _available_nodes_hint(workflow: dict, *, limit: int = 12) -> str: + """Compact ``id (type)`` list of nodes that DO exist — to correct a + mistargeted node id.""" + out: list[str] = [] + for n in workflow.get("nodes") or []: + if isinstance(n, dict) and n.get("id") is not None: + out.append(f"{n.get('id')} ({n.get('type', '?')})") + if len(out) >= limit: + out.append("…") + break + return ", ".join(out) + + +def _enrich_resolution_error(e: ValueError, workflow: dict, graph, *, widget: Any = None) -> ValueError: + """Turn a *not-found* edit error into an actionable one. + + An LLM editing a graph tends to rebuild an identifier from memory instead of + copying it from ``comfy workflow slots`` — and a wrong id often lands on a + real *sibling* (e.g. ``285/288.vae_name`` hits a CLIPLoader when the VAELoader + is ``285/29``), so the edit fails or, worse, silently mis-targets. When the + widget name is known we scan the workflow for the address that actually + carries it; otherwise we list the node ids that exist. Shape/enum/type + errors (the target resolved fine) pass through unchanged. + """ + msg = str(e) + if "not found" not in msg: + return e + if widget: + from comfy_cli.cql.engine import _suggest_slots_for_input + + addrs = _suggest_slots_for_input(workflow, str(widget), graph) + if addrs: + return ValueError( + f"{msg}. Did you mean: {'; '.join(addrs)}? " + "Copy the address verbatim from `comfy workflow slots` — never rebuild it." + ) + nodes_hint = _available_nodes_hint(workflow) + if nodes_hint: + return ValueError( + f"{msg}. Nodes in this workflow: {nodes_hint}. " + "Use an id from `comfy workflow slots` / `ls-nodes` — never rebuild it." + ) + return e + + +def _find_by_str(workflow: dict, node_id: Any) -> dict | None: + """Locate a node comparing ids as strings — subgraph op paths carry string + ids while top-level node ids are ints.""" + s = str(node_id) + for n in workflow.get("nodes") or []: + if isinstance(n, dict) and str(n.get("id", "")) == s: + return n + return None + + +def _stamp_key(op: dict) -> list: + """A total causal order for last-writer-wins: higher ``base_version`` wins, + ties broken by ``actor`` then the unique ``op_id`` (so no two distinct ops + ever compare equal).""" + stamp = op.get("stamp") or [op.get("base_version", 0), op.get("actor", "")] + return [stamp[0], stamp[1], op["op_id"]] + + +def _lww_gate(workflow: dict, op: dict) -> bool: + """True iff this ``set_widget`` should apply under last-writer-wins. A write + to a target already claimed by a higher-or-equal stamp is dropped, making the + surviving value independent of apply order.""" + prior = workflow.get("_widget_stamps", {}).get(json.dumps(_write_target(op), default=str)) + return prior is None or _stamp_key(op) > list(prior) + + +def _lww_commit(workflow: dict, op: dict) -> None: + """Record this op's stamp as the winner for its target.""" + workflow.setdefault("_widget_stamps", {})[json.dumps(_write_target(op), default=str)] = _stamp_key(op) + + +def _next_autogrow_name(ins: list, requested: str) -> str: + """A free autogrow slot name. Prefer the op's requested name; if a concurrent + connect already took it, grow the next sequential ``{base}.{elem}{N}`` so no + slot is ever clobbered (the server convention stays sequential).""" + taken = {i.get("name") for i in ins} + if requested not in taken: + return requested + base, _, stem = requested.partition(".") + elem = stem.rstrip("0123456789") or "slot" + n = 0 + while f"{base}.{elem}{n}" in taken: + n += 1 + return f"{base}.{elem}{n}" + + +# --------------------------------------------------------------------------- +# primitives — each returns (workflow, op); the op is applied via apply_op so +# apply(base, op) == primitive(base) holds by construction (P1 fidelity). +# --------------------------------------------------------------------------- + + +def add_node( + workflow: dict, + graph, + class_type: str, + *, + pos: list | None = None, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + m = graph.node(class_type) + if m is None: + raise ValueError(f"unknown node type {class_type!r}") + node = _build_node(mint_id(), class_type, m, graph, pos) + op = _new_op( + "add_node", + actor, + base_version, + node_id=node["id"], + class_type=class_type, + pos=node["pos"], + node=node, + ) + return apply_op(workflow, op, graph), op + + +def set_widget( + workflow: dict, + graph, + node_id: Any, + widget: str, + value: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + """Set a widget, enriching a not-found node/widget error with the real + address that carries ``widget`` so a mistargeted edit self-corrects in one + step (see :func:`_enrich_resolution_error`).""" + try: + return _set_widget_impl(workflow, graph, node_id, widget, value, actor=actor, base_version=base_version) + except ValueError as e: + raise _enrich_resolution_error(e, workflow, graph, widget=widget) from e + + +def _normalize_combo(graph, class_type: str, widget: str, value: Any) -> tuple[Any, dict | None]: + """Rewrite a mangled model/COMBO value to the real option it means so the + model actually loads (e.g. ``checkpoints/wai-illustrious-sdxl.safetensors`` → + ``wai-illustrious-sdxl.safetensors``). Returns ``(value, note)`` — ``note`` is + an informational warning when a rewrite happened, else ``None``. Only an + UNAMBIGUOUS match is rewritten; anything else is left untouched so validate's + ``unknown_enum_value`` (with ``did_you_mean``) still fires. + """ + m = graph.node(class_type) + if m is None: + return value, None + port = next((p for p in m.inputs if p.name == widget), None) + if port is None: + return value, None + canon = port.canonical_combo(value) + if canon is None or canon == value: + return value, None + return canon, { + "code": "normalized_value", + "field": widget, + "message": f"{value!r} is not an exact option; using the matching model {canon!r}", + "from": str(value), + "to": canon, + } + + +def _set_widget_impl( + workflow: dict, + graph, + node_id: Any, + widget: str, + value: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + # Subgraph-aware: a subgraph instance's *promoted* input (flat ``57.text`` — + # exactly what ``comfy workflow slots`` advertises) or an interior node + # (nested ``57/27.text``) resolves INTO the subgraph definition. Both forms + # reuse the CQL engine's slot resolver so set-widget and slots agree. The op + # carries the resolved interior ``path`` + ``inner_widget`` so apply/replay is + # deterministic and writes back into the definition (the change persists). + sub = _subgraph_write_target(workflow, node_id, widget) + if sub is not None: + segments, inner_widget = sub + target = _navigate_subgraph_path(workflow, segments) # read-only: current value + schema + inner_type = target.get("type", "") + value, norm_note = _normalize_combo(graph, inner_type, inner_widget, value) + order = graph.widget_order(inner_type) + old = None + if inner_widget in order: + i = order.index(inner_widget) + cur = target.get("widgets_values") or [] + old = cur[i] if i < len(cur) else None + warnings = _validate_widget(graph, inner_type, inner_widget, value) # raises on shape mismatch + if norm_note: + warnings = [norm_note, *warnings] + op = _new_op( + "set_widget", + actor, + base_version, + node_id=node_id, + widget=widget, + value=value, + old=old, + path=[str(s) for s in segments], + inner_widget=inner_widget, + ) + if warnings: + op["warnings"] = warnings + return apply_op(workflow, op, graph), op + + node = _require(workflow, node_id) + class_type = node.get("type", "") + idx = _widget_index(graph, class_type, widget) # raises on unknown widget name + value, norm_note = _normalize_combo(graph, class_type, widget, value) + widgets = node.get("widgets_values") or [] + old = widgets[idx] if idx < len(widgets) else None + warnings = _validate_widget(graph, class_type, widget, value) # raises on shape mismatch + if norm_note: + warnings = [norm_note, *warnings] + op = _new_op( + "set_widget", + actor, + base_version, + node_id=node_id, + widget=widget, + value=value, + old=old, + ) + if warnings: + op["warnings"] = warnings + return apply_op(workflow, op, graph), op + + +def _subgraph_write_target(workflow: dict, node_id: Any, widget: str) -> tuple[list[str], str] | None: + """Resolve a subgraph promoted/interior widget address to ``(node_path, inner_widget)``. + + Returns ``None`` when ``node_id`` is an ordinary top-level node (the caller + uses the direct widget path). Two address forms resolve here — the SAME ones + ``comfy workflow slots`` advertises for a subgraph instance: + + * FLAT promoted input (``57.text``): ``node_id`` is a subgraph *instance* + and ``widget`` names one of its promoted proxy inputs; we follow the + instance's ``proxyWidgets`` to the interior node that backs it. + * NESTED interior (``57/27.text``): ``node_id`` already carries the + ``/`` path; its segments pass straight through. + + Raises ``ValueError`` (with a slots-consistent hint) when the node is a + subgraph instance but ``widget`` is not one of its promoted inputs. + """ + from comfy_cli.cql import engine as _engine + + node_str = str(node_id) + # Nested interior form: the interior path is explicit. + if _engine._SUBGRAPH_PATH_SEP in node_str: + return node_str.split(_engine._SUBGRAPH_PATH_SEP), widget + + defs_by_id = _engine._subgraph_defs_by_id(workflow) + if not defs_by_id: + return None + instance = _find(workflow, node_id) + if instance is None: + return None # let the direct path raise the canonical "node not found" + if defs_by_id.get(instance.get("type", "")) is None: + return None # ordinary top-level node + # Subgraph instance: map the promoted input name → interior node via proxyWidgets. + proxy = (instance.get("properties") or {}).get("proxyWidgets") or [] + proxied: list[str] = [] + for entry in proxy: + if not (isinstance(entry, list) and len(entry) >= 2): + continue + name = entry[1] if isinstance(entry[1], str) else str(entry[1]) + proxied.append(name) + if name == widget: + return [node_str, str(entry[0])], widget + raise ValueError( + f"promoted input {widget!r} not found on subgraph node {node_id}; " + f"available: {', '.join(proxied) if proxied else '(none)'} " + f"(or address an interior widget directly, e.g. {node_str}/.)" + ) + + +def _navigate_subgraph_path(workflow: dict, segments: list[str]) -> dict: + """Read-only walk of a ``/``-separated node path into subgraph definitions. + + Unlike the engine's apply-time resolver this does NOT fork shared definitions + (a read must not mutate); the forking happens at apply time. Raises + ``ValueError`` describing the first hop that couldn't be found. + """ + from comfy_cli.cql import engine as _engine + + defs_by_id = _engine._subgraph_defs_by_id(workflow) + node = next( + (n for n in workflow.get("nodes") or [] if isinstance(n, dict) and str(n.get("id", "")) == str(segments[0])), + None, + ) + if node is None: + raise ValueError(f"node {segments[0]} not found in workflow") + for seg in segments[1:]: + sg = defs_by_id.get(node.get("type", "")) + if sg is None: + raise ValueError(f"node {node.get('id')} is not a subgraph; cannot descend to {seg!r}") + node = next( + (n for n in (sg.get("nodes") or []) if isinstance(n, dict) and str(n.get("id", "")) == str(seg)), + None, + ) + if node is None: + raise ValueError(f"interior node {seg} not found in subgraph {sg.get('id')}") + return node + + +def connect( + workflow: dict, + graph, + from_node: Any, + from_slot: Any, + to_node: Any, + to_slot: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + """Wire two nodes, enriching a not-found endpoint error with the list of + node ids that exist (see :func:`_enrich_resolution_error`).""" + try: + return _connect_impl( + workflow, graph, from_node, from_slot, to_node, to_slot, actor=actor, base_version=base_version + ) + except ValueError as e: + raise _enrich_resolution_error(e, workflow, graph) from e + + +def _connect_impl( + workflow: dict, + graph, + from_node: Any, + from_slot: Any, + to_node: Any, + to_slot: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + src = _require(workflow, from_node) + dst = _require(workflow, to_node) + out_idx, link_type = _resolve_output_slot(src, graph, from_slot) + in_idx, grow = _resolve_input_target(dst, graph, to_slot, link_type) + # Type-check concrete slots: an output only connects to an input of the same + # type (or a wildcard "*"). Autogrow slots are minted with the source type, + # so they need no check. Without this, a mis-wire silently clobbers a link. + if in_idx is not None: + dst_type = (dst.get("inputs") or [])[in_idx].get("type") + if link_type and dst_type and link_type != dst_type and "*" not in (link_type, dst_type): + raise ValueError( + f"type mismatch: {link_type} output of node {from_node} cannot connect to " + f"{dst_type} input {(dst.get('inputs') or [])[in_idx].get('name')!r} of node {to_node}" + ) + op = _new_op( + "connect", + actor, + base_version, + link_id=mint_id(), + from_node=from_node, + from_slot=out_idx, + to_node=to_node, + to_slot=in_idx, + link_type=link_type, + ) + if grow is not None: + op["grow"] = grow # autogrow: apply appends this input slot, then wires it + return apply_op(workflow, op, graph), op + + +def delete_node( + workflow: dict, + graph, + node_id: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + """Delete a node, enriching a not-found error with the list of node ids that + exist (see :func:`_enrich_resolution_error`).""" + try: + return _delete_node_impl(workflow, graph, node_id, actor=actor, base_version=base_version) + except ValueError as e: + raise _enrich_resolution_error(e, workflow, graph) from e + + +def _delete_node_impl( + workflow: dict, + graph, + node_id: Any, + *, + actor: str = "cli", + base_version: int = 0, +) -> tuple[dict, dict]: + _require(workflow, node_id) + removed = [ln[0] for ln in workflow.get("links") or [] if ln[1] == node_id or ln[3] == node_id] + op = _new_op("delete_node", actor, base_version, node_id=node_id, removed_links=removed) + return apply_op(workflow, op, graph), op + + +# --------------------------------------------------------------------------- +# recipes — a parameterized op-batch. A recipe is `{params?, ops:[...]}`; a bare +# list is a param-less batch. `${name}` placeholders in op values are filled from +# `--param`. Validation is strict: a required param with no value, an unknown +# param, or a `${name}` the recipe didn't declare all fail — never a silent blank. +# --------------------------------------------------------------------------- + +_PARAM_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +class RecipeError(ValueError): + """A recipe or its parameters are malformed.""" + + +def parse_recipe(doc: Any) -> tuple[list, dict]: + """Split a recipe document into (ops, params_decl). Accepts a bare op list.""" + if isinstance(doc, list): + return doc, {} + if isinstance(doc, dict) and isinstance(doc.get("ops"), list): + return doc["ops"], (doc.get("params") or {}) + raise RecipeError("recipe must be a JSON array of ops, or an object with an `ops` array") + + +def resolve_params(params_decl: dict, provided: dict[str, str]) -> dict[str, Any]: + """Type-coerce provided values against the declared params. Errors on an + unknown param, or a declared param with neither a value nor a default.""" + unknown = sorted(set(provided) - set(params_decl)) + if unknown: + raise RecipeError(f"unknown --param {unknown}; recipe declares {sorted(params_decl)}") + out: dict[str, Any] = {} + for name, decl in params_decl.items(): + decl = decl if isinstance(decl, dict) else {} + if name in provided: + out[name] = _coerce_param(provided[name], decl.get("type", "string"), name) + elif "default" in decl: + out[name] = decl["default"] + else: + raise RecipeError(f"missing required --param {name!r}") + return out + + +def _coerce_param(raw: str, type_name: str, name: str) -> Any: + if type_name == "int": + try: + return int(raw) + except ValueError as e: + raise RecipeError(f"--param {name}: expected int, got {raw!r}") from e + if type_name in ("float", "number"): + try: + return float(raw) + except ValueError as e: + raise RecipeError(f"--param {name}: expected number, got {raw!r}") from e + if type_name in ("bool", "boolean"): + low = raw.strip().lower() + if low in ("true", "1", "yes"): + return True + if low in ("false", "0", "no"): + return False + raise RecipeError(f"--param {name}: expected bool, got {raw!r}") + return raw # string (the default) + + +def substitute_params(ops: list, params: dict[str, Any]) -> list: + """Replace `${name}` in op values. A value that is exactly `${name}` takes the + param's real (typed) value; embedded refs interpolate as text. An undeclared + `${name}` is an error, not a blank.""" + + def sub(value: Any) -> Any: + if isinstance(value, str): + whole = _PARAM_REF.fullmatch(value) + if whole: + return _param(whole.group(1), params) + return _PARAM_REF.sub(lambda m: str(_param(m.group(1), params)), value) + if isinstance(value, list): + return [sub(v) for v in value] + if isinstance(value, dict): + return {k: sub(v) for k, v in value.items()} + return value + + return [sub(op) for op in ops] + + +def _param(name: str, params: dict[str, Any]) -> Any: + if name not in params: + raise RecipeError(f"recipe references undeclared param ${{{name}}}") + return params[name] + + +def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | None = None) -> dict: + """Project a UI-format graph into a recipe — the op-batch that rebuilds it + (add_node + non-default set_widget + connect). The inverse of `apply`: + `apply(empty, capture(wf))` reproduces `wf`. Top-level nodes only. + + `lift` maps `(node_id, widget_name) -> param_name`: those widgets become + `${param_name}` holes (with a `params` header entry defaulting to the current + value) even if the value equals the node default — so the fields you want to + vary are actually parameterizable. No auto-parameterization otherwise.""" + if (workflow.get("definitions") or {}).get("subgraphs"): + raise RecipeError("capture does not support subgraphs yet — edit/flatten top-level nodes first") + lift = lift or {} + nodes = [n for n in (workflow.get("nodes") or []) if isinstance(n, dict) and "id" in n] + by_id = {n["id"]: n for n in nodes} + + # Validate lift targets up front — no silently-ignored typos. + for (node_id, widget), _pname in lift.items(): + node = by_id.get(node_id) + if node is None: + raise RecipeError(f"--param target node {node_id!r} not in workflow") + if widget not in graph.widget_order(node.get("type", "")): + raise RecipeError(f"--param target {node_id}.{widget!r}: not a widget on {node.get('type')}") + + alias_by_id: dict[Any, str] = {} + counts: dict[str, int] = {} + for n in nodes: + slug = re.sub(r"[^a-z0-9]+", "_", str(n.get("type", "node")).lower()).strip("_") or "node" + counts[slug] = counts.get(slug, 0) + 1 + alias_by_id[n["id"]] = slug if counts[slug] == 1 else f"{slug}_{counts[slug]}" + + ops: list[dict] = [] + params_header: dict[str, Any] = {} + for n in nodes: + alias = alias_by_id[n["id"]] + class_type = n.get("type") + add: dict[str, Any] = {"op": "add_node", "class_type": class_type, "as": alias} + if n.get("pos"): + add["at"] = n["pos"] + ops.append(add) + order = graph.widget_order(class_type) + defaults = graph.widget_defaults(class_type) + widgets = n.get("widgets_values") or [] + for i, wname in enumerate(order): + if i >= len(widgets): + break + pname = lift.get((n["id"], wname)) + if pname is not None: + # Explicitly lifted → a ${param} hole, current value as its default. + ops.append({"op": "set_widget", "node": alias, "widget": wname, "value": f"${{{pname}}}"}) + params_header[pname] = {"type": _widget_param_type(graph, class_type, wname), "default": widgets[i]} + elif widgets[i] != defaults.get(wname): + # Only widgets that differ from the fresh-node default — add_node fills the rest. + ops.append({"op": "set_widget", "node": alias, "widget": wname, "value": widgets[i]}) + + node_by_id = {n["id"]: n for n in nodes} + for ln in workflow.get("links") or []: + if not (isinstance(ln, list) and len(ln) >= 5): + continue + _lid, from_id, from_slot, to_id, to_slot = ln[0], ln[1], ln[2], ln[3], ln[4] + if from_id not in alias_by_id or to_id not in alias_by_id: + continue + out_name = _slot_name(node_by_id[from_id].get("outputs"), from_slot) + in_name = _slot_name(node_by_id[to_id].get("inputs"), to_slot) + ops.append( + {"op": "connect", "from": f"{alias_by_id[from_id]}.{out_name}", "to": f"{alias_by_id[to_id]}.{in_name}"} + ) + + return {"recipe": name, "params": params_header, "ops": ops} + + +def _widget_param_type(graph, class_type: str, widget: str) -> str: + """Recipe param type for a widget, from its schema port type.""" + m = graph.node(class_type) + port = next((p for p in (m.inputs if m else []) if p.name == widget), None) + t = (port.type if port else "").upper() + if t == "INT": + return "int" + if t in ("FLOAT", "NUMBER"): + return "float" + if t == "BOOLEAN": + return "bool" + return "string" + + +def _slot_name(slots: Any, idx: Any) -> Any: + """A link's slot addressed by name where the node declares one, else by index + (both are valid connect targets).""" + if isinstance(slots, list) and isinstance(idx, int) and 0 <= idx < len(slots): + nm = slots[idx].get("name") if isinstance(slots[idx], dict) else None + if nm: + return nm + return idx + + +# --------------------------------------------------------------------------- +# apply_specs — run a batch of edit specs (add_node/connect/set_widget/delete_node) +# with `as` aliases so later specs reference just-minted nodes. Shared by the +# `apply` and `foreach` commands. Raises on a malformed spec so the caller can keep +# the batch atomic (write nothing on failure). +# --------------------------------------------------------------------------- + + +def resolve_ref(ref: Any, aliases: dict[str, Any]) -> Any: + """Map an alias to its minted id; pass ints/unknown strings through.""" + if isinstance(ref, str): + if ref in aliases: + return aliases[ref] + if ref.lstrip("-").isdigit(): + return int(ref) + return ref + + +def _split_ref_slot(spec_val: str, aliases: dict[str, Any]) -> tuple[Any, Any]: + """Split `.` and resolve the node part.""" + node_part, _, slot = str(spec_val).partition(".") + return resolve_ref(node_part, aliases), slot + + +def apply_specs(workflow: dict, graph, specs: list, *, actor: str = "cli", base_version: int = 0) -> tuple[dict, list, dict]: + """Apply edit specs to ``workflow`` in order. Returns (workflow, ops, aliases).""" + aliases: dict[str, Any] = {} + ops: list[dict] = [] + for i, spec in enumerate(specs): + if not isinstance(spec, dict) or "op" not in spec: + raise ValueError(f"spec #{i} must be an object with an 'op' field") + kind = spec["op"] + if kind == "add_node": + workflow, op = add_node(workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version) + if spec.get("as"): + aliases[spec["as"]] = op["node_id"] + elif kind == "connect": + fn, fs = _split_ref_slot(spec["from"], aliases) + tn, ts = _split_ref_slot(spec["to"], aliases) + workflow, op = connect(workflow, graph, fn, fs, tn, ts, actor=actor, base_version=base_version) + elif kind == "set_widget": + workflow, op = set_widget( + workflow, graph, resolve_ref(spec["node"], aliases), spec["widget"], spec["value"], + actor=actor, base_version=base_version, + ) + elif kind == "delete_node": + workflow, op = delete_node(workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version) + else: + raise ValueError(f"spec #{i}: unknown op {kind!r}") + ops.append(op) + return workflow, ops, aliases + + +# --------------------------------------------------------------------------- +# apply — the deterministic, idempotent replay used by every consumer +# --------------------------------------------------------------------------- + + +def apply_op(workflow: dict, op: dict, graph) -> dict: + """Replay one op onto ``workflow`` in place and return it. Idempotent: an + op whose ``op_id`` was already applied is a no-op.""" + applied = workflow.setdefault("_applied_ops", []) + if op["op_id"] in applied: + return workflow + kind = op["op"] + if kind == "add_node": + _apply_add_node(workflow, op) + elif kind == "set_widget": + _apply_set_widget(workflow, op, graph) + elif kind == "connect": + _apply_connect(workflow, op) + elif kind == "delete_node": + _apply_delete_node(workflow, op) + else: + raise ValueError(f"unknown op {kind!r}") + applied.append(op["op_id"]) + return workflow + + +def _apply_add_node(workflow: dict, op: dict) -> None: + nodes = workflow.setdefault("nodes", []) + if any(n.get("id") == op["node_id"] for n in nodes): + return + nodes.append(copy.deepcopy(op["node"])) + workflow["last_node_id"] = max(workflow.get("last_node_id") or 0, op["node_id"]) + + +def _apply_set_widget(workflow: dict, op: dict, graph) -> None: + # Last-writer-wins: a lower-stamped concurrent write to this target is + # dropped, so the surviving value is the same in any apply order. + if not _lww_gate(workflow, op): + return + path = op.get("path") + if path: + # Subgraph interior write. A concurrently-deleted instance => no-op + # (delete wins). Otherwise descend the resolved node path (forking any + # shared definition en route so a sibling instance can't alias this + # write) and set the interior widget — the reference resolver the + # ``slots``/``set-slot`` surface uses, so the two agree by construction. + if _find_by_str(workflow, path[0]) is None: + return + from comfy_cli.cql import engine as _engine + + defs_by_id = _engine._subgraph_defs_by_id(workflow) + target = _engine._resolve_node_path(workflow, [str(s) for s in path], defs_by_id) + _engine._write_widget(target, op["inner_widget"], op["value"], graph, extend=False) + _lww_commit(workflow, op) + return + node = _find(workflow, op["node_id"]) + if node is None: + return # target concurrently deleted => no-op (delete wins). + idx = _widget_index(graph, node.get("type", ""), op["widget"]) + widgets = node.setdefault("widgets_values", []) + if idx >= len(widgets): + widgets.extend([None] * (idx + 1 - len(widgets))) + widgets[idx] = op["value"] + _lww_commit(workflow, op) + + +def _apply_connect(workflow: dict, op: dict) -> None: + # Totality: either endpoint concurrently deleted => no-op (delete wins), so a + # merge consumer can replay a connect and a delete in either order without a + # crash or a dangling link. Resolve both before mutating anything. + dst = _find(workflow, op["to_node"]) + src = _find(workflow, op["from_node"]) + if dst is None or src is None: + return + grow = op.get("grow") + if grow is not None: + # Autogrow: grow a concrete slot and wire it. Keyed by ``grow_id`` (the + # link id) so replay is idempotent AND non-clobbering — a concurrent + # autogrow that minted the same requested name gets its own fresh slot + # instead of overwriting this one, so neither connection is lost. The + # slot's convergence identity is ``grow_id``; its display name stays + # sequential per the server's ``images.imageN`` convention. + ins = dst.setdefault("inputs", []) + to_idx = next((k for k, i in enumerate(ins) if i.get("grow_id") == op["link_id"]), None) + if to_idx is None: + entry = {"name": _next_autogrow_name(ins, grow["name"]), "type": grow["type"], "link": None, "grow_id": op["link_id"]} + if grow.get("widget"): + # Mark as a converted widget (ComfyUI's widget→input); value stays + # in widgets_values for positional alignment, converter uses the link. + entry["widget"] = {"name": grow["widget"]} + ins.append(entry) + to_idx = len(ins) - 1 + else: + to_idx = op["to_slot"] + # A concrete input holds at most one link. Replacing it must fully retire + # the old link (drop the tuple + scrub the old source's out-links). + prev = dst["inputs"][to_idx].get("link") + if prev is not None and prev != op["link_id"]: + _remove_link(workflow, prev) + link = [op["link_id"], op["from_node"], op["from_slot"], op["to_node"], to_idx, op["link_type"]] + links = workflow.setdefault("links", []) + if not any(ln[0] == op["link_id"] for ln in links): + links.append(link) + dst["inputs"][to_idx]["link"] = op["link_id"] + out_links = src["outputs"][op["from_slot"]].setdefault("links", []) + if op["link_id"] not in out_links: + out_links.append(op["link_id"]) + + +def _remove_link(workflow: dict, link_id: Any) -> None: + """Drop a link tuple and scrub every input/output reference to it.""" + workflow["links"] = [ln for ln in workflow.get("links") or [] if ln[0] != link_id] + for n in workflow.get("nodes") or []: + for inp in n.get("inputs") or []: + if inp.get("link") == link_id: + inp["link"] = None + for out in n.get("outputs") or []: + if link_id in (out.get("links") or []): + out["links"] = [lid for lid in out["links"] if lid != link_id] + + +def _apply_delete_node(workflow: dict, op: dict) -> None: + node_id = op["node_id"] + workflow["nodes"] = [n for n in workflow.get("nodes") or [] if n.get("id") != node_id] + removed = set(op.get("removed_links") or []) + kept = [ + ln + for ln in workflow.get("links") or [] + if ln[0] not in removed and ln[1] != node_id and ln[3] != node_id + ] + workflow["links"] = kept + kept_ids = {ln[0] for ln in kept} + # Scrub dangling references so no input/output points at a gone link. + for n in workflow.get("nodes") or []: + for inp in n.get("inputs") or []: + if inp.get("link") is not None and inp["link"] not in kept_ids: + inp["link"] = None + for out in n.get("outputs") or []: + out["links"] = [lid for lid in (out.get("links") or []) if lid in kept_ids] + + +# --------------------------------------------------------------------------- +# conflict detection + canonicalization (for ask-to-merge / convergence checks) +# --------------------------------------------------------------------------- + + +def _write_target(op: dict) -> tuple: + kind = op["op"] + if kind == "set_widget": + # Subgraph writes target the resolved interior path so the flat promoted + # form (``57.text``) and the nested form (``57/27.text``) that land on the + # same interior widget share one write target (converge, not clobber). + if op.get("path"): + return ("widget", tuple(str(s) for s in op["path"]), op["inner_widget"]) + return ("widget", op["node_id"], op["widget"]) + if kind in ("add_node", "delete_node"): + return ("node", op["node_id"]) + if kind == "connect": + grow = op.get("grow") + if grow is not None: + # Two autogrow connects onto the same base share a target (their + # relative order in the batch is the sequence decision the merge + # consumer must make); distinct bases don't collide. + return ("input", op["to_node"], "grow", str(grow["name"]).split(".", 1)[0]) + return ("input", op["to_node"], op["to_slot"]) + return (kind,) + + +def detect_conflict(a: dict, b: dict) -> bool: + """True iff two ops write the same target incompatibly — the signal V0's + ask-to-merge raises instead of silently clobbering. Two autogrow connects to + the same base conflict here (their batch order is undecidable leaderlessly) + even though :func:`apply_op` keeps both connections and ``canonical`` treats + their order as immaterial.""" + if _write_target(a) != _write_target(b): + return False + if a["op"] == "set_widget" and b["op"] == "set_widget": + return a.get("value") != b.get("value") + return True + + +def _slot_identity(inp: dict) -> tuple: + """A position-independent identity for an input slot: an autogrown slot is + keyed by its ``grow_id`` (stable across apply order), a fixed slot by name.""" + if isinstance(inp, dict) and inp.get("grow_id") is not None: + return ("grow", inp["grow_id"]) + return ("name", inp.get("name") if isinstance(inp, dict) else None) + + +def canonical(workflow: dict) -> dict: + """A comparison-stable view: strip apply bookkeeping and normalize every + order-dependent-but-semantically-immaterial detail away. Two graphs that + converged are ``canonical``-equal regardless of the order ops were applied in. + + Normalizations: nodes ordered by id; links ordered by id AND their target + slot resolved from a raw list index to a position-independent identity (so a + concurrently-grown slot landing at a different index still matches); + autogrown input slots ordered by ``grow_id`` with their order-dependent + display name folded out; subgraph definitions ordered by id. + """ + w = copy.deepcopy(workflow) + w.pop("_applied_ops", None) + w.pop("_widget_stamps", None) + nodes = w.get("nodes") + # Capture each node's original index -> slot identity BEFORE reordering + # inputs, so links (which reference the raw index) can be rewritten. + slot_identity: dict[Any, dict[int, tuple]] = {} + if isinstance(nodes, list): + for n in nodes: + if not isinstance(n, dict): + continue + slot_identity[n.get("id")] = {i: _slot_identity(inp) for i, inp in enumerate(n.get("inputs") or [])} + # Reorder each node's grown slots deterministically (by grow_id) and drop + # their display name, which is order-dependent (image0 vs image1). + for n in nodes: + if not isinstance(n, dict) or not isinstance(n.get("inputs"), list): + continue + fixed = [i for i in n["inputs"] if not (isinstance(i, dict) and i.get("grow_id") is not None)] + grown = sorted( + (i for i in n["inputs"] if isinstance(i, dict) and i.get("grow_id") is not None), + key=lambda i: i["grow_id"], + ) + for i in grown: + i["name"] = "\x00grow" + n["inputs"] = fixed + grown + w["nodes"] = sorted(nodes, key=lambda n: n.get("id")) + links = w.get("links") + if isinstance(links, list): + canon = [] + for ln in links: + ln = list(ln) + if len(ln) >= 5: + ident = slot_identity.get(ln[3], {}).get(ln[4]) + if ident is not None: + ln[4] = ident + canon.append(ln) + w["links"] = sorted(canon, key=lambda ln: ln[0]) + defs = (w.get("definitions") or {}).get("subgraphs") + if isinstance(defs, list): + w["definitions"]["subgraphs"] = sorted(defs, key=lambda sg: str(sg.get("id", ""))) + return w + + +def strip_internal(workflow: dict) -> dict: + """Remove apply-only bookkeeping before serializing to disk.""" + workflow.pop("_applied_ops", None) + workflow.pop("_widget_stamps", None) + return workflow + + +# --------------------------------------------------------------------------- +# schema helpers +# --------------------------------------------------------------------------- + + +def _build_node(node_id: int, class_type: str, m, graph, pos: list | None) -> dict: + inputs = [{"name": p.name, "type": p.type, "link": None} for p in m.inputs if p.is_link] + outputs = [{"name": p.name, "type": p.type, "links": []} for p in m.outputs] + # Widget values in positional order, including dynamic-combo selectors and + # their sub-widgets — sourced from the engine so add-node matches the converter. + defaults = graph.widget_defaults(class_type) + widgets = [defaults.get(name) for name in graph.widget_order(class_type)] + return { + "id": node_id, + "type": class_type, + "pos": list(pos) if pos else [0, 0], + "size": [210, 100], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": inputs, + "outputs": outputs, + "properties": {}, + "widgets_values": widgets, + } + + +def _widget_index(graph, class_type: str, widget: str) -> int: + order = graph.widget_order(class_type) + if widget not in order: + avail = [w for w in order if w != "control_after_generate"] + raise ValueError( + f"widget {widget!r} not found on {class_type}; " + f"available: {', '.join(avail) if avail else '(none — all inputs are links)'}" + ) + return order.index(widget) + + +def _validate_widget(graph, class_type: str, widget: str, value: Any) -> list[dict]: + """Shape-validate a widget value (hard error) and collect catalog warnings + (soft — e.g. unknown COMBO option, out-of-range number).""" + m = graph.node(class_type) + if m is None: + return [] + port = next((p for p in m.inputs if p.name == widget), None) + if port is None: + return [] + err = port.validate_shape(value) + if err: + raise ValueError(err) + return port.validate_catalog(value) + + +def _resolve_output_slot(node: dict, graph, slot: Any) -> tuple[int, str]: + outs = node.get("outputs") or [] + if isinstance(slot, int) or (isinstance(slot, str) and slot.lstrip("-").isdigit()): + i = int(slot) + if not (0 <= i < len(outs)): + raise ValueError(f"output slot {i} out of range for node {node.get('id')}") + return i, outs[i].get("type", "*") + for i, o in enumerate(outs): + if o.get("name") == slot: + return i, o.get("type", "*") + names = [o.get("name") for o in outs] + raise ValueError(f"output {slot!r} not found on node {node.get('id')}; outputs: {names}") + + +def _resolve_input_slot(node: dict, graph, slot: Any) -> int: + ins = node.get("inputs") or [] + if isinstance(slot, int) or (isinstance(slot, str) and slot.lstrip("-").isdigit()): + i = int(slot) + if not (0 <= i < len(ins)): + raise ValueError(f"input slot {i} out of range for node {node.get('id')}") + return i + for i, inp in enumerate(ins): + if inp.get("name") == slot: + return i + names = [inp.get("name") for inp in ins] + raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") + + +def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) -> tuple[int | None, dict | None]: + """Resolve a connect target. Returns ``(index, None)`` for a concrete input, + or ``(None, grow)`` where ``grow`` is the input slot to append (autogrow slot, + or a widget converted to an input). + + - **Autogrow** (``COMFY_AUTOGROW_V3``, e.g. ``BatchImagesNode.images``) declares + one base input but the server wants one slot key per connection + (``images.image0``, …). Addressing the base or a dotted ``images.imageN`` key + grows a concrete slot minted with the source type. + - **Widget → input** (e.g. ``CreateVideo.fps``): a widget-backed input isn't a + link slot, so it's converted — a linked input carrying a ``widget`` marker is + appended. Its value stays in ``widgets_values`` (positional alignment holds); + the API converter reads the link and skips the widget by name. + """ + ins = node.get("inputs") or [] + # Concrete slot (index or exact name) that is NOT an autogrow base. + try: + idx = _resolve_input_slot(node, None, slot) + if str(ins[idx].get("type", "")).startswith("COMFY_AUTOGROW"): + base = ins[idx].get("name") + return None, _plan_autogrow(ins, base, elem_type) + return idx, None + except ValueError: + pass + # Dotted autogrow key (images.image0) or a base that has no concrete slot yet. + if isinstance(slot, str): + base = slot.split(".", 1)[0] + ag = next((i for i in ins if i.get("name") == base and str(i.get("type", "")).startswith("COMFY_AUTOGROW")), None) + if ag is not None: + requested = slot if "." in slot else None + return None, _plan_autogrow(ins, base, elem_type, requested=requested) + # Widget-backed input: convert the widget to a linked input. + if graph is not None and isinstance(slot, str) and slot in graph.widget_order(node.get("type", "")): + return None, {"name": slot, "type": elem_type or "*", "widget": slot} + names = [i.get("name") for i in ins] + raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") + + +def _plan_autogrow(ins: list, base: str, elem_type: str | None, requested: str | None = None) -> dict: + existing = [i for i in ins if str(i.get("name", "")).startswith(base + ".")] + if requested and not any(i.get("name") == requested for i in ins): + name = requested + else: + elem = base[:-1] if base.endswith("s") else base + name = f"{base}.{elem}{len(existing)}" + return {"name": name, "type": elem_type or "*"} diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 5f094cabf..854985365 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1065,8 +1065,19 @@ def _dynamic_combo_sub_inputs( names: list[str] = [] for section in ("required", "optional"): section_def = sub_def.get(section) or {} - if isinstance(section_def, dict): - names.extend(f"{input_name}.{sub_name}" for sub_name in section_def.keys()) + if not isinstance(section_def, dict): + continue + for sub_name, sub_spec in section_def.items(): + # Only widget sub-inputs occupy a slot in ``widgets_values``. + # Connection-only sub-inputs (IMAGE, autogrow templates, + # ``forceInput`` widgets, ...) are wired via links and carry no + # saved value, so counting them here over-reports the combo's + # span and shifts every widget after it — including a seed's + # control_after_generate marker that then survives into the + # next input (e.g. GeminiNanoBanana2V2's ``response_modalities``). + is_widget, _is_dynamic = _is_widget_input(sub_spec) + if is_widget: + names.append(f"{input_name}.{sub_name}") return names return [] @@ -1184,9 +1195,27 @@ def is_control(v: Any) -> bool: for input_name, input_spec in section_def.items(): if vidx >= len(widget_values): break - is_widget, _is_dynamic = _is_widget_input(input_spec) + is_widget, is_dynamic = _is_widget_input(input_spec) if not is_widget: continue + if is_dynamic: + # A V3 dynamic combo (``COMFY_*COMBO*``) occupies its selector + # slot plus a variable number of sub-input slots chosen by the + # selected option. Copy the whole span through untouched and + # advance ``vidx`` in lockstep with ``_get_widget_name_order`` + # (which expands the same sub-inputs). Otherwise the walk + # treats the combo as a single slot, reaches a later seed input + # too early, checks the wrong slot for its control_after_generate + # marker, and leaves the marker in place — shifting every widget + # after the seed by one (e.g. GeminiNanoBanana2V2 / Nano Banana 2, + # whose dynamic ``model`` precedes the seed and whose + # ``response_modalities`` sits right after it, so the stray + # ``"fixed"`` lands on ``response_modalities``). + subs = _dynamic_combo_sub_inputs(input_name, input_spec, widget_values, vidx) + span = min(1 + len(subs), len(widget_values) - vidx) + out.extend(widget_values[vidx : vidx + span]) + vidx += span + continue out.append(widget_values[vidx]) vidx += 1 if vidx < len(widget_values) and _has_control_after_generate_companion( @@ -1205,21 +1234,34 @@ def _has_control_after_generate_companion(input_name: str, input_spec: Any, next Two ways the frontend adds the companion widget: * Explicit: the input spec sets ``control_after_generate: True``. - * Implicit: the input is named ``seed`` or ``noise_seed`` and is INT-typed. - The frontend's ``useIntWidget`` composable adds the companion in that case - regardless of the schema flag. - - For the implicit path we peek at the next value: older workflows saved - before the companion existed don't have the marker string, so we must - verify the slot really is a control keyword before consuming it. + * Implicit: a seed-like INT widget. The frontend's ``useIntWidget`` + composable appends the companion after seed-like INT inputs even when + the schema omits the flag. + + The implicit path is *value-gated and node-agnostic*: we only consume the + next slot when it is literally one of the control keywords + (``"fixed"``/``"increment"``/``"decrement"``/``"randomize"``). That string + is only ever present when the frontend really did append the companion, so + it is a reliable signal regardless of schema flags or the exact input name. + + We still require the input to be a seed-like INT (name contains ``seed``, + case-insensitive) rather than *any* INT. Partner/API nodes name the widget + every which way -- ``seed``/``noise_seed`` (Bria/Kling/Vidu/Wan2), + ``image_seed``/``model_seed``/``texture_seed`` (Tripo), ``Seed`` (Rodin3D), + ``rand_seed``, ``noise_seed_sde``, ``variation_seed`` -- and several ship + the input *unflagged*, so the old exact ``seed``/``noise_seed`` match let + their companion survive and shifted every later widget by one. Keeping the + ``seed`` substring guard preserves the schema-aware path's protection + against a legitimate non-seed INT (e.g. ``steps``) that merely happens to + precede a COMBO/STRING widget whose value equals a control keyword. """ + if not (isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES): + return False options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {} if options.get("control_after_generate"): - return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES + return True input_type = input_spec[0] if input_spec else None - if input_type == "INT" and input_name in ("seed", "noise_seed"): - return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES - return False + return input_type == "INT" and "seed" in input_name.lower() def _collect_widget_inputs( diff --git a/comfy_cli/workspace_manager.py b/comfy_cli/workspace_manager.py index 85809e55c..954bd5e4a 100644 --- a/comfy_cli/workspace_manager.py +++ b/comfy_cli/workspace_manager.py @@ -3,7 +3,6 @@ from datetime import datetime from enum import Enum -import git import typer import yaml @@ -86,6 +85,10 @@ def check_comfy_repo(path) -> tuple[bool, str | None]: """ if not os.path.exists(path): return False, None + # Imported lazily: GitPython costs ~90ms to import and is only needed on + # this detection path, not for every CLI invocation. + import git + try: repo = git.Repo(path, search_parent_directories=True) path_is_comfy_repo = any(remote.url in constants.COMFY_ORIGIN_URL_CHOICES for remote in repo.remotes) diff --git a/pyproject.toml b/pyproject.toml index f91d096e5..dd76e6e68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,10 @@ dependencies = [ "uv>=0.11.15", "websocket-client", + # Bundled static ffmpeg so `comfy preview` renders video/audio previews on + # machines without a system ffmpeg (the CLI-as-agent path). ffprobe metadata + # still needs system ffprobe; preview degrades to extension-based classification. + "imageio-ffmpeg", ] # `bench` — the BE-2302 arm-B micro-edit benchmark runner (comfy_cli/bench). Only a diff --git a/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json b/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json new file mode 100644 index 000000000..710069e33 --- /dev/null +++ b/tests/comfy_cli/command/generate/fixtures/partner_nodes_object_info.json @@ -0,0 +1,674 @@ +{ + "ByteDanceImageToVideoNode": { + "api_node": true, + "category": "partner/video/ByteDance", + "deprecated": false, + "description": "Generate video using ByteDance models via api based on image and prompt", + "dev_only": false, + "display_name": "ByteDance Image to Video", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "camera_fixed": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "Specifies whether to fix the camera. The platform appends an instruction to fix the camera to your prompt, but does not guarantee the actual effect." + } + ], + "generate_audio": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "This parameter is ignored for any model except seedance-1-5-pro." + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 0, + "display": "number", + "max": 2147483647, + "min": 0, + "step": 1, + "tooltip": "Seed to use for generation." + } + ], + "watermark": [ + "BOOLEAN", + { + "advanced": true, + "default": false, + "tooltip": "Whether to add an \"AI generated\" watermark to the video." + } + ] + }, + "required": { + "aspect_ratio": [ + "COMBO", + { + "multiselect": false, + "options": [ + "adaptive", + "16:9", + "4:3", + "1:1", + "3:4", + "9:16", + "21:9" + ], + "tooltip": "The aspect ratio of the output video." + } + ], + "duration": [ + "INT", + { + "default": 5, + "display": "slider", + "max": 12, + "min": 3, + "step": 1, + "tooltip": "The duration of the output video in seconds." + } + ], + "image": [ + "IMAGE", + { + "tooltip": "First frame to be used for the video." + } + ], + "model": [ + "COMBO", + { + "default": "seedance-1-0-pro-fast-251015", + "multiselect": false, + "options": [ + "seedance-1-5-pro-251215", + "seedance-1-0-pro-250528", + "seedance-1-0-lite-i2v-250428", + "seedance-1-0-pro-fast-251015" + ] + } + ], + "prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "The text prompt used to generate the video." + } + ], + "resolution": [ + "COMBO", + { + "multiselect": false, + "options": [ + "480p", + "720p", + "1080p" + ], + "tooltip": "The resolution of the output video." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "seed", + "camera_fixed", + "watermark", + "generate_audio" + ], + "required": [ + "model", + "prompt", + "image", + "resolution", + "aspect_ratio", + "duration" + ] + }, + "is_input_list": false, + "name": "ByteDanceImageToVideoNode", + "output": [ + "VIDEO" + ], + "output_is_list": [ + false + ], + "output_matchtypes": null, + "output_name": [ + "VIDEO" + ], + "output_node": false, + "output_tooltips": [ + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [ + { + "name": "model", + "type": "COMBO" + }, + { + "name": "duration", + "type": "INT" + }, + { + "name": "resolution", + "type": "COMBO" + }, + { + "name": "generate_audio", + "type": "BOOLEAN" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $priceByModel := {\n \"seedance-1-5-pro\": {\n \"480p\":[0.12,0.12],\n \"720p\":[0.26,0.26],\n \"1080p\":[0.58,0.59]\n },\n \"seedance-1-0-pro\": {\n \"480p\":[0.23,0.24],\n \"720p\":[0.51,0.56],\n \"1080p\":[1.18,1.22]\n },\n \"seedance-1-0-pro-fast\": {\n \"480p\":[0.09,0.1],\n \"720p\":[0.21,0.23],\n \"1080p\":[0.47,0.49]\n },\n \"seedance-1-0-lite\": {\n \"480p\":[0.17,0.18],\n \"720p\":[0.37,0.41],\n \"1080p\":[0.85,0.88]\n }\n };\n $model := widgets.model;\n $modelKey :=\n $contains($model, \"seedance-1-5-pro\") ? \"seedance-1-5-pro\" :\n $contains($model, \"seedance-1-0-pro-fast\") ? \"seedance-1-0-pro-fast\" :\n $contains($model, \"seedance-1-0-pro\") ? \"seedance-1-0-pro\" :\n \"seedance-1-0-lite\";\n $resolution := widgets.resolution;\n $resKey :=\n $contains($resolution, \"1080\") ? \"1080p\" :\n $contains($resolution, \"720\") ? \"720p\" :\n \"480p\";\n $modelPrices := $lookup($priceByModel, $modelKey);\n $baseRange := $lookup($modelPrices, $resKey);\n $min10s := $baseRange[0];\n $max10s := $baseRange[1];\n $scale := widgets.duration / 10;\n $audioMultiplier := ($modelKey = \"seedance-1-5-pro\" and widgets.generate_audio) ? 2 : 1;\n $minCost := $min10s * $scale * $audioMultiplier;\n $maxCost := $max10s * $scale * $audioMultiplier;\n ($minCost = $maxCost)\n ? {\"type\":\"usd\",\"usd\": $minCost, \"format\": { \"approximate\": true }}\n : {\"type\":\"range_usd\",\"min_usd\": $minCost, \"max_usd\": $maxCost, \"format\": { \"approximate\": true }}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_bytedance", + "search_aliases": null + }, + "Flux2ProImageNode": { + "api_node": true, + "category": "partner/image/BFL", + "deprecated": true, + "description": "Generates images synchronously based on prompt and resolution.", + "dev_only": false, + "display_name": "Flux.2 [pro] Image", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "images": [ + "IMAGE", + { + "tooltip": "Up to 9 images to be used as references." + } + ] + }, + "required": { + "height": [ + "INT", + { + "default": 768, + "max": 2048, + "min": 256, + "step": 32 + } + ], + "prompt": [ + "STRING", + { + "default": "", + "multiline": true, + "tooltip": "Prompt for the image generation or edit" + } + ], + "prompt_upsampling": [ + "BOOLEAN", + { + "advanced": true, + "default": true, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation." + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 0, + "max": 18446744073709551615, + "min": 0, + "tooltip": "The random seed used for creating the noise." + } + ], + "width": [ + "INT", + { + "default": 1024, + "max": 2048, + "min": 256, + "step": 32 + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "images" + ], + "required": [ + "prompt", + "width", + "height", + "seed", + "prompt_upsampling" + ] + }, + "is_input_list": false, + "name": "Flux2ProImageNode", + "output": [ + "IMAGE" + ], + "output_is_list": [ + false + ], + "output_matchtypes": null, + "output_name": [ + "IMAGE" + ], + "output_node": false, + "output_tooltips": [ + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [ + "images" + ], + "widgets": [ + { + "name": "width", + "type": "INT" + }, + { + "name": "height", + "type": "INT" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $MP := 1024 * 1024;\n $outMP := $max([1, $floor(((widgets.width * widgets.height) + $MP - 1) / $MP)]);\n $outputCost := 0.03 + 0.015 * ($outMP - 1);\n inputs.images.connected\n ? {\n \"type\":\"range_usd\",\n \"min_usd\": $outputCost + 0.015,\n \"max_usd\": $outputCost + 0.12,\n \"format\": { \"approximate\": true }\n }\n : {\"type\":\"usd\",\"usd\": $outputCost}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_bfl", + "search_aliases": null + }, + "GeminiImageNode": { + "api_node": true, + "category": "partner/image/Gemini", + "deprecated": false, + "description": "Edit images synchronously via Google API.", + "dev_only": false, + "display_name": "Nano Banana (Google Gemini Image)", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "optional": { + "aspect_ratio": [ + "COMBO", + { + "default": "auto", + "multiselect": false, + "options": [ + "auto", + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9" + ], + "tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares." + } + ], + "files": [ + "GEMINI_INPUT_FILES", + { + "tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node." + } + ], + "images": [ + "IMAGE", + { + "tooltip": "Optional image(s) to use as context for the model. To include multiple images, you can use the Batch Images node." + } + ], + "response_modalities": [ + "COMBO", + { + "advanced": true, + "multiselect": false, + "options": [ + "IMAGE+TEXT", + "IMAGE" + ], + "tooltip": "Choose 'IMAGE' for image-only output, or 'IMAGE+TEXT' to return both the generated image and a text response." + } + ], + "system_prompt": [ + "STRING", + { + "advanced": true, + "default": "You are an expert image-generation engine. You must ALWAYS produce an image.\nInterpret all user input\u2014regardless of format, intent, or abstraction\u2014as literal visual directives for image composition.\nIf a prompt is conversational or lacks specific visual details, you must creatively invent a concrete visual scenario that depicts the concept.\nPrioritize generating the visual representation above any text, formatting, or conversational requests.", + "multiline": true, + "tooltip": "Foundational instructions that dictate an AI's behavior." + } + ] + }, + "required": { + "model": [ + "COMBO", + { + "multiselect": false, + "options": [ + "gemini-2.5-flash-image" + ], + "tooltip": "The Gemini model to use for generating responses." + } + ], + "prompt": [ + "STRING", + { + "default": "", + "multiline": true, + "tooltip": "Text prompt for generation" + } + ], + "seed": [ + "INT", + { + "control_after_generate": true, + "default": 42, + "max": 18446744073709551615, + "min": 0, + "tooltip": "When seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "optional": [ + "images", + "files", + "aspect_ratio", + "response_modalities", + "system_prompt" + ], + "required": [ + "prompt", + "model", + "seed" + ] + }, + "is_input_list": false, + "name": "GeminiImageNode", + "output": [ + "IMAGE", + "STRING" + ], + "output_is_list": [ + false, + false + ], + "output_matchtypes": null, + "output_name": [ + "IMAGE", + "STRING" + ], + "output_node": false, + "output_tooltips": [ + null, + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [] + }, + "engine": "jsonata", + "expr": "{\"type\":\"usd\",\"usd\":0.039,\"format\":{\"suffix\":\"/Image (1K)\",\"approximate\":true}}" + }, + "python_module": "comfy_api_nodes.nodes_gemini", + "search_aliases": null + }, + "KlingImage2VideoNode": { + "api_node": true, + "category": "partner/video/Kling", + "deprecated": false, + "description": "", + "dev_only": false, + "display_name": "Kling Image(First Frame) to Video", + "essentials_category": null, + "experimental": false, + "has_intermediate_output": false, + "input": { + "hidden": { + "api_key_comfy_org": [ + "API_KEY_COMFY_ORG" + ], + "auth_token_comfy_org": [ + "AUTH_TOKEN_COMFY_ORG" + ], + "comfy_usage_source": [ + "COMFY_USAGE_SOURCE" + ], + "unique_id": [ + "UNIQUE_ID" + ] + }, + "required": { + "aspect_ratio": [ + "COMBO", + { + "default": "16:9", + "multiselect": false, + "options": [ + "16:9", + "9:16", + "1:1" + ] + } + ], + "cfg_scale": [ + "FLOAT", + { + "default": 0.8, + "max": 1.0, + "min": 0.0 + } + ], + "duration": [ + "COMBO", + { + "default": "5", + "multiselect": false, + "options": [ + "5", + "10" + ] + } + ], + "mode": [ + "COMBO", + { + "default": "std", + "multiselect": false, + "options": [ + "std", + "pro" + ] + } + ], + "model_name": [ + "COMBO", + { + "default": "kling-v2-master", + "multiselect": false, + "options": [ + "kling-v1", + "kling-v1-5", + "kling-v1-6", + "kling-v2-master", + "kling-v2-1", + "kling-v2-1-master", + "kling-v2-5-turbo" + ] + } + ], + "negative_prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "Negative text prompt" + } + ], + "prompt": [ + "STRING", + { + "multiline": true, + "tooltip": "Positive text prompt" + } + ], + "start_frame": [ + "IMAGE", + { + "tooltip": "The reference image used to generate the video." + } + ] + } + }, + "input_order": { + "hidden": [ + "auth_token_comfy_org", + "api_key_comfy_org", + "unique_id", + "comfy_usage_source" + ], + "required": [ + "start_frame", + "prompt", + "negative_prompt", + "model_name", + "cfg_scale", + "mode", + "aspect_ratio", + "duration" + ] + }, + "is_input_list": false, + "name": "KlingImage2VideoNode", + "output": [ + "VIDEO", + "STRING", + "STRING" + ], + "output_is_list": [ + false, + false, + false + ], + "output_matchtypes": null, + "output_name": [ + "VIDEO", + "video_id", + "duration" + ], + "output_node": false, + "output_tooltips": [ + null, + null, + null + ], + "price_badge": { + "depends_on": { + "input_groups": [], + "inputs": [], + "widgets": [ + { + "name": "mode", + "type": "COMBO" + }, + { + "name": "model_name", + "type": "COMBO" + }, + { + "name": "duration", + "type": "COMBO" + } + ] + }, + "engine": "jsonata", + "expr": "\n (\n $mode := widgets.mode;\n $model := widgets.model_name;\n $dur := widgets.duration;\n $contains($model,\"v2-5-turbo\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.7} : {\"type\":\"usd\",\"usd\":0.35})\n : ($contains($model,\"v2-1-master\") or $contains($model,\"v2-master\"))\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":2.8} : {\"type\":\"usd\",\"usd\":1.4})\n : ($contains($model,\"v2-1\") or $contains($model,\"v1-6\") or $contains($model,\"v1-5\"))\n ? (\n $contains($mode,\"pro\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.98} : {\"type\":\"usd\",\"usd\":0.49})\n : ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.56} : {\"type\":\"usd\",\"usd\":0.28})\n )\n : $contains($model,\"v1\")\n ? (\n $contains($mode,\"pro\")\n ? ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.98} : {\"type\":\"usd\",\"usd\":0.49})\n : ($contains($dur,\"10\") ? {\"type\":\"usd\",\"usd\":0.28} : {\"type\":\"usd\",\"usd\":0.14})\n )\n : {\"type\":\"usd\",\"usd\":0.14}\n )\n " + }, + "python_module": "comfy_api_nodes.nodes_kling", + "search_aliases": null + } +} diff --git a/tests/comfy_cli/command/generate/test_emit.py b/tests/comfy_cli/command/generate/test_emit.py index 1d060d6c3..003e00e48 100644 --- a/tests/comfy_cli/command/generate/test_emit.py +++ b/tests/comfy_cli/command/generate/test_emit.py @@ -4,12 +4,22 @@ """ import json +from pathlib import Path import pytest from typer.testing import CliRunner from comfy_cli.cmdline import app as cli_app from comfy_cli.command.generate import emit +from comfy_cli.cql.engine import Graph + +# Recorded object_info for the partner nodes MODEL_NODE_MAP targets, snapshotted +# from the cloud catalog. Used to enforce NodeSpec's completeness contract: the +# emitted node must carry EVERY widget input, optional section included (a +# schema-`optional` input may still be positionally required by execute()). +PARTNER_OBJECT_INFO = json.loads( + (Path(__file__).parent / "fixtures" / "partner_nodes_object_info.json").read_text(encoding="utf-8") +) @pytest.fixture(autouse=True) @@ -67,6 +77,66 @@ def test_build_kling_i2v_class_and_start_frame(): assert wf["1"]["inputs"]["start_frame"] == [loader_id, 0] +def test_build_seedance_fills_execute_required_defaults(): + """Regression: ByteDanceImageToVideoNode declares seed/camera_fixed/watermark + `optional=True` in its schema but its execute() takes them WITHOUT Python + defaults — omitting them validates cleanly and then fails the run with + "missing 3 required positional arguments" (observed live on cloud). The + emitter must always write them.""" + wf = emit.build_workflow( + "seedance", + {"prompt": "drift", "image": "frame.png", "model": "seedance-1-0-lite-i2v-250428"}, + ) + inputs = wf["1"]["inputs"] + assert inputs["seed"] == 0 + assert inputs["camera_fixed"] is False + assert inputs["watermark"] is False + assert inputs["generate_audio"] is False + + +def test_build_seedance_proxy_flag_spellings_reach_node_inputs(): + """The generate proxy flags are --ratio/--camerafixed; the node inputs are + aspect_ratio/camera_fixed. User-passed values must not be dropped.""" + wf = emit.build_workflow( + "seedance", + {"prompt": "drift", "image": "frame.png", "ratio": "9:16", "camerafixed": True, "watermark": True}, + ) + inputs = wf["1"]["inputs"] + assert inputs["aspect_ratio"] == "9:16" + assert inputs["camera_fixed"] is True + assert inputs["watermark"] is True + + +@pytest.mark.parametrize("model", sorted(emit.MODEL_NODE_MAP)) +def test_emitted_node_covers_every_widget_input(model): + """Completeness contract (see NodeSpec docstring): for every model the + emitter supports, the emitted partner node must contain ALL of the node's + widget (non-link) inputs — required AND optional — plus every required link + input. Schema-`optional` does not imply optional-at-execute for V3 nodes, + so any absent widget input is a potential run-time crash.""" + ns = emit.MODEL_NODE_MAP[model] + graph = Graph.from_object_info(PARTNER_OBJECT_INFO) + meta = graph.node(ns.node_class) + assert meta is not None, f"{ns.node_class} missing from the fixture snapshot — refresh it" + + values = {"prompt": "p"} + if ns.image_params: + values[next(iter(ns.image_params))] = "img.png" + wf = emit.build_workflow(model, values) + inputs = wf["1"]["inputs"] + + for port in meta.inputs: + if port.is_link: + if port.required: + assert port.name in inputs, f"{model}: required link input {port.name!r} not wired" + continue + assert port.name in inputs, ( + f"{model}: widget input {port.name!r} missing from the emitted node — " + f"schema-optional inputs may still be positionally required at execute() " + f"time; add a default to MODEL_NODE_MAP[{model!r}].fixed" + ) + + def test_unknown_model_lists_supported(): with pytest.raises(emit.EmitError) as ei: emit.build_workflow("dalle", {"prompt": "x"}) diff --git a/tests/comfy_cli/command/github/test_pr.py b/tests/comfy_cli/command/github/test_pr.py index 3b7e93e41..6c1290443 100644 --- a/tests/comfy_cli/command/github/test_pr.py +++ b/tests/comfy_cli/command/github/test_pr.py @@ -914,7 +914,7 @@ def test_specific_version_with_v_prefix_passes_through(self, mock_local, mock_ap mock_api.assert_not_called() mock_co.assert_called_once_with("/repo", "v0.20.1") - @patch("comfy_cli.command.install.requests.get") + @patch("requests.get") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) def test_latest_with_rate_limited_api_when_no_local_tags(self, mock_co, mock_get, tmp_path): """End-to-end repro of issue #440: empty local clone + 60/hr exhausted IP. @@ -936,7 +936,7 @@ def test_latest_with_rate_limited_api_when_no_local_tags(self, mock_co, mock_get mock_co.assert_not_called() - @patch("comfy_cli.command.install.requests.get") + @patch("requests.get") @patch("comfy_cli.command.install.git_checkout_tag", return_value=True) def test_latest_with_local_tags_no_network_at_all(self, mock_co, mock_get, tmp_path): """The pre-fix repro of issue #440: with local tags present, no @@ -998,7 +998,7 @@ def crash_on_api(*args, **kwargs): with ( patch.dict("os.environ", {}, clear=True), - patch("comfy_cli.command.install.requests.get", side_effect=crash_on_api), + patch("requests.get", side_effect=crash_on_api), patch("comfy_cli.command.install.clone_comfyui") as mock_clone, patch("comfy_cli.command.install.ensure_workspace_python", return_value=sys.executable), patch("comfy_cli.command.install.pip_install_comfyui_dependencies"), @@ -1042,7 +1042,7 @@ def test_full_execute_with_specific_version_no_api_no_resolver(self, tmp_path): with ( patch.dict("os.environ", {}, clear=True), patch( - "comfy_cli.command.install.requests.get", + "requests.get", side_effect=AssertionError("API must not be called for specific versions"), ), patch( diff --git a/tests/comfy_cli/command/test_code_search.py b/tests/comfy_cli/command/test_code_search.py index 56d628a41..63e0fcad1 100644 --- a/tests/comfy_cli/command/test_code_search.py +++ b/tests/comfy_cli/command/test_code_search.py @@ -258,7 +258,7 @@ def test_limit_hit(self, limit_hit_search): class TestFetchResults: - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_successful_fetch(self, mock_get, raw_api_response): mock_response = MagicMock() mock_response.json.return_value = raw_api_response @@ -270,7 +270,7 @@ def test_successful_fetch(self, mock_get, raw_api_response): mock_get.assert_called_once_with(API_URL, params={"query": "LoadImage"}, timeout=REQUEST_TIMEOUT) assert result == raw_api_response - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_http_error_propagates(self, mock_get): mock_response = MagicMock() mock_response.raise_for_status.side_effect = requests.HTTPError(response=MagicMock(status_code=500)) @@ -279,14 +279,14 @@ def test_http_error_propagates(self, mock_get): with pytest.raises(requests.HTTPError): _fetch_results("LoadImage") - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_timeout_propagates(self, mock_get): mock_get.side_effect = requests.Timeout("timed out") with pytest.raises(requests.Timeout): _fetch_results("LoadImage") - @patch("comfy_cli.command.code_search.requests.get") + @patch("requests.get") def test_connection_error_propagates(self, mock_get): mock_get.side_effect = requests.ConnectionError("no connection") diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index d1729aa1f..7ea95eac6 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -1059,6 +1059,37 @@ def test_ui_workflow_converts_and_submits(self, ui_workflow_file, fake_target): submitted_args, _ = mock_client.submit_prompt.call_args assert submitted_args[0] == self.CONVERTED + def test_ui_workflow_conversion_honors_object_info_file_env(self, ui_workflow_file, fake_target, tmp_path, monkeypatch): + """Both cloud object_info loads on this path (UI→API conversion, then + preflight-validate) are routed through resilient_load_object_info, so + COMFY_OBJECT_INFO_FILE — a pre-warmed/baked catalog an agent host + provides — must be read with NO live /object_info fetch at all.""" + from comfy_cli.comfy_client import SubmitResult + from comfy_cli.command.run import execute_cloud + + dump_path = tmp_path / "object_info.json" + dump_path.write_text(json.dumps({"KSampler": {}})) + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(dump_path)) + + mock_client = MagicMock() + mock_client.submit_prompt.return_value = SubmitResult(prompt_id="prompt-env", number=1, node_errors={}) + + def _network_fetch_should_not_run(**_kwargs): + raise AssertionError("network object_info fetch should not run with COMFY_OBJECT_INFO_FILE set") + + with ( + patch("comfy_cli.target.resolve_target", return_value=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", side_effect=_network_fetch_should_not_run), + patch("comfy_cli.comfy_client.Client", return_value=mock_client), + patch("comfy_cli.command.run._spawn_watcher"), + ): + execute_cloud(ui_workflow_file, wait=False) + + assert mock_convert.called + submitted_args, _ = mock_client.submit_prompt.call_args + assert submitted_args[0] == self.CONVERTED + def test_ui_workflow_conversion_failure_surfaces_conversion_error(self, ui_workflow_file, fake_target): from comfy_cli.command.run import execute_cloud from comfy_cli.workflow_to_api import WorkflowConversionError diff --git a/tests/comfy_cli/command/test_run_watcher.py b/tests/comfy_cli/command/test_run_watcher.py new file mode 100644 index 000000000..30504cd7a --- /dev/null +++ b/tests/comfy_cli/command/test_run_watcher.py @@ -0,0 +1,67 @@ +"""``COMFY_NO_WATCH`` — the env kill switch that suppresses the detached +watcher subprocess for agentic callers. + +The non-wait run path (both local and cloud) spawns a detached, credential- +inheriting watcher via ``subprocess.Popen(..., start_new_session=True)`` that +survives the parent process and polls the jobs API for up to 6h. Agents that +already have their own job-wait loop (e.g. the cloud agent's Redis pub/sub + +reconcile GET) have no use for it — it's a pure-waste orphan process holding +onto COMFY_CLOUD_AUTH_TOKEN / COMFY_CLOUD_API_KEY after the parent exits. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from comfy_cli.command.run.watcher import _no_watch_requested, _spawn_watcher + + +class TestNoWatchRequested: + def test_unset_is_false(self, monkeypatch): + monkeypatch.delenv("COMFY_NO_WATCH", raising=False) + assert _no_watch_requested() is False + + def test_one_is_true(self, monkeypatch): + monkeypatch.setenv("COMFY_NO_WATCH", "1") + assert _no_watch_requested() is True + + def test_false_like_values_are_false(self, monkeypatch): + for v in ("0", "false", "False", "no", "off", ""): + monkeypatch.setenv("COMFY_NO_WATCH", v) + assert _no_watch_requested() is False, f"{v!r} should not suppress the watcher" + + def test_other_truthy_values_are_true(self, monkeypatch): + for v in ("true", "TRUE", "yes", "1", "on"): + monkeypatch.setenv("COMFY_NO_WATCH", v) + assert _no_watch_requested() is True, f"{v!r} should suppress the watcher" + + +class TestSpawnWatcherHonorsKillSwitch: + def test_no_watch_env_suppresses_spawn(self, monkeypatch): + monkeypatch.setenv("COMFY_NO_WATCH", "1") + with patch("comfy_cli.command.run.watcher.subprocess.Popen") as mock_popen: + result = _spawn_watcher("prompt-123", where="cloud", notify=False) + + mock_popen.assert_not_called() + assert result is False + + def test_without_env_spawn_still_happens(self, monkeypatch): + # Control: with the kill switch unset, the existing spawn behavior is + # unchanged — Popen IS called. + monkeypatch.delenv("COMFY_NO_WATCH", raising=False) + with patch("comfy_cli.command.run.watcher.subprocess.Popen") as mock_popen: + result = _spawn_watcher("prompt-123", where="cloud", notify=False) + + mock_popen.assert_called_once() + assert result is True + + def test_no_watch_env_suppresses_local_spawn_too(self, monkeypatch): + # The same env check gates the local (non-cloud) watcher spawn site + # in run/__init__.py's execute(), since both route through + # _spawn_watcher. + monkeypatch.setenv("COMFY_NO_WATCH", "1") + with patch("comfy_cli.command.run.watcher.subprocess.Popen") as mock_popen: + result = _spawn_watcher("prompt-456", where="local", host="127.0.0.1", port=8188, notify=True) + + mock_popen.assert_not_called() + assert result is False diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py new file mode 100644 index 000000000..d73272347 --- /dev/null +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -0,0 +1,1452 @@ +"""Scenarios for `comfy workflow add-node/connect/set-widget/delete` — the +CRDT-ready structured-edit primitives. + +These are the observation layer for the red→green loop. Each command must: + * mutate a frontend-format workflow file (or --stdout), and + * emit a structured, CRDT-mergeable op in the envelope's `data.op`. + +The op-model correctness properties (fidelity / idempotency / convergence / +conflict-detection / name-safety / api-validity) are exercised directly against +`comfy_cli.workflow_ops`, which the commands wrap. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli import workflow_ops +from comfy_cli.caller import Caller +from comfy_cli.command import workflow as workflow_cmd +from comfy_cli.command import workflow_edit +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import ( + OutputMode, + Renderer, + reset_renderer_for_testing, + set_renderer, +) + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _object_info() -> dict[str, Any]: + return { + "CheckpointLoaderSimple": { + "input": {"required": {"ckpt_name": [["a.safetensors", "b.safetensors"]]}}, + "input_order": {"required": ["ckpt_name"]}, + "output": ["MODEL", "CLIP", "VAE"], + "output_name": ["MODEL", "CLIP", "VAE"], + "category": "loaders", + "display_name": "Load Checkpoint", + "python_module": "nodes", + }, + "CLIPTextEncode": { + "input": {"required": {"text": ["STRING", {"multiline": True}], "clip": "CLIP"}}, + "input_order": {"required": ["clip", "text"]}, + "output": ["CONDITIONING"], + "output_name": ["CONDITIONING"], + "category": "conditioning", + "display_name": "CLIP Text Encode", + "python_module": "nodes", + }, + "KSampler": { + "input": { + "required": { + "model": "MODEL", + "positive": "CONDITIONING", + "negative": "CONDITIONING", + "latent_image": "LATENT", + "seed": ["INT", {"default": 0, "min": 0, "max": 2**32, "control_after_generate": True}], + "steps": ["INT", {"default": 20, "min": 1, "max": 10000}], + "cfg": ["FLOAT", {"default": 8.0}], + "sampler_name": [["euler", "euler_ancestral"]], + "scheduler": [["normal", "karras"]], + "denoise": ["FLOAT", {"default": 1.0}], + }, + }, + "input_order": { + "required": [ + "model", + "positive", + "negative", + "latent_image", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + ] + }, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "KSampler", + "python_module": "nodes", + }, + "EmptyLatentImage": { + "input": { + "required": { + "width": ["INT", {"default": 512}], + "height": ["INT", {"default": 512}], + "batch_size": ["INT", {"default": 1}], + } + }, + "input_order": {"required": ["width", "height", "batch_size"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "latent", + "display_name": "Empty Latent Image", + "python_module": "nodes", + }, + "VAEDecode": { + "input": {"required": {"samples": "LATENT", "vae": "VAE"}}, + "input_order": {"required": ["samples", "vae"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "latent", + "display_name": "VAE Decode", + "python_module": "nodes", + }, + "KlingFLFTest": { + "input": { + "required": { + "first_frame": "IMAGE", + "last_frame": "IMAGE", + "prompt": ["STRING", {"default": ""}], + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "kling-v3", + "inputs": { + "required": { + "resolution": ["COMBO", {"default": "1080p", "options": ["4k", "1080p", "720p"]}] + } + }, + } + ] + }, + ], + } + }, + "input_order": {"required": ["first_frame", "last_frame", "prompt", "model"]}, + "output": ["VIDEO"], + "output_name": ["VIDEO"], + "category": "video", + "display_name": "Kling FLF (test)", + "python_module": "nodes", + }, + "PrimitiveFloat": { + "input": {"required": {"value": ["FLOAT", {"default": 1.0}]}}, + "input_order": {"required": ["value"]}, + "output": ["FLOAT"], + "output_name": ["FLOAT"], + "category": "primitive", + "display_name": "Float", + "python_module": "nodes", + }, + "BatchImagesNode": { + "input": {"required": {"images": "COMFY_AUTOGROW_V3"}}, + "input_order": {"required": ["images"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "image/batch", + "display_name": "Batch Images", + "python_module": "nodes", + }, + } + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + + +def _base_workflow() -> dict: + """A minimal but wired frontend-format graph: EmptyLatentImage -> KSampler.""" + return { + "last_node_id": 7, + "last_link_id": 1, + "nodes": [ + { + "id": 3, + "type": "KSampler", + "pos": [100, 100], + "inputs": [ + {"name": "model", "type": "MODEL", "link": None}, + {"name": "positive", "type": "CONDITIONING", "link": None}, + {"name": "negative", "type": "CONDITIONING", "link": None}, + {"name": "latent_image", "type": "LATENT", "link": 1}, + ], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": []}], + "widgets_values": [42, "fixed", 20, 8.0, "euler", "normal", 1.0], + }, + { + "id": 7, + "type": "EmptyLatentImage", + "pos": [0, 0], + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": [1]}], + "widgets_values": [512, 512, 1], + }, + ], + "links": [[1, 7, 0, 3, 3, "LATENT"]], + } + + +def _autogrow_workflow() -> dict: + """A BatchImagesNode (autogrow `images` input) plus two IMAGE sources, so + two connects can race onto the same autogrow base.""" + return { + "last_node_id": 21, + "last_link_id": 0, + "nodes": [ + { + "id": 10, + "type": "BatchImagesNode", + "pos": [200, 0], + "inputs": [{"name": "images", "type": "COMFY_AUTOGROW_V3", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + }, + { + "id": 20, + "type": "VAEDecode", + "pos": [0, 0], + "inputs": [{"name": "samples", "type": "LATENT", "link": None}, {"name": "vae", "type": "VAE", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + }, + { + "id": 21, + "type": "VAEDecode", + "pos": [0, 100], + "inputs": [{"name": "samples", "type": "LATENT", "link": None}, {"name": "vae", "type": "VAE", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + }, + ], + "links": [], + } + + +def _convergence_base() -> dict: + """A graph rich enough to exercise every op kind concurrently: two widget + nodes (KSampler 3, EmptyLatentImage 7), an autogrow sink (BatchImagesNode 10), + and two IMAGE sources (20, 21).""" + wf = _base_workflow() + wf["nodes"].append( + { + "id": 10, + "type": "BatchImagesNode", + "pos": [300, 0], + "inputs": [{"name": "images", "type": "COMFY_AUTOGROW_V3", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + } + ) + for nid, y in ((20, 0), (21, 120)): + wf["nodes"].append( + { + "id": nid, + "type": "VAEDecode", + "pos": [150, y], + "inputs": [{"name": "samples", "type": "LATENT", "link": None}, {"name": "vae", "type": "VAE", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + } + ) + wf["last_node_id"] = 21 + return wf + + +# Each spec mints one well-formed op against a fresh _convergence_base(); all are +# causally independent (they reference only base nodes), so any subset is a valid +# concurrent batch off the same base_version. +_CONVERGENCE_OP_SPECS = [ + "set_steps", + "set_cfg", + "set_width", + "set_steps2", # a second write to steps => LWW race with set_steps + "del_ksampler", + "del_latent", + "connect_latent", + "connect_latent2", # a second link into the same concrete slot => a conflict + "autogrow_20", + "autogrow_21", # a second autogrow onto the same base + "add_vae", +] + + +def _make_convergence_op(spec: str, rng, g) -> dict: + b = _convergence_base() + a = rng.choice("abc") + v = rng.randint(0, 3) + if spec == "set_steps": + _, op = workflow_ops.set_widget(b, g, 3, "steps", rng.randint(1, 40), actor=a, base_version=v) + elif spec == "set_cfg": + _, op = workflow_ops.set_widget(b, g, 3, "cfg", float(rng.randint(1, 15)), actor=a, base_version=v) + elif spec == "set_width": + _, op = workflow_ops.set_widget(b, g, 7, "width", rng.choice([256, 512, 768, 1024]), actor=a, base_version=v) + elif spec == "set_steps2": + _, op = workflow_ops.set_widget(b, g, 3, "steps", rng.randint(41, 80), actor=a, base_version=v) + elif spec == "del_ksampler": + _, op = workflow_ops.delete_node(b, g, 3, actor=a) + elif spec == "del_latent": + _, op = workflow_ops.delete_node(b, g, 7, actor=a) + elif spec == "connect_latent": + _, op = workflow_ops.connect(b, g, 7, "LATENT", 3, "latent_image", actor=a, base_version=v) + elif spec == "connect_latent2": + _, op = workflow_ops.connect(b, g, 7, "LATENT", 3, "latent_image", actor=a, base_version=v) + elif spec == "autogrow_20": + _, op = workflow_ops.connect(b, g, 20, "IMAGE", 10, "images", actor=a, base_version=v) + elif spec == "autogrow_21": + _, op = workflow_ops.connect(b, g, 21, "IMAGE", 10, "images", actor=a, base_version=v) + elif spec == "add_vae": + _, op = workflow_ops.add_node(b, g, "VAEDecode", actor=a) + else: # pragma: no cover - guard against a typo in the spec list + raise AssertionError(f"unknown convergence op spec {spec!r}") + return op + + +def _two_instance_subgraph_workflow() -> dict: + """Two top-level instances (57, 58) of ONE shared subgraph definition, so an + interior write must fork the shared def before mutating it.""" + wf = _subgraph_workflow() + inst57 = next(n for n in wf["nodes"] if n["id"] == 57) + inst58 = copy.deepcopy(inst57) + inst58["id"] = 58 + inst58["pos"] = [400, 0] + wf["nodes"].append(inst58) + wf["last_node_id"] = 58 + return wf + + +def _write(tmp_path: Path, data: dict, name: str = "wf.json") -> Path: + p = tmp_path / name + p.write_text(json.dumps(data, indent=2), encoding="utf-8") + return p + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(workflow_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + lines = [ln for ln in captured.strip().splitlines() if ln.strip()] + for line in reversed(lines): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +# --------------------------------------------------------------------------- +# add-node +# --------------------------------------------------------------------------- + + +class TestAddNode: + def test_adds_node_and_emits_op(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "VAEDecode"], capsys) + assert env["ok"] is True + op = env["data"]["op"] + assert op["op"] == "add_node" + assert op["class_type"] == "VAEDecode" + assert isinstance(op["op_id"], str) and op["op_id"] + nid = op["node_id"] + on_disk = json.loads(path.read_text()) + node = next(n for n in on_disk["nodes"] if n["id"] == nid) + assert node["type"] == "VAEDecode" + # inputs/outputs materialized from object_info so it is connectable + assert {i["name"] for i in node["inputs"]} == {"samples", "vae"} + assert [o["name"] for o in node["outputs"]] == ["IMAGE"] + + def test_combo_widgets_default_to_first_choice(self, patched_graph, tmp_path, capsys): + """A fresh node must not leave COMBO widgets null (would fail at runtime).""" + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "KSampler"], capsys) + nid = env["data"]["op"]["node_id"] + node = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid) + assert None not in node["widgets_values"], node["widgets_values"] + assert "euler" in node["widgets_values"] # sampler_name first choice + assert "normal" in node["widgets_values"] # scheduler first choice + + def test_where_flag_threads_to_catalog_resolver(self, monkeypatch, tmp_path, capsys): + captured: dict = {} + + def fake_get_graph(input_path, host, port, where=None): + captured["where"] = where + return _graph() + + monkeypatch.setattr(workflow_edit, "_get_graph", fake_get_graph) + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "VAEDecode", "--where", "cloud"], capsys) + assert env["ok"] is True + assert captured["where"] == "cloud" + + def test_ids_are_large_ints_and_collision_free(self, patched_graph, tmp_path, capsys): + """CRDT identity: leaderless, collision-free, int-typed (converter-safe).""" + path = _write(tmp_path, _base_workflow()) + env1 = _run(["add-node", str(path), "VAEDecode"], capsys) + env2 = _run(["add-node", str(path), "VAEDecode"], capsys) + id1, id2 = env1["data"]["op"]["node_id"], env2["data"]["op"]["node_id"] + assert isinstance(id1, int) and isinstance(id2, int) + assert id1 != id2 + # Not a small sequential counter value — minted from a large space. + assert id1 > 10_000 and id2 > 10_000 + + +# --------------------------------------------------------------------------- +# set-widget (name-addressed) +# --------------------------------------------------------------------------- + + +class TestSetWidget: + def test_sets_widget_by_name_and_records_old_new(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.steps", "35"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "set_widget" + assert op["node_id"] == 3 + assert op["widget"] == "steps" + assert op["value"] == 35 + assert op["old"] == 20 + on_disk = json.loads(path.read_text()) + ks = next(n for n in on_disk["nodes"] if n["id"] == 3) + # widget_order: seed, control_after_generate, steps -> index 2 + assert ks["widgets_values"][2] == 35 + + def test_unknown_widget_errors(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.nope", "1"], capsys) + assert env["ok"] is False + + def test_shape_mismatch_rejected(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.steps", "notanumber"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + def test_error_envelope_command_is_qualified(self, patched_graph, tmp_path, capsys): + """The error envelope's `command` must match the success one (not bare `workflow`).""" + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.nope", "1"], capsys) + assert env["command"] == "workflow set-widget" + + +# --------------------------------------------------------------------------- +# set-widget on SUBGRAPH-based templates (the modern gallery templates) +# +# Modern ComfyUI templates wrap their real nodes inside a subgraph *instance* +# (a top-level node whose `type` is a subgraph UUID). `slots` advertises the +# instance's promoted inputs as flat `.` addresses (e.g. +# `57.text`). set-widget MUST accept the SAME address slots emits — descending +# into the subgraph definition and writing the interior node's widget — plus the +# nested `/.` form the skill documents. +# --------------------------------------------------------------------------- + + +_SG_UUID = "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1" + + +def _subgraph_workflow() -> dict: + """A minimal curated subgraph template (derived from a fetched gallery + template): a top-level subgraph instance `57` whose promoted `text`/`seed`/ + `steps` route through `proxyWidgets` to interior CLIPTextEncode `27` and + KSampler `3`, plus a plain top-level node `9` so we can prove top-level edits + still work alongside subgraph edits.""" + return { + "last_node_id": 60, + "last_link_id": 0, + "nodes": [ + { + "id": 57, + "type": _SG_UUID, + "pos": [0, 0], + "inputs": [], + "outputs": [], + "properties": {"proxyWidgets": [["27", "text"], ["3", "seed"], ["3", "steps"]]}, + }, + { + "id": 9, + "type": "EmptyLatentImage", + "pos": [10, 10], + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": []}], + "widgets_values": [512, 512, 1], + }, + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": _SG_UUID, + "name": "Text to Image", + "inputs": [ + {"name": "text", "type": "STRING"}, + {"name": "seed", "type": "INT"}, + {"name": "steps", "type": "INT"}, + ], + "nodes": [ + {"id": 27, "type": "CLIPTextEncode", "widgets_values": ["old prompt"]}, + {"id": 3, "type": "KSampler", "widgets_values": [42, "fixed", 20, 8.0, "euler", "normal", 1.0]}, + ], + } + ] + }, + } + + +def _interior(wf: dict, inner_id) -> dict: + sg = wf["definitions"]["subgraphs"][0] + return next(n for n in sg["nodes"] if str(n["id"]) == str(inner_id)) + + +class TestSetWidgetSubgraph: + def test_flat_promoted_address_writes_interior_node(self, patched_graph, tmp_path, capsys): + """`57.text` — the exact address `slots` advertises — writes CLIPTextEncode 27.""" + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.text", "a cat on a bicycle"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "set_widget" + assert op["node_id"] == 57 + assert op["value"] == "a cat on a bicycle" + assert op["old"] == "old prompt" + # op is self-describing + replayable: resolved interior path + widget. + assert op["path"] == ["57", "27"] + assert op["inner_widget"] == "text" + # CRDT stamping preserved. + assert isinstance(op["op_id"], str) and op["op_id"] + assert op["stamp"] == [0, "cli"] + # value landed on the interior node, in the definition (persists on disk). + wf = json.loads(path.read_text()) + assert _interior(wf, 27)["widgets_values"][0] == "a cat on a bicycle" + + def test_flat_promoted_int_input_writes_ksampler(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.seed", "12345"], capsys) + assert env["ok"] is True, env + assert env["data"]["op"]["path"] == ["57", "3"] + wf = json.loads(path.read_text()) + assert _interior(wf, 3)["widgets_values"][0] == 12345 # seed is index 0 + + def test_nested_interior_address_writes_interior_node(self, patched_graph, tmp_path, capsys): + """`57/27.text` (the nested form the skill documents) hits the same widget.""" + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57/27.text", "a nested cat"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["node_id"] == "57/27" + assert op["path"] == ["57", "27"] + assert op["inner_widget"] == "text" + wf = json.loads(path.read_text()) + assert _interior(wf, 27)["widgets_values"][0] == "a nested cat" + + def test_flat_and_nested_share_a_conflict_target(self): + """Flat `57.text` and nested `57/27.text` land on the same interior + widget, so their ops must resolve to the SAME CRDT write target (they + converge — one does not silently clobber the other undetected).""" + wf = _subgraph_workflow() + _, flat = workflow_ops.set_widget(copy.deepcopy(wf), _graph(), 57, "text", "A") + _, nested = workflow_ops.set_widget(copy.deepcopy(wf), _graph(), "57/27", "text", "B") + assert workflow_ops._write_target(flat) == workflow_ops._write_target(nested) + assert workflow_ops.detect_conflict(flat, nested) is True # different values, same target + + def test_slots_and_set_widget_agree(self, patched_graph, monkeypatch, tmp_path, capsys): + """The self-consistency the bug broke: every flat address `slots` emits + for the subgraph instance is accepted by set-widget.""" + # slots resolves its graph via workflow.py's _get_graph; set-widget via + # workflow_edit.py's. patched_graph covers the latter; patch the former too. + monkeypatch.setattr(workflow_cmd, "_get_graph", lambda *a, **kw: _graph()) + path = _write(tmp_path, _subgraph_workflow()) + slots_env = _run(["slots", str(path)], capsys) + addrs = [s["address"] for s in slots_env["data"]["slots"] if str(s["address"]).startswith("57.")] + assert addrs, slots_env # the instance's promoted inputs are advertised flat + for addr in ("57.text", "57.seed", "57.steps"): + assert addr in addrs + env = _run(["set-widget", str(path), addr, "3" if addr != "57.text" else "x"], capsys) + assert env["ok"] is True, (addr, env) + + def test_unknown_promoted_input_errors_cleanly(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.nope", "1"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert "not found on subgraph node 57" in env["error"]["message"] + + def test_type_mismatch_on_promoted_int_rejected(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57.seed", '"notanumber"'], capsys) + assert env["ok"] is False + # unchanged on disk (edit rejected before write). + wf = json.loads(path.read_text()) + assert _interior(wf, 3)["widgets_values"][0] == 42 + + def test_top_level_edit_still_works_with_subgraphs_present(self, patched_graph, tmp_path, capsys): + """A plain top-level node in a workflow that also contains subgraphs is + still edited directly (no regression).""" + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "9.width", "768"], capsys) + assert env["ok"] is True, env + assert "path" not in env["data"]["op"] # direct top-level op, not a subgraph op + wf = json.loads(path.read_text()) + node9 = next(n for n in wf["nodes"] if n["id"] == 9) + assert node9["widgets_values"][0] == 768 + + +# --------------------------------------------------------------------------- +# connect +# --------------------------------------------------------------------------- + + +class TestConnect: + def test_connects_and_wires_slots(self, patched_graph, tmp_path, capsys): + wf = _base_workflow() + path = _write(tmp_path, wf) + # add a VAEDecode then connect KSampler.LATENT -> VAEDecode.samples + add = _run(["add-node", str(path), "VAEDecode"], capsys) + vae_id = add["data"]["op"]["node_id"] + env = _run(["connect", str(path), "3.LATENT", f"{vae_id}.samples"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "connect" + link_id = op["link_id"] + on_disk = json.loads(path.read_text()) + link = next(ln for ln in on_disk["links"] if ln[0] == link_id) + assert link[1] == 3 and link[3] == vae_id # from KSampler -> to VAEDecode + vae = next(n for n in on_disk["nodes"] if n["id"] == vae_id) + samples = next(i for i in vae["inputs"] if i["name"] == "samples") + assert samples["link"] == link_id + + def test_autogrow_input_grows_a_slot_per_connection(self, patched_graph, tmp_path, capsys): + """COMFY_AUTOGROW inputs (BatchImagesNode.images) grow images.image0/1… — the + assembly wiring the CRDT/apply path needs for video.""" + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + a = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + b = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + batch = _run(["add-node", str(path), "BatchImagesNode"], capsys)["data"]["op"]["node_id"] + e1 = _run(["connect", str(path), f"{a}.IMAGE", f"{batch}.images"], capsys) + e2 = _run(["connect", str(path), f"{b}.IMAGE", f"{batch}.images"], capsys) + assert e1["ok"] and e2["ok"], (e1, e2) + assert e1["data"]["op"]["grow"]["name"] == "images.image0" + assert e2["data"]["op"]["grow"]["name"] == "images.image1" + bn = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == batch) + grown = [i for i in bn["inputs"] if i["name"].startswith("images.image")] + assert {i["name"] for i in grown} == {"images.image0", "images.image1"} + assert all(i["link"] is not None and i["type"] == "IMAGE" for i in grown) + + def test_connect_converts_widget_to_input(self, patched_graph, tmp_path, capsys): + """connect onto a widget-backed input (KSampler.cfg) converts it to a link; + widgets_values stays intact and the converter uses the link (fps-style wiring).""" + path = _write(tmp_path, _base_workflow()) # KSampler id 3, widgets len 7 + src = _run(["add-node", str(path), "PrimitiveFloat"], capsys)["data"]["op"]["node_id"] + env = _run(["connect", str(path), f"{src}.FLOAT", "3.cfg"], capsys) + assert env["ok"] is True, env + assert env["data"]["op"]["grow"] == {"name": "cfg", "type": "FLOAT", "widget": "cfg"} + wf = json.loads(path.read_text()) + ks = next(n for n in wf["nodes"] if n["id"] == 3) + cfg_in = next(i for i in ks["inputs"] if i["name"] == "cfg") + assert cfg_in["link"] is not None and cfg_in["widget"] == {"name": "cfg"} + assert len(ks["widgets_values"]) == 7 # value kept → positional alignment holds + + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(wf, _object_info()) + assert api["3"]["inputs"]["cfg"] == [str(src), 0] # cfg is a link now + assert api["3"]["inputs"]["steps"] == 20 # other widgets still aligned + + def test_type_mismatch_rejected(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + add = _run(["add-node", str(path), "VAEDecode"], capsys) + vae_id = add["data"]["op"]["node_id"] + # KSampler LATENT output cannot feed a VAE-typed input. + env = _run(["connect", str(path), "3.LATENT", f"{vae_id}.vae"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + def test_replacing_input_link_scrubs_the_old_one(self, patched_graph, tmp_path, capsys): + """Re-wiring an occupied input must retire the previous link, not orphan it.""" + path = _write(tmp_path, _base_workflow()) + # KSampler.latent_image already holds link 1 (from EmptyLatentImage 7). + add = _run(["add-node", str(path), "EmptyLatentImage"], capsys) + new_src = add["data"]["op"]["node_id"] + env = _run(["connect", str(path), f"{new_src}.LATENT", "3.latent_image"], capsys) + assert env["ok"] is True, env + new_link = env["data"]["op"]["link_id"] + wf = json.loads(path.read_text()) + # old link 1 is gone entirely; only the new link references latent_image + assert all(ln[0] != 1 for ln in wf["links"]) + ks = next(n for n in wf["nodes"] if n["id"] == 3) + assert next(i for i in ks["inputs"] if i["name"] == "latent_image")["link"] == new_link + # old source's out-links no longer carry the retired link + old_src = next(n for n in wf["nodes"] if n["id"] == 7) + assert 1 not in (old_src["outputs"][0]["links"] or []) + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +class TestDelete: + def test_deletes_node_and_incident_links(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["delete-node", str(path), "7"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "delete_node" + assert op["node_id"] == 7 + on_disk = json.loads(path.read_text()) + assert all(n["id"] != 7 for n in on_disk["nodes"]) + # link 1 (7 -> 3) must be gone, and KSampler.latent_image cleared + assert all(ln[1] != 7 and ln[3] != 7 for ln in on_disk["links"]) + ks = next(n for n in on_disk["nodes"] if n["id"] == 3) + latent = next(i for i in ks["inputs"] if i["name"] == "latent_image") + assert latent["link"] is None + + + def test_nested_subgraph_address_missing_interior_node_errors(self, patched_graph, tmp_path, capsys): + # A nested address into a graph with no such subgraph/interior node fails + # cleanly (the top-level workflow here has no subgraph instance 10). + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "10/9.prompt", "x"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + +# --------------------------------------------------------------------------- +# invariant: the edit surface operates on UI (frontend) format ONLY +# (API format is a throwaway produced only at `run`) +# --------------------------------------------------------------------------- + + +class TestUiFormatOnly: + _API = {"3": {"class_type": "KSampler", "inputs": {}}} # API format: dict keyed by ids + + def test_add_node_rejects_api_format(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, self._API) + env = _run(["add-node", str(path), "VAEDecode"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_not_frontend_format" + + def test_apply_rejects_api_format(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, self._API) + ops = tmp_path / "ops.json" + ops.write_text("[]", encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops)], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_not_frontend_format" + + def test_set_widget_rejects_api_format(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, self._API) + env = _run(["set-widget", str(path), "3.steps", "20"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_not_frontend_format" + + +# --------------------------------------------------------------------------- +# ls-nodes +# --------------------------------------------------------------------------- + + +class TestLsNodes: + def test_lists_nodes(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["ls-nodes", str(path)], capsys) + assert env["ok"] is True + ids = {n["id"] for n in env["data"]["nodes"]} + assert ids == {3, 7} + types = {n["type"] for n in env["data"]["nodes"]} + assert types == {"KSampler", "EmptyLatentImage"} + + +# --------------------------------------------------------------------------- +# apply — batch with aliases +# --------------------------------------------------------------------------- + + +class TestApplyBatch: + def _empty(self, tmp_path): + return _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + + def test_builds_graph_in_one_pass_with_aliases(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + specs = [ + {"op": "add_node", "class_type": "CheckpointLoaderSimple", "as": "ckpt"}, + {"op": "add_node", "class_type": "CLIPTextEncode", "as": "pos"}, + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "EmptyLatentImage", "as": "lat"}, + {"op": "connect", "from": "ckpt.MODEL", "to": "ks.model"}, + {"op": "connect", "from": "ckpt.CLIP", "to": "pos.clip"}, + {"op": "connect", "from": "pos.CONDITIONING", "to": "ks.positive"}, + {"op": "connect", "from": "lat.LATENT", "to": "ks.latent_image"}, + {"op": "set_widget", "node": "pos", "widget": "text", "value": "a cat"}, + {"op": "set_widget", "node": "ks", "widget": "steps", "value": 30}, + ] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is True, env + assert env["data"]["count"] == 10 + assert set(env["data"]["aliases"]) == {"ckpt", "pos", "ks", "lat"} + # the aliased KSampler really got wired + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(json.loads(path.read_text()), _object_info()) + ks_id = str(env["data"]["aliases"]["ks"]) + assert ks_id in api + assert api[ks_id]["inputs"]["model"][0] == str(env["data"]["aliases"]["ckpt"]) + + def test_batch_is_atomic_on_failure(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + before = path.read_text() + specs = [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "NoSuchNode"}, # fails + ] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert path.read_text() == before, "failed batch must not write a partial graph" + + +# --------------------------------------------------------------------------- +# dynamic combo (COMFY_DYNAMICCOMBO_V3) — set_widget on model + model.resolution +# --------------------------------------------------------------------------- + + +class TestDynamicCombo: + def test_add_node_fills_dynamiccombo_defaults(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + nid = _run(["add-node", str(path), "KlingFLFTest"], capsys)["data"]["op"]["node_id"] + g = _graph() + node = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid) + order = g.widget_order("KlingFLFTest") + assert "model" in order and "model.resolution" in order + wv = node["widgets_values"] + assert wv[order.index("model")] == "kling-v3" # first key + assert wv[order.index("model.resolution")] == "1080p" # sub default + + def test_set_widget_dynamiccombo_selector_and_sub(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + nid = _run(["add-node", str(path), "KlingFLFTest"], capsys)["data"]["op"]["node_id"] + e1 = _run(["set-widget", str(path), f"{nid}.model", "kling-v3"], capsys) + e2 = _run(["set-widget", str(path), f"{nid}.model.resolution", "720p"], capsys) + assert e1["ok"] and e2["ok"], (e1, e2) + g = _graph() + order = g.widget_order("KlingFLFTest") + wv = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid)["widgets_values"] + assert wv[order.index("model.resolution")] == "720p" + + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(json.loads(path.read_text()), _object_info()) + assert api[str(nid)]["inputs"]["model"] == "kling-v3" + assert api[str(nid)]["inputs"]["model.resolution"] == "720p" + + +# --------------------------------------------------------------------------- +# recipes — parameterized op-batches (apply --param) +# --------------------------------------------------------------------------- + + +class TestRecipes: + def _recipe(self): + return { + "recipe": "t2i", + "params": {"positive": {"type": "string"}, "steps": {"type": "int", "default": 20}}, + "ops": [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "set_widget", "node": "ks", "widget": "steps", "value": "${steps}"}, + {"op": "add_node", "class_type": "CLIPTextEncode", "as": "pos"}, + {"op": "set_widget", "node": "pos", "widget": "text", "value": "a ${positive} scene"}, + ], + } + + def _empty(self, tmp_path): + return _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + + def test_param_substitution_is_typed(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=quiet forest", "--param", "steps=35"], capsys) + assert env["ok"] is True, env + wf = json.loads(path.read_text()) + g = _graph() + ks = next(n for n in wf["nodes"] if n["type"] == "KSampler") + pos = next(n for n in wf["nodes"] if n["type"] == "CLIPTextEncode") + assert ks["widgets_values"][g.widget_order("KSampler").index("steps")] == 35 # int, not "35" + assert pos["widgets_values"][g.widget_order("CLIPTextEncode").index("text")] == "a quiet forest scene" + + def test_default_used_when_param_omitted(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=x"], capsys) + assert env["ok"] is True + wf = json.loads(path.read_text()) + g = _graph() + ks = next(n for n in wf["nodes"] if n["type"] == "KSampler") + assert ks["widgets_values"][g.widget_order("KSampler").index("steps")] == 20 # declared default + + def test_missing_required_param_errors(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp)], capsys) # positive omitted, no default + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert "positive" in env["error"]["message"] + + def test_unknown_param_errors(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=x", "--param", "nope=1"], capsys) + assert env["ok"] is False + assert "nope" in env["error"]["message"] + + def test_bad_type_errors(self, patched_graph, tmp_path, capsys): + path = self._empty(tmp_path) + rp = tmp_path / "r.json" + rp.write_text(json.dumps(self._recipe()), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=x", "--param", "steps=notanint"], capsys) + assert env["ok"] is False + assert "int" in env["error"]["message"] + + +# --------------------------------------------------------------------------- +# foreach — bulk-instantiate a recipe over N param-sets +# --------------------------------------------------------------------------- + + +class TestForeach: + def _recipe(self, tmp_path): + rp = tmp_path / "r.json" + rp.write_text( + json.dumps( + { + "recipe": "t2i", + "params": {"positive": {"type": "string"}, "steps": {"type": "int", "default": 20}}, + "ops": [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "set_widget", "node": "ks", "widget": "steps", "value": "${steps}"}, + {"op": "add_node", "class_type": "CLIPTextEncode", "as": "pos"}, + {"op": "set_widget", "node": "pos", "widget": "text", "value": "${positive}"}, + ], + } + ), + encoding="utf-8", + ) + return rp + + def test_foreach_materializes_one_workflow_per_param_set(self, patched_graph, tmp_path, capsys): + rp = self._recipe(tmp_path) + params = tmp_path / "sets.jsonl" + params.write_text('{"positive":"a cat","steps":10}\n{"positive":"a dog","steps":30}\n', encoding="utf-8") + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is True, env + assert env["data"]["count"] == 2 + files = sorted(out.glob("*.json")) + assert len(files) == 2 + g = _graph() + seen = [] + for f in files: + wf = json.loads(f.read_text()) + pos = next(n for n in wf["nodes"] if n["type"] == "CLIPTextEncode") + ks = next(n for n in wf["nodes"] if n["type"] == "KSampler") + seen.append( + ( + pos["widgets_values"][g.widget_order("CLIPTextEncode").index("text")], + ks["widgets_values"][g.widget_order("KSampler").index("steps")], + ) + ) + assert seen == [("a cat", 10), ("a dog", 30)] # each param-set → its own workflow + + def test_foreach_accepts_json_array(self, patched_graph, tmp_path, capsys): + rp = self._recipe(tmp_path) + params = tmp_path / "sets.json" + params.write_text(json.dumps([{"positive": "x"}, {"positive": "y"}, {"positive": "z"}]), encoding="utf-8") + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is True + assert env["data"]["count"] == 3 # steps uses the default + + def test_foreach_bad_param_set_fails(self, patched_graph, tmp_path, capsys): + rp = self._recipe(tmp_path) + params = tmp_path / "sets.jsonl" + params.write_text('{"steps":10}\n', encoding="utf-8") # missing required positive + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is False + assert "positive" in env["error"]["message"] + + +# --------------------------------------------------------------------------- +# capture — project a graph into a recipe; round-trips through apply +# --------------------------------------------------------------------------- + + +class TestCapture: + def test_capture_roundtrips_through_apply(self, patched_graph, tmp_path, capsys): + src = _write(tmp_path, _base_workflow()) + cap = _run(["capture", str(src), "--name", "base"], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + # a non-default widget (KSampler seed=42) is captured; defaults are not + assert any(o["op"] == "set_widget" and o["widget"] == "seed" and o["value"] == 42 for o in recipe["ops"]) + assert not any(o.get("widget") == "steps" for o in recipe["ops"]) # steps=20 is the default + + empty = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}, "empty.json") + rp = tmp_path / "r.json" + rp.write_text(json.dumps(recipe), encoding="utf-8") + applied = _run(["apply", str(empty), "--ops", str(rp)], capsys) + assert applied["ok"] is True, applied + + rebuilt = json.loads(empty.read_text()) + orig = _base_workflow() + assert sorted(n["type"] for n in rebuilt["nodes"]) == sorted(n["type"] for n in orig["nodes"]) + assert len(rebuilt["links"]) == len(orig["links"]) + g = _graph() + ks = next(n for n in rebuilt["nodes"] if n["type"] == "KSampler") + assert ks["widgets_values"][g.widget_order("KSampler").index("seed")] == 42 # preserved + + from comfy_cli.workflow_to_api import convert_ui_to_api + + api = convert_ui_to_api(rebuilt, _object_info()) + ks_api = next(v for v in api.values() if v["class_type"] == "KSampler") + assert isinstance(ks_api["inputs"].get("latent_image"), list) # wiring preserved + + def test_capture_lifts_widget_to_param_even_at_default(self, patched_graph, tmp_path, capsys): + """`--param` promotes a widget to a ${param} hole even when its value is the + node default (the footgun: capture would otherwise drop it).""" + # EmptyLatentImage.width=512 IS the default → normally not captured. + src = _write(tmp_path, _base_workflow()) + lat_id = next(n["id"] for n in _base_workflow()["nodes"] if n["type"] == "EmptyLatentImage") + cap = _run(["capture", str(src), "--param", f"{lat_id}.width=w"], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + assert "w" in recipe["params"] and recipe["params"]["w"]["type"] == "int" + assert any(o["op"] == "set_widget" and o.get("value") == "${w}" for o in recipe["ops"]) + # and it applies with an override + empty = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}, "e.json") + rp = tmp_path / "r.json" + rp.write_text(json.dumps(recipe), encoding="utf-8") + env = _run(["apply", str(empty), "--ops", str(rp), "--param", "w=768"], capsys) + assert env["ok"] is True, env + g = _graph() + lat = next(n for n in json.loads(empty.read_text())["nodes"] if n["type"] == "EmptyLatentImage") + assert lat["widgets_values"][g.widget_order("EmptyLatentImage").index("width")] == 768 + + def test_capture_param_rejects_unknown_target(self, patched_graph, tmp_path, capsys): + src = _write(tmp_path, _base_workflow()) + env = _run(["capture", str(src), "--param", "3.nope=x"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + def test_capture_rejects_subgraphs(self, patched_graph, tmp_path, capsys): + wf = _base_workflow() + wf["definitions"] = {"subgraphs": [{"id": "sg", "name": "x", "nodes": []}]} + path = _write(tmp_path, wf) + env = _run(["capture", str(path)], capsys) + assert env["ok"] is False + assert "subgraph" in env["error"]["message"].lower() + + +# --------------------------------------------------------------------------- +# op-model correctness — direct against workflow_ops (P1..P7) +# --------------------------------------------------------------------------- + + +class TestOpModel: + def _ops(self): + from comfy_cli import workflow_ops + + return workflow_ops + + def test_p1_fidelity_apply_equals_primitive(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + for make in ( + lambda w: ops.add_node(w, g, "VAEDecode"), + lambda w: ops.set_widget(w, g, 3, "steps", 33), + lambda w: ops.delete_node(w, g, 7), + ): + direct, op = make(copy.deepcopy(base)) + replayed = ops.apply_op(copy.deepcopy(base), op, g) + assert ops.canonical(replayed) == ops.canonical(direct) + + def test_p2_idempotency(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 33) + once = ops.apply_op(copy.deepcopy(base), op, g) + twice = ops.apply_op(ops.apply_op(copy.deepcopy(base), op, g), op, g) + assert ops.canonical(once) == ops.canonical(twice) + + def test_p3_convergence_nonoverlapping(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op_add = ops.add_node(copy.deepcopy(base), g, "VAEDecode", actor="agent") + _, op_set = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 50, actor="human") + ab = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_add, g), op_set, g) + ba = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_set, g), op_add, g) + assert ops.canonical(ab) == ops.canonical(ba) + + def test_p4_conflict_detection(self): + ops = self._ops() + g = _graph() + base = _base_workflow() + _, a = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 10, actor="human") + _, b = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 20, actor="agent") + _, c = ops.set_widget(copy.deepcopy(base), g, 3, "cfg", 7.0, actor="agent") + assert ops.detect_conflict(a, b) is True + assert ops.detect_conflict(a, c) is False + + def test_p5_widget_name_safe_under_layout_shift(self): + """Name-addressed op survives a concurrent widget-layout shift.""" + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op = ops.set_widget(copy.deepcopy(base), g, 3, "denoise", 0.5, actor="agent") + # Simulate another peer prepending a widget slot on the same node + # (indices all shift by one); a name-keyed op must still hit denoise. + shifted = copy.deepcopy(base) + ks = next(n for n in shifted["nodes"] if n["id"] == 3) + ks["widgets_values"] = ["INJECTED", *ks["widgets_values"]] + # apply must re-resolve by name, not blindly by the original index + out = ops.apply_op(shifted, op, g) + ks_out = next(n for n in out["nodes"] if n["id"] == 3) + order = g.widget_order("KSampler") + assert ks_out["widgets_values"][order.index("denoise")] == 0.5 + assert ks_out["widgets_values"][0] == "INJECTED" + + def test_p7_api_convert_valid(self): + ops = self._ops() + from comfy_cli.workflow_to_api import convert_ui_to_api + + g = _graph() + wf = _base_workflow() + wf, _ = ops.add_node(wf, g, "VAEDecode") + vae_id = next(n["id"] for n in wf["nodes"] if n["type"] == "VAEDecode") + wf, _ = ops.connect(wf, g, 3, "LATENT", vae_id, "samples") + api = convert_ui_to_api(wf, _object_info()) + assert isinstance(api, dict) + # the added VAEDecode survives conversion with an int-keyed id + assert str(vae_id) in api + + # -- convergence properties (P8..P11): a merge consumer replays ops in any + # order; apply must be TOTAL (never crash) and ORDER-INDEPENDENT (both + # orders reach the same canonical graph). One property per convergence + # bug the deep review flagged. + + def test_p8_totality_delete_wins_over_concurrent_edit(self): + """A write to a concurrently-deleted node is a no-op (delete wins), + never a crash. {delete(3), set_widget(3)} converges in either order.""" + ops = self._ops() + g = _graph() + base = _base_workflow() + _, op_del = ops.delete_node(copy.deepcopy(base), g, 3, actor="human") + _, op_set = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 99, actor="agent") + # neither order may raise; both must converge to "node 3 gone". + del_then_set = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_del, g), op_set, g) + set_then_del = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_set, g), op_del, g) + assert ops.canonical(del_then_set) == ops.canonical(set_then_del) + assert all(n["id"] != 3 for n in del_then_set["nodes"]) + + def test_p8_totality_connect_to_deleted_node_is_noop(self): + """A connect whose endpoint was concurrently deleted no-ops without + crashing or leaving a dangling link.""" + ops = self._ops() + g = _graph() + base = _base_workflow() + # wire EmptyLatentImage(7).LATENT -> KSampler(3).latent_image, then race a + # delete of the source node 7. + _, op_conn = ops.connect(copy.deepcopy(base), g, 7, "LATENT", 3, "latent_image", actor="agent") + _, op_del = ops.delete_node(copy.deepcopy(base), g, 7, actor="human") + out = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_del, g), op_conn, g) + assert all(n["id"] != 7 for n in out["nodes"]) + # no link may reference the deleted node 7 (as source or target). + assert all(ln[1] != 7 and ln[3] != 7 for ln in out.get("links") or []) + + def test_p9_autogrow_connects_are_commutative(self): + """Two concurrent autogrow connects to the same base must both survive + (no clobber) and converge regardless of apply order.""" + ops = self._ops() + g = _graph() + base = _autogrow_workflow() + _, op1 = ops.connect(copy.deepcopy(base), g, 20, "IMAGE", 10, "images", actor="a") + _, op2 = ops.connect(copy.deepcopy(base), g, 21, "IMAGE", 10, "images", actor="b") + ab = ops.apply_op(ops.apply_op(copy.deepcopy(base), op1, g), op2, g) + ba = ops.apply_op(ops.apply_op(copy.deepcopy(base), op2, g), op1, g) + # both source links survive in either order (no silent connection loss). + for out in (ab, ba): + link_srcs = {ln[1] for ln in out.get("links") or []} + assert {20, 21} <= link_srcs, out.get("links") + # ...and the two orders converge. + assert ops.canonical(ab) == ops.canonical(ba) + + def test_p9_autogrow_grow_id_survives_api_conversion(self): + """The ``grow_id`` bookkeeping (persisted on grown slots as their + convergence identity) must not break API conversion — both wired sources + reach the flat API prompt.""" + ops = self._ops() + from comfy_cli.workflow_to_api import convert_ui_to_api + + g = _graph() + base = _autogrow_workflow() + _, op1 = ops.connect(copy.deepcopy(base), g, 20, "IMAGE", 10, "images", actor="a") + _, op2 = ops.connect(copy.deepcopy(base), g, 21, "IMAGE", 10, "images", actor="b") + wf = ops.apply_op(ops.apply_op(copy.deepcopy(base), op1, g), op2, g) + assert any(i.get("grow_id") for i in next(n for n in wf["nodes"] if n["id"] == 10)["inputs"]) + api = convert_ui_to_api(wf, _object_info()) + wired = list(api["10"]["inputs"].values()) + assert ["20", 0] in wired and ["21", 0] in wired, wired + + def test_p10_subgraph_fork_is_deterministic(self): + """Forking a shared subgraph definition must mint a deterministic id, so + two replicas replaying the same ops reach byte-identical graphs.""" + ops = self._ops() + g = _graph() + base = _two_instance_subgraph_workflow() + _, op_a = ops.set_widget(copy.deepcopy(base), g, "57/27", "text", "A", actor="a") + _, op_b = ops.set_widget(copy.deepcopy(base), g, "58/27", "text", "B", actor="b") + replica1 = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_a, g), op_b, g) + replica2 = ops.apply_op(ops.apply_op(copy.deepcopy(base), op_a, g), op_b, g) + # deterministic fork ids => two independent replays are identical. + assert ops.canonical(replica1) == ops.canonical(replica2) + + def test_p11_concurrent_widget_writes_resolve_by_stamp(self): + """Two concurrent writes to the same widget converge on the higher-stamp + value regardless of apply order (last-writer-wins by causal stamp).""" + ops = self._ops() + g = _graph() + base = _base_workflow() + _, lo = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 10, actor="human", base_version=5) + _, hi = ops.set_widget(copy.deepcopy(base), g, 3, "steps", 20, actor="agent", base_version=7) + lo_hi = ops.apply_op(ops.apply_op(copy.deepcopy(base), lo, g), hi, g) + hi_lo = ops.apply_op(ops.apply_op(copy.deepcopy(base), hi, g), lo, g) + assert ops.canonical(lo_hi) == ops.canonical(hi_lo) + order = g.widget_order("KSampler") + ks = next(n for n in lo_hi["nodes"] if n["id"] == 3) + assert ks["widgets_values"][order.index("steps")] == 20 # higher base_version wins + + # -- sufficiency (P12..P13): P8..P11 prove specific bugs are fixed; these + # prove the op model's CONTRACT holds across randomized inputs — every op + # pair either converges or is flagged (never silently diverges), and + # canonical() is a sound equality oracle (folds only immaterial detail). + + def test_p12_every_op_pair_converges_or_is_flagged(self): + """The load-bearing invariant: any two concurrent ops EITHER converge + under replay OR are reported by ``detect_conflict``. A silent divergence + (order matters, but nothing flagged it) is the failure this rules out. + Proved over randomized pairs with a fixed seed (reproducible).""" + import itertools + import random + + ops = self._ops() + g = _graph() + rng = random.Random(20260707) + checked = 0 + for _ in range(400): + specs = rng.sample(_CONVERGENCE_OP_SPECS, k=rng.randint(2, 4)) + pool = [_make_convergence_op(s, rng, g) for s in specs] + for a, b in itertools.combinations(pool, 2): + base = _convergence_base() + ab = ops.apply_op(ops.apply_op(copy.deepcopy(base), a, g), b, g) + ba = ops.apply_op(ops.apply_op(copy.deepcopy(base), b, g), a, g) + if ops.canonical(ab) != ops.canonical(ba): + assert ops.detect_conflict(a, b), ( + "SILENT DIVERGENCE", + (a["op"], a.get("widget") or a.get("to_node")), + (b["op"], b.get("widget") or b.get("to_node")), + ) + checked += 1 + assert checked > 1000 # the harness genuinely exercised many pairs + + def test_p12b_conflict_free_sets_fully_converge(self): + """Higher-order: a set of pairwise-non-conflicting ops converges across + ALL apply orders (not just pairs) — catches 3-way interactions.""" + import itertools + import random + + ops = self._ops() + g = _graph() + rng = random.Random(4242) + trials = 0 + for _ in range(300): + specs = rng.sample(_CONVERGENCE_OP_SPECS, k=rng.randint(2, 4)) + pool = [_make_convergence_op(s, rng, g) for s in specs] + free: list[dict] = [] + for op in pool: # greedily keep a maximal conflict-free subset + if all(not ops.detect_conflict(op, kept) for kept in free): + free.append(op) + if len(free) < 2: + continue + perms = list(itertools.permutations(free)) + if len(perms) > 24: + perms = rng.sample(perms, 24) + cans = [] + for perm in perms: + wf = _convergence_base() + for op in perm: + wf = ops.apply_op(wf, op, g) + cans.append(ops.canonical(wf)) + for c in cans[1:]: + assert c == cans[0], "conflict-free set diverged across apply orders" + trials += 1 + assert trials > 50 + + def test_p13_canonical_is_a_sound_equality_oracle(self): + """``canonical`` must fold away ONLY immaterial detail (apply + bookkeeping, node/link/def ordering, a grown slot's display name) and + must PRESERVE every material difference — otherwise a real divergence + could hide behind a false ``canonical`` match.""" + ops = self._ops() + g = _graph() + base = _convergence_base() + c0 = ops.canonical(base) + assert c0 == ops.canonical(copy.deepcopy(base)) # stable / reflexive + + # Immaterial differences must NOT change canonical. + immaterial = copy.deepcopy(base) + immaterial["nodes"] = list(reversed(immaterial["nodes"])) + immaterial["links"] = list(reversed(immaterial["links"])) + immaterial["_applied_ops"] = ["deadbeef"] + immaterial["_widget_stamps"] = {"('widget', 3, 'steps')": [9, "z", "op"]} + assert ops.canonical(immaterial) == c0 + + # Material differences MUST change canonical. + widget = copy.deepcopy(base) + next(n for n in widget["nodes"] if n["id"] == 3)["widgets_values"][2] = 999 + assert ops.canonical(widget) != c0 # a changed widget value + removed_node = copy.deepcopy(base) + removed_node["nodes"] = [n for n in removed_node["nodes"] if n["id"] != 7] + assert ops.canonical(removed_node) != c0 # a removed node + removed_link = copy.deepcopy(base) + removed_link["links"] = [] + assert ops.canonical(removed_link) != c0 # a removed link + + # Autogrow: the grown slot's DISPLAY NAME is immaterial (order-dependent), + # but WHICH SOURCE it wires is material. Prove canonical draws that line. + _, o20 = ops.connect(_convergence_base(), g, 20, "IMAGE", 10, "images", actor="a") + _, o21 = ops.connect(_convergence_base(), g, 21, "IMAGE", 10, "images", actor="b") + grown = ops.apply_op(ops.apply_op(_convergence_base(), o20, g), o21, g) + renamed = copy.deepcopy(grown) + for inp in next(n for n in renamed["nodes"] if n["id"] == 10)["inputs"]: + if inp.get("grow_id") is not None: + inp["name"] = f"images.renamed{inp['grow_id']}" # cosmetic only + assert ops.canonical(renamed) == ops.canonical(grown) # name is immaterial + rewired = copy.deepcopy(grown) + for ln in rewired["links"]: + if ln[1] == 21: + ln[1] = 20 # a grown slot now sourced from a different node + assert ops.canonical(rewired) != ops.canonical(grown) # source is material + + +class TestOpResolutionSuggestions: + """The edit ops enrich a *not-found* error with the real id/address, so an + agent that rebuilt an identifier from memory (hitting a wrong node, a real + sibling, or a nonexistent id) self-corrects in one step instead of looping. + Covers the whole edit surface: set_widget, connect, delete_node.""" + + def test_set_widget_wrong_node_suggests_the_widgets_real_address(self): + # 'steps' lives on KSampler (3), not EmptyLatentImage (7). + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*3\.steps \(KSampler\)"): + workflow_ops.set_widget(wf, g, 7, "steps", 20) + + def test_set_widget_missing_node_suggests_the_widgets_real_address(self): + # Node 999 doesn't exist (mirrors a wrong id/separator); 'steps' is on 3. + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*3\.steps \(KSampler\)"): + workflow_ops.set_widget(wf, g, 999, "steps", 20) + + def test_set_widget_unknown_widget_no_false_suggestion(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError) as ei: + workflow_ops.set_widget(wf, g, 3, "no_such_widget", 1) + assert "Did you mean" not in str(ei.value) + + def test_set_widget_shape_error_is_not_enriched(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError) as ei: + workflow_ops.set_widget(wf, g, 3, "steps", "not_an_int") + assert "Did you mean" not in str(ei.value) + + def test_connect_missing_node_lists_available_nodes(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Nodes in this workflow:.*KSampler"): + workflow_ops.connect(wf, g, 999, "LATENT", 3, "latent_image") + + def test_delete_missing_node_lists_available_nodes(self): + g, wf = _graph(), _base_workflow() + with pytest.raises(ValueError, match=r"Nodes in this workflow:.*KSampler"): + workflow_ops.delete_node(wf, g, 999) + + +class TestSetWidgetModelNormalization: + """set_widget auto-corrects a mangled COMBO/model value to the real option so + the model actually loads even when the agent rebuilds the name from memory + (e.g. adds a directory prefix) — the reliable fix for 'model not found'.""" + + def test_prefixed_combo_value_is_normalized_in_the_op(self): + g, wf = _graph(), _base_workflow() + _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "samplers/euler") + assert op["value"] == "euler" # the real option, prefix stripped + assert any(w.get("code") == "normalized_value" for w in op.get("warnings", [])) + + def test_exact_value_is_untouched_and_unwarned(self): + g, wf = _graph(), _base_workflow() + _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "euler") + assert op["value"] == "euler" + assert not any(w.get("code") == "normalized_value" for w in op.get("warnings", [])) + + def test_unknown_value_is_left_for_validate_to_flag(self): + g, wf = _graph(), _base_workflow() + _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "totally_made_up") + assert op["value"] == "totally_made_up" # not silently changed + assert any(w.get("code") == "unknown_enum_value" for w in op.get("warnings", [])) diff --git a/tests/comfy_cli/command/test_workflow_edit_cloud.py b/tests/comfy_cli/command/test_workflow_edit_cloud.py new file mode 100644 index 000000000..4ac177491 --- /dev/null +++ b/tests/comfy_cli/command/test_workflow_edit_cloud.py @@ -0,0 +1,151 @@ +"""Cloud red→green for the structured-edit primitives — the real-vendor gate. + +Unlike the offline unit tests (which stub `object_info`), these build a graph +against the **live Comfy Cloud node catalog** and prove it converts + submits. +This is the test that catches drift between our primitives and the real schemas. + +Gating (so the default suite stays offline + free): + * All tests skip unless `COMFY_CLOUD_E2E=1` AND a cloud session exists + (`comfy cloud login`). + * The submit test additionally needs `COMFY_CLOUD_E2E_RUN=1` — it spends credits. + +Run after login: + COMFY_CLOUD_E2E=1 uv run --extra dev pytest tests/comfy_cli/command/test_workflow_edit_cloud.py -v + # include a real job submission (spends credits): + COMFY_CLOUD_E2E=1 COMFY_CLOUD_E2E_RUN=1 uv run --extra dev pytest tests/comfy_cli/command/test_workflow_edit_cloud.py -v +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _cloud_ready() -> bool: + """True iff a cloud target with usable credentials is configured.""" + try: + from comfy_cli.target import resolve_target + + t = resolve_target(where="cloud") + except Exception: + return False + return bool(getattr(t, "api_key", None) or getattr(t, "auth_token", None)) + + +pytestmark = pytest.mark.skipif( + not (os.environ.get("COMFY_CLOUD_E2E") and _cloud_ready()), + reason="cloud e2e: set COMFY_CLOUD_E2E=1 and run `comfy cloud login` first", +) + + +def _cloud_object_info() -> dict: + from comfy_cli.cql.loader import resilient_load_object_info + + return resilient_load_object_info(mode="cloud", host="127.0.0.1", port=8188) + + +def _first_enum(graph, class_type: str, widget: str): + """A real, catalog-valid value for a COMBO widget (e.g. a checkpoint name).""" + m = graph.node(class_type) + if m is None: + pytest.skip(f"cloud catalog has no {class_type}") + port = next((p for p in m.inputs if p.name == widget), None) + if port is None or not port.enum_values: + pytest.skip(f"cloud catalog exposes no choices for {class_type}.{widget}") + return port.enum_values[0] + + +def _build_txt2img(graph): + """Build a minimal txt2img graph with the edit primitives, using real + catalog values. Returns (workflow, id_map).""" + from comfy_cli import workflow_ops as w + + wf = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + ids: dict[str, int] = {} + for key, ct in [ + ("ckpt", "CheckpointLoaderSimple"), + ("pos", "CLIPTextEncode"), + ("neg", "CLIPTextEncode"), + ("latent", "EmptyLatentImage"), + ("ks", "KSampler"), + ("vae", "VAEDecode"), + ("save", "SaveImage"), + ]: + wf, op = w.add_node(wf, graph, ct) + ids[key] = op["node_id"] + + def C(a, aslot, b, bslot): + nonlocal wf + wf, _ = w.connect(wf, graph, ids[a], aslot, ids[b], bslot) + + C("ckpt", "MODEL", "ks", "model") + C("ckpt", "CLIP", "pos", "clip") + C("ckpt", "CLIP", "neg", "clip") + C("pos", "CONDITIONING", "ks", "positive") + C("neg", "CONDITIONING", "ks", "negative") + C("latent", "LATENT", "ks", "latent_image") + C("ks", "LATENT", "vae", "samples") + C("ckpt", "VAE", "vae", "vae") + C("vae", "IMAGE", "save", "images") + + wf, _ = w.set_widget(wf, graph, ids["ckpt"], "ckpt_name", _first_enum(graph, "CheckpointLoaderSimple", "ckpt_name")) + wf, _ = w.set_widget(wf, graph, ids["pos"], "text", "a serene mountain lake at dawn") + wf, _ = w.set_widget(wf, graph, ids["neg"], "text", "blurry, low quality") + wf, _ = w.set_widget(wf, graph, ids["ks"], "steps", 8) + return wf, ids + + +def test_build_txt2img_against_live_cloud_catalog(): + """RED→GREEN: the primitives must produce a graph that converts cleanly + against the REAL cloud schemas (not a stub).""" + from comfy_cli.cql.engine import Graph + from comfy_cli.workflow_to_api import convert_ui_to_api + + oi = _cloud_object_info() + graph = Graph.from_object_info(oi) + wf, ids = _build_txt2img(graph) + + api = convert_ui_to_api(wf, oi) + # every node survived conversion + for key, nid in ids.items(): + assert str(nid) in api, f"{key} (node {nid}) dropped by converter" + # KSampler's model input resolved to the checkpoint node (link not dropped) + ks = api[str(ids["ks"])] + assert ks["inputs"]["model"][0] == str(ids["ckpt"]) + # no null required enum left behind (the add-node default-fill guarantee) + assert api[str(ids["ckpt"])]["inputs"]["ckpt_name"] is not None + + +@pytest.mark.skipif(not os.environ.get("COMFY_CLOUD_E2E_RUN"), reason="submit spends credits: set COMFY_CLOUD_E2E_RUN=1") +def test_submit_built_graph_to_cloud(tmp_path): + """RED→GREEN (credit-gated): a primitive-built graph submits to cloud and + returns a prompt_id. Runs the real `comfy run --where cloud`.""" + from comfy_cli.cql.engine import Graph + + graph = Graph.from_object_info(_cloud_object_info()) + wf, _ = _build_txt2img(graph) + wf_path = tmp_path / "built.json" + wf_path.write_text(json.dumps(wf), encoding="utf-8") + + proc = subprocess.run( + [sys.executable, "-m", "comfy_cli", "--json", "run", "--workflow", str(wf_path), "--where", "cloud"], + capture_output=True, + text=True, + timeout=180, + cwd=str(Path(__file__).resolve().parents[3]), + ) + env = None + for line in reversed([ln for ln in proc.stdout.strip().splitlines() if ln.strip()]): + try: + env = json.loads(line) + break + except json.JSONDecodeError: + continue + assert env is not None, f"no envelope (rc={proc.returncode}, stderr={proc.stderr[:500]})" + assert env["ok"] is True, env.get("error") + assert env["data"].get("prompt_id"), f"expected a prompt_id, got {env['data']}" diff --git a/tests/comfy_cli/conftest.py b/tests/comfy_cli/conftest.py index ba177eb94..b38d15407 100644 --- a/tests/comfy_cli/conftest.py +++ b/tests/comfy_cli/conftest.py @@ -57,6 +57,23 @@ def _isolate_config_path(tmp_path, monkeypatch): yield fake_root +@pytest.fixture(autouse=True) +def _isolate_object_info_cache_dir(tmp_path, monkeypatch): + """Redirect the ``object_info`` disk cache to a per-test tmp dir. + + ``resilient_load_object_info`` (comfy_cli.cql.loader) reads/writes + ``~/.cache/comfy-cli/object_info-*.json`` (or ``$XDG_CACHE_HOME``) as a + side effect of every cache-first fetch. Now that `comfy run`'s UI-convert + and preflight-validate call sites route through it too, any test that + exercises those paths would otherwise read stale state from — or write + real dumps into — the developer's actual cache directory. + """ + fake = tmp_path / "comfy-cli-cache" + fake.mkdir(mode=0o700, parents=True, exist_ok=True) + monkeypatch.setenv("XDG_CACHE_HOME", str(fake)) + yield fake + + @pytest.fixture(autouse=True) def _isolate_jobs_state_dir(tmp_path, monkeypatch): """Redirect ``jobs_state.state_dir`` to a per-test tmp dir. diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index dc26e6773..2357f3b46 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -167,8 +167,11 @@ def _object_info() -> dict[str, Any]: "fps": [[25, 50], {"default": 25}], "resolution": ["COMBO", {"options": ["1920x1080", "2560x1440"], "default": "1920x1080"}], }, + "optional": { + "seed": ["INT", {"default": 0, "min": 0, "max": 2**31 - 1}], + }, }, - "input_order": {"required": ["prompt", "duration", "fps", "resolution"]}, + "input_order": {"required": ["prompt", "duration", "fps", "resolution"], "optional": ["seed"]}, "output": ["VIDEO"], "output_name": ["VIDEO"], "category": "partner/video/LTXV", @@ -490,7 +493,9 @@ def test_unknown_enum_value(self, graph: Graph): assert "euler" in errs[0]["suggestions"] def test_valid_edges_pass(self, graph: Graph): - """Well-wired edges don't produce errors.""" + """Well-wired edges don't produce edge errors. (The KSampler is + deliberately partial — its missing required inputs surface as + missing_required_input, which is a separate check.)""" wf = { "1": { "class_type": "CheckpointLoaderSimple", @@ -510,6 +515,35 @@ def test_valid_edges_pass(self, graph: Graph): }, } result = graph.validate_workflow(wf) + edge_codes = {"dangling_edge", "output_index_out_of_range", "edge_type_mismatch"} + assert [e for e in result["errors"] if e["code"] in edge_codes] == [] + assert all(e["code"] == "missing_required_input" for e in result["errors"]) + + def test_missing_required_input_is_error(self, graph: Graph): + """A required input that is simply ABSENT must fail validate — the + server rejects it ("Required input is missing"), so a clean pass here + is a false green. Regression: emitted partner-node workflows omitted + inputs entirely and still validated.""" + wf = self._valid_workflow() + del wf["2"]["inputs"]["steps"] # widget input + del wf["2"]["inputs"]["model"] # link input + result = graph.validate_workflow(wf) + assert result["valid"] is False + errs = {e["field"]: e for e in result["errors"] if e["code"] == "missing_required_input"} + assert set(errs) == {"steps", "model"} + assert errs["steps"]["node_id"] == "2" + # The hint should surface the schema default when there is one. + assert "20" in errs["steps"]["hint"] + + def test_missing_optional_input_is_not_error(self, graph: Graph): + """Optional inputs may be omitted freely (LtxvApiTextToVideo.seed).""" + wf = { + "1": { + "class_type": "LtxvApiTextToVideo", + "inputs": {"prompt": "a boat", "duration": 8, "fps": 25, "resolution": "1920x1080"}, + }, + } + result = graph.validate_workflow(wf) assert result["valid"] is True assert result["errors"] == [] @@ -552,17 +586,9 @@ def test_edge_type_mismatch(self, graph: Graph): This is advisory (warning, not error) — ComfyUI allows cross-type wiring via reroutes and converters; the server is the authority.""" - wf = { - "1": { - "class_type": "CheckpointLoaderSimple", - "inputs": {"ckpt_name": "sd_xl_base.safetensors"}, - }, - "2": { - "class_type": "KSampler", - # Output index 1 is CLIP, but model input expects MODEL - "inputs": {"model": ["1", 1]}, - }, - } + wf = self._valid_workflow() + # Output index 1 is CLIP, but the model input expects MODEL. + wf["2"]["inputs"]["model"] = ["1", 1] result = graph.validate_workflow(wf) # edge_type_mismatch is a warning, not a hard error assert result["valid"] is True @@ -760,8 +786,11 @@ def test_dotted_slots_validate_clean(self, graph: Graph): }, } result = graph.validate_workflow(wf) - assert result["valid"] is True, result["errors"] # The dotted slots must not trip type-mismatch or unknown-input noise. + # (The bare VAEDecode loaders legitimately miss their own required + # links — scope the check to the autogrow node.) + errs_autogrow = [e for e in result["errors"] if e["node_id"] == "20"] + assert errs_autogrow == [], errs_autogrow assert result["warnings"] == [] def test_bare_link_wiring_errors_with_slot_hint(self, graph: Graph): @@ -880,6 +909,39 @@ def test_apply_returns_catalog_warnings(self, graph: Graph): assert "above_max" in codes +class TestSlotSuggestionOnNotFound: + """A not-found address is enriched with the real address that carries the + intended widget, so an agent that targeted the wrong node/separator (the + common LLM failure of rebuilding an address from memory) self-corrects in + one step instead of looping.""" + + def test_wrong_node_right_widget_suggests_correct_address(self, graph: Graph): + # 'text' lives on the CLIPTextEncode (node 6), not EmptyLatentImage (7). + wf = _direct_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*6\.text \(CLIPTextEncode\)"): + _apply_one_slot(wf, "7.text", "x", graph) + + def test_missing_node_right_widget_suggests_correct_address(self, graph: Graph): + # Node 999 doesn't exist (mirrors a wrong id/separator); 'seed' is on KSampler 3. + wf = _direct_workflow() + with pytest.raises(ValueError, match=r"Did you mean:.*3\.seed \(KSampler\)"): + _apply_one_slot(wf, "999.seed", 1, graph) + + def test_unknown_widget_name_gets_no_false_suggestion(self, graph: Graph): + # No node carries 'nonexistent' → original error, no "Did you mean". + wf = _direct_workflow() + with pytest.raises(ValueError) as ei: + _apply_one_slot(wf, "3.nonexistent", 1, graph) + assert "Did you mean" not in str(ei.value) + + def test_shape_error_is_not_enriched(self, graph: Graph): + # The widget resolved fine; a shape rejection must pass through untouched. + wf = _direct_workflow() + with pytest.raises(ValueError) as ei: + _apply_one_slot(wf, "3.seed", "not_an_int", graph) + assert "Did you mean" not in str(ei.value) + + # =========================================================================== # TestTemplateModeSlots # =========================================================================== @@ -1234,3 +1296,54 @@ def test_load_from_target_refuses_non_loopback_local_host(): with pytest.raises(LoadError, match="non-loopback"): _load_from_target(mode="local", host="example.com", port=8188) + + +class TestComboNormalizationAndSuggestions: + """Port.canonical_combo rewrites a mangled model value (dir prefix / dropped + subfolder / case drift) to the real option when unambiguous; suggest_combo + + validate_catalog.did_you_mean point a rejected value at the nearest options.""" + + def _port(self): + from comfy_cli.cql.engine import Port + return Port( + name="ckpt_name", + type="COMBO", + enum_values=["sd_xl_base.safetensors", "v1-5-pruned.safetensors", "sub/model_x.safetensors"], + ) + + def test_canonical_strips_added_directory_prefix(self): + p = self._port() + assert p.canonical_combo("checkpoints/sd_xl_base.safetensors") == "sd_xl_base.safetensors" + + def test_canonical_matches_dropped_subfolder_by_basename(self): + p = self._port() + assert p.canonical_combo("model_x.safetensors") == "sub/model_x.safetensors" + + def test_canonical_case_insensitive(self): + p = self._port() + assert p.canonical_combo("SD_XL_BASE.SAFETENSORS") == "sd_xl_base.safetensors" + + def test_canonical_exact_value_returns_none(self): + p = self._port() + assert p.canonical_combo("sd_xl_base.safetensors") is None + + def test_canonical_unknown_returns_none(self): + p = self._port() + assert p.canonical_combo("realisticVisionV60B1.safetensors") is None + + def test_canonical_ambiguous_basename_returns_none(self): + from comfy_cli.cql.engine import Port + p = Port(name="ckpt_name", type="COMBO", enum_values=["a/dup.safetensors", "b/dup.safetensors"]) + assert p.canonical_combo("dup.safetensors") is None # two matches → don't guess + + def test_suggest_returns_close_options(self): + p = self._port() + got = p.suggest_combo("sd_xl_bas.safetensors") + assert "sd_xl_base.safetensors" in got + + def test_validate_catalog_adds_did_you_mean(self): + p = self._port() + w = p.validate_catalog("v1-5-prund.safetensors") # typo + assert w and w[0]["code"] == "unknown_enum_value" + assert "did_you_mean" in w[0] + assert "v1-5-pruned.safetensors" in w[0]["did_you_mean"] diff --git a/tests/comfy_cli/cql/test_loader_resilient.py b/tests/comfy_cli/cql/test_loader_resilient.py index 1bfce55ca..2a3466534 100644 --- a/tests/comfy_cli/cql/test_loader_resilient.py +++ b/tests/comfy_cli/cql/test_loader_resilient.py @@ -43,6 +43,8 @@ def _isolated_cache(tmp_path, monkeypatch): """Point the cache dir at a throwaway tmp dir for every test.""" cache_root = tmp_path / "cache" monkeypatch.setenv("XDG_CACHE_HOME", str(cache_root)) + # Don't let a developer's TTL override leak into the tests. + monkeypatch.delenv(loader.OBJECT_INFO_TTL_ENV, raising=False) # Make the host-key resolution deterministic and I/O-free. monkeypatch.setattr(loader, "_resolve_host_key", lambda mode, host, port: "https://test.comfy.org") return cache_root @@ -182,6 +184,9 @@ def test_persistent_failure_falls_back_to_cache_with_warning(monkeypatch): import comfy_cli.cql.engine as engine _fake_refresh(monkeypatch) + # Disable the cache-first TTL gate so the freshly-seeded cache does not + # short-circuit the fetch — this test exercises the *failure* fallback. + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "0") # Seed the cache with a (stale) dump. loader.write_object_info_cache("https://test.comfy.org", STALE_OBJECT_INFO) @@ -204,6 +209,7 @@ def test_connection_error_also_falls_back_to_cache(monkeypatch): import comfy_cli.cql.engine as engine _fake_refresh(monkeypatch) + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "0") loader.write_object_info_cache("https://test.comfy.org", STALE_OBJECT_INFO) def _offline(**kw): diff --git a/tests/comfy_cli/cql/test_loader_ttl.py b/tests/comfy_cli/cql/test_loader_ttl.py new file mode 100644 index 000000000..46deccb42 --- /dev/null +++ b/tests/comfy_cli/cql/test_loader_ttl.py @@ -0,0 +1,237 @@ +"""Cache-first TTL tests for ``resilient_load_object_info``. + +The loader serves a per-host cache entry younger than the TTL (default 10 +minutes, ``COMFY_OBJECT_INFO_TTL`` seconds to override, ``0`` = always fetch) +without any network call. These tests pin that policy: + + - a fresh cache hit never touches the network, + - an expired entry refetches live, + - TTL=0 bypasses the gate entirely, + - entries are keyed per target base URL (cloud vs local never collide), + - a fetch failure still falls back to the stale cache even past the TTL. + +``engine._load_from_target`` is always mocked — no sockets are opened. +""" + +from __future__ import annotations + +import os + +import pytest + +from comfy_cli.cql import loader +from comfy_cli.cql.engine import LoadError + +CACHED = {"CachedNode": {"input": {"required": {}}, "output": [], "category": "cached"}} +LIVE = {"LiveNode": {"input": {"required": {}}, "output": [], "category": "live"}} + +CLOUD_KEY = "https://cloud.test.comfy.org" +LOCAL_KEY = "http://127.0.0.1:8188" + + +@pytest.fixture(autouse=True) +def _isolated_cache(tmp_path, monkeypatch): + """Throwaway cache dir; no TTL override leaking in from the dev env.""" + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.delenv(loader.OBJECT_INFO_TTL_ENV, raising=False) + + +def _pin_host_key(monkeypatch, key: str) -> None: + monkeypatch.setattr(loader, "_resolve_host_key", lambda mode, host, port: key) + + +def _expire_cache(host_key: str, age_seconds: float) -> None: + """Backdate the cache file's mtime so the entry reads as ``age_seconds`` old.""" + path = loader.object_info_cache_path(host_key) + stamp = path.stat().st_mtime - age_seconds + os.utime(path, (stamp, stamp)) + + +def _forbid_network(monkeypatch): + """Fail loudly if the live fetch runs; return the call counter.""" + import comfy_cli.cql.engine as engine + + calls = {"n": 0} + + def _boom(**kw): + calls["n"] += 1 + raise AssertionError("network fetch must not run on a fresh cache hit") + + monkeypatch.setattr(engine, "_load_from_target", _boom) + return calls + + +# --------------------------------------------------------------------------- +# fresh cache hit → no network call +# --------------------------------------------------------------------------- + + +def test_fresh_cache_hit_skips_network(monkeypatch): + _pin_host_key(monkeypatch, CLOUD_KEY) + calls = _forbid_network(monkeypatch) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == CACHED + assert calls["n"] == 0 + + +def test_fresh_hit_respects_custom_ttl(monkeypatch): + """An entry older than the default TTL is still fresh under a larger one.""" + _pin_host_key(monkeypatch, CLOUD_KEY) + calls = _forbid_network(monkeypatch) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=3600) # 1h old — past the 10m default + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "7200") + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == CACHED + assert calls["n"] == 0 + + +# --------------------------------------------------------------------------- +# expired entry → live refetch (and cache rewrite) +# --------------------------------------------------------------------------- + + +def test_expired_ttl_refetches(monkeypatch): + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, CLOUD_KEY) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=loader.DEFAULT_OBJECT_INFO_TTL_SECONDS + 1) + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == LIVE + assert calls["n"] == 1 + # The refetch rewrote the cache with the live payload. + assert loader.read_object_info_cache(CLOUD_KEY) == LIVE + + +# --------------------------------------------------------------------------- +# TTL=0 → always fetch live, even with a brand-new cache entry +# --------------------------------------------------------------------------- + + +def test_ttl_zero_bypasses_cache(monkeypatch): + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, CLOUD_KEY) + loader.write_object_info_cache(CLOUD_KEY, CACHED) # fresh, would hit + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, "0") + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1) + + assert result == LIVE + assert calls["n"] == 1 + + +# --------------------------------------------------------------------------- +# per-target keying: a fresh cloud entry must not satisfy a local lookup +# --------------------------------------------------------------------------- + + +def test_fresh_entry_for_other_target_does_not_hit(monkeypatch): + import comfy_cli.cql.engine as engine + + loader.write_object_info_cache(CLOUD_KEY, CACHED) # fresh, but for cloud + _pin_host_key(monkeypatch, LOCAL_KEY) # this call targets local + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="local", host="127.0.0.1", port=8188) + + assert result == LIVE # served live, never the cloud entry + assert calls["n"] == 1 + # Each target keeps its own entry. + assert loader.read_object_info_cache(CLOUD_KEY) == CACHED + assert loader.read_object_info_cache(LOCAL_KEY) == LIVE + + +# --------------------------------------------------------------------------- +# expired entry + fetch failure → stale fallback still works +# --------------------------------------------------------------------------- + + +def test_expired_entry_still_serves_as_stale_fallback(monkeypatch): + import comfy_cli.cloud.oauth as oauth + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, CLOUD_KEY) + monkeypatch.setattr(oauth, "ensure_fresh_session", lambda **kw: None) + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=loader.DEFAULT_OBJECT_INFO_TTL_SECONDS + 1) + + def _offline(**kw): + raise LoadError("cannot reach the server: offline") + + monkeypatch.setattr(engine, "_load_from_target", _offline) + + warnings: list[str] = [] + result = loader.resilient_load_object_info(mode="cloud", host="h", port=1, _warn=warnings.append) + + assert result == CACHED + assert len(warnings) == 1 + assert "stale" in warnings[0].lower() + + +# --------------------------------------------------------------------------- +# TTL env parsing +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # unset → default + ("", loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # blank → default + (" ", loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # whitespace → default + ("garbage", loader.DEFAULT_OBJECT_INFO_TTL_SECONDS), # unparseable → default + ("0", 0.0), + ("-5", 0.0), # negative clamps to bypass + ("30", 30.0), + ("1.5", 1.5), + ], +) +def test_object_info_cache_ttl_parsing(monkeypatch, raw, expected): + if raw is None: + monkeypatch.delenv(loader.OBJECT_INFO_TTL_ENV, raising=False) + else: + monkeypatch.setenv(loader.OBJECT_INFO_TTL_ENV, raw) + assert loader.object_info_cache_ttl() == expected + + +def test_read_fresh_missing_file_returns_none(): + assert loader.read_fresh_object_info_cache("https://nope.example", ttl=600) is None + + +def test_read_fresh_future_mtime_treated_as_expired(monkeypatch): + """Clock skew: an mtime in the future must not count as fresh.""" + loader.write_object_info_cache(CLOUD_KEY, CACHED) + _expire_cache(CLOUD_KEY, age_seconds=-3600) # 1h in the future + assert loader.read_fresh_object_info_cache(CLOUD_KEY, ttl=600) is None diff --git a/tests/comfy_cli/cql/test_object_info_env.py b/tests/comfy_cli/cql/test_object_info_env.py new file mode 100644 index 000000000..d1adea0c7 --- /dev/null +++ b/tests/comfy_cli/cql/test_object_info_env.py @@ -0,0 +1,64 @@ +"""COMFY_OBJECT_INFO_FILE is honored by the single object_info loader, so EVERY +CQL consumer routed through it — workflow edits, `nodes show`/`find`, `validate`, +fragments — resolves the node schema from a baked/pre-warmed file offline: no +network fetch, no cloud credential. A host sets one env var instead of threading +`--input` through each command.""" + +from __future__ import annotations + +import comfy_cli.cql.engine as engine +import comfy_cli.cql.loader as loader + + +def test_resilient_load_honors_object_info_file_env(monkeypatch, tmp_path): + dump = tmp_path / "object_info.json" + dump.write_text('{"KSampler": {}}') + seen: dict[str, str] = {} + + def fake_load(p): + seen["path"] = p + return {"ok": True} + + monkeypatch.setattr(engine, "_load_from_file", fake_load) + # The network path must NOT run when the env dump is set. + monkeypatch.setattr( + engine, "_load_from_target", + lambda **_: (_ for _ in ()).throw(AssertionError("network fetch should not run with COMFY_OBJECT_INFO_FILE set")), + ) + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(dump)) + + out = loader.resilient_load_object_info(mode="cloud") + + assert out == {"ok": True} + assert seen["path"] == str(dump), "no --input => COMFY_OBJECT_INFO_FILE is read" + + +def test_explicit_input_wins_over_env(monkeypatch, tmp_path): + env_dump = tmp_path / "env.json" + env_dump.write_text("{}") + explicit = tmp_path / "explicit.json" + explicit.write_text("{}") + seen: dict[str, str] = {} + monkeypatch.setattr(engine, "_load_from_file", lambda p: seen.setdefault("path", p) or {}) + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(env_dump)) + + loader.resilient_load_object_info(mode="cloud", input_path=str(explicit)) + + assert seen["path"] == str(explicit), "explicit --input overrides the env default" + + +def test_no_env_falls_through_to_network(monkeypatch): + # Neither --input nor the env var: the loader proceeds to the cache/network + # path (asserted by _load_from_file NOT being called with an env path). + called: dict[str, bool] = {} + monkeypatch.setattr(engine, "_load_from_file", lambda p: called.setdefault("file", True) or {}) + monkeypatch.setattr(engine, "_load_from_target", lambda **_: {"from": "network"}) + monkeypatch.delenv("COMFY_OBJECT_INFO_FILE", raising=False) + # A fresh cache miss forces the network path. + monkeypatch.setattr(loader, "read_fresh_object_info_cache", lambda *a, **k: None) + monkeypatch.setattr(loader, "write_object_info_cache", lambda *a, **k: None) + + out = loader.resilient_load_object_info(mode="cloud") + + assert out == {"from": "network"} + assert "file" not in called, "no env => the offline file path is not taken" diff --git a/tests/comfy_cli/skills/test_installer.py b/tests/comfy_cli/skills/test_installer.py index 09297223b..c087ba922 100644 --- a/tests/comfy_cli/skills/test_installer.py +++ b/tests/comfy_cli/skills/test_installer.py @@ -42,7 +42,6 @@ def _force_json_renderer(): def test_bundles_expected_skills(): names = bundled_skill_names() assert "comfy" in names - assert "comfy-fragments" in names assert "comfy-debug" in names assert "comfy-relay" in names @@ -77,12 +76,6 @@ def test_comfy_skill_covers_cloud_setup_and_routing(): assert needle in text, f"comfy skill should mention {needle}" -def test_comfy_fragments_skill_covers_composition(): - text = skill_content("comfy-fragments") - for needle in ("workflow compose", "_fragment", "blueprint"): - assert needle in text, f"comfy-fragments skill should mention {needle}" - - def test_skill_content_rejects_unknown_name(): with pytest.raises(ValueError) as exc: skill_content("not-a-real-skill") @@ -111,8 +104,8 @@ def test_plan_install_project_scope_paths(tmp_path: Path): def test_plan_install_filters_by_skill(tmp_path: Path): - plans = plan_install(scope="project", project_root=tmp_path, skills=["comfy", "comfy-fragments"]) - assert {p.skill for p in plans} == {"comfy", "comfy-fragments"} + plans = plan_install(scope="project", project_root=tmp_path, skills=["comfy", "comfy-debug"]) + assert {p.skill for p in plans} == {"comfy", "comfy-debug"} # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/test_credentials.py b/tests/comfy_cli/test_credentials.py index 2b0d5b5c6..9b367964b 100644 --- a/tests/comfy_cli/test_credentials.py +++ b/tests/comfy_cli/test_credentials.py @@ -27,6 +27,7 @@ from comfy_cli.cloud import oauth from comfy_cli.credentials import ( CLOUD_API_KEY_PROVIDER, + CLOUD_BEARER_ENV_VAR, Credential, find_api_key, get_session, @@ -51,6 +52,7 @@ def clean_env(monkeypatch: pytest.MonkeyPatch): """No ambient credentials: no env vars, no stored key, no session.""" monkeypatch.delenv("COMFY_CLOUD_API_KEY", raising=False) monkeypatch.delenv("COMFY_API_KEY", raising=False) + monkeypatch.delenv("COMFY_CLOUD_AUTH_TOKEN", raising=False) monkeypatch.setattr(auth_store, "get", lambda _provider: None) monkeypatch.setattr(auth_store, "get_cloud_session", lambda: None) monkeypatch.setattr(oauth, "ensure_fresh_session", lambda **kw: None) @@ -329,3 +331,46 @@ def test_no_direct_credential_reads_outside_resolver(): "Direct credential reads found outside comfy_cli/credentials.py — " "use resolve_cloud_credential / find_api_key / get_session instead:\n" + "\n".join(violations) ) + + +# --------------------------------------------------------------------------- +# forwarded Bearer token (COMFY_CLOUD_AUTH_TOKEN — the trusted-caller path) +# --------------------------------------------------------------------------- + + +class TestForwardedBearerToken: + def test_bearer_env_yields_oauth_credential_for_cloud(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="jwt-abc", source=f"env:{CLOUD_BEARER_ENV_VAR}") + + def test_bearer_env_is_stripped(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, " jwt-abc \n") + cred = resolve_cloud_credential(purpose="cloud") + assert cred is not None and cred.value == "jwt-abc" + + def test_blank_bearer_env_is_ignored(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, " \n\t") + assert resolve_cloud_credential(purpose="cloud") is None + + def test_live_session_outranks_bearer_env(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + clean_env.setattr(oauth, "ensure_fresh_session", lambda **kw: _session(token="live-token")) + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="live-token", source="session") + + def test_expired_session_falls_through_to_bearer_env(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + clean_env.setattr(oauth, "ensure_fresh_session", lambda **kw: _session(expired=True)) + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="jwt-abc", source=f"env:{CLOUD_BEARER_ENV_VAR}") + + def test_bearer_env_outranks_api_key_env(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + clean_env.setenv("COMFY_CLOUD_API_KEY", "comfyui-key") + cred = resolve_cloud_credential(purpose="cloud") + assert cred == Credential(kind="oauth", value="jwt-abc", source=f"env:{CLOUD_BEARER_ENV_VAR}") + + def test_bearer_env_ignored_for_partner_purpose(self, clean_env): + clean_env.setenv(CLOUD_BEARER_ENV_VAR, "jwt-abc") + assert resolve_cloud_credential(purpose="partner") is None diff --git a/tests/comfy_cli/test_env_checker.py b/tests/comfy_cli/test_env_checker.py index bed317569..51ed7b590 100644 --- a/tests/comfy_cli/test_env_checker.py +++ b/tests/comfy_cli/test_env_checker.py @@ -33,22 +33,22 @@ def test_python_37_is_old(self): class TestCheckComfyServerRunning: - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_server_running(self, mock_get): mock_get.return_value.status_code = 200 assert check_comfy_server_running() is True - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_server_not_running(self, mock_get): mock_get.side_effect = requests.exceptions.ConnectionError() assert check_comfy_server_running() is False - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_non_200_status(self, mock_get): mock_get.return_value.status_code = 500 assert check_comfy_server_running() is False - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_custom_port_and_host(self, mock_get): mock_get.return_value.status_code = 200 check_comfy_server_running(port=9999, host="0.0.0.0") @@ -58,7 +58,7 @@ def test_custom_port_and_host(self, mock_get): # alter user-visible "is the server up?" behaviour on slow hosts. assert mock_get.call_args.kwargs["timeout"] == 5.0 - @patch("comfy_cli.env_checker.requests.get") + @patch("requests.get") def test_caller_can_override_timeout(self, mock_get): mock_get.return_value.status_code = 200 check_comfy_server_running(port=8188, host="127.0.0.1", timeout=42) diff --git a/tests/comfy_cli/test_standalone.py b/tests/comfy_cli/test_standalone.py index 3f63e11bc..a82847f60 100644 --- a/tests/comfy_cli/test_standalone.py +++ b/tests/comfy_cli/test_standalone.py @@ -33,37 +33,37 @@ def _mock_response(text, status_code=200): class TestResolvePythonVersion: - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_resolves_312(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.12") assert result == "3.12.13" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_resolves_310(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.10") assert result == "3.10.20" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_resolves_313(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) result = _resolve_python_version("https://example.com/release", "3.13") assert result == "3.13.12" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_missing_version_raises(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) with pytest.raises(RuntimeError, match="No Python 3.14.x found"): _resolve_python_version("https://example.com/release", "3.14") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_http_error_propagates(self, mock_get): mock_get.return_value = _mock_response("", status_code=404) with pytest.raises(Exception, match="HTTP 404"): _resolve_python_version("https://example.com/release", "3.12") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_picks_highest_patch(self, mock_get): """If multiple patch versions exist for a minor series, pick the highest.""" sha256sums = """\ @@ -75,13 +75,13 @@ def test_picks_highest_patch(self, mock_get): result = _resolve_python_version("https://example.com/release", "3.12") assert result == "3.12.13" - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_url_construction(self, mock_get): mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) _resolve_python_version("https://example.com/release/", "3.12") mock_get.assert_called_once_with("https://example.com/release/SHA256SUMS") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_no_false_match_across_minor(self, mock_get): """3.1 should not match 3.12 or 3.10.""" mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS) @@ -91,7 +91,7 @@ def test_no_false_match_across_minor(self, mock_get): class TestDownloadStandalonePython: @patch("comfy_cli.standalone.download_url") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_minor_version_triggers_resolution(self, mock_get, mock_download): """When version is a minor version (X.Y), it should resolve the patch.""" mock_get.side_effect = [ @@ -109,7 +109,7 @@ def test_minor_version_triggers_resolution(self, mock_get, mock_download): assert "3.12.13" in call_args[1].get("url", "") or "3.12.13" in str(call_args) @patch("comfy_cli.standalone.download_url") - @patch("comfy_cli.standalone.requests.get") + @patch("requests.get") def test_full_version_skips_resolution(self, mock_get, mock_download): """When version is a full version (X.Y.Z), no resolution needed.""" mock_get.return_value = _mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}') diff --git a/tests/comfy_cli/test_tracking.py b/tests/comfy_cli/test_tracking.py index b79319cad..eeca3bbd2 100644 --- a/tests/comfy_cli/test_tracking.py +++ b/tests/comfy_cli/test_tracking.py @@ -544,7 +544,7 @@ def test_no_tracking_when_stdin_not_tty(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=False), patch.object(tracking_module.sys.stdout, "isatty", return_value=True), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() @@ -556,7 +556,7 @@ def test_no_tracking_when_stdout_not_tty(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=True), patch.object(tracking_module.sys.stdout, "isatty", return_value=False), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() @@ -612,7 +612,7 @@ def test_prompts_when_both_are_tty(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=True), patch.object(tracking_module.sys.stdout, "isatty", return_value=True), - patch.object(tracking_module.ui, "prompt_confirm_action", return_value=False) as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action", return_value=False) as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_called_once() @@ -623,7 +623,7 @@ def test_skip_prompt_bypasses_tty_check(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=False), patch.object(tracking_module.sys.stdout, "isatty", return_value=False), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent(skip_prompt=True, default_value=False) mock_prompt.assert_not_called() @@ -635,7 +635,7 @@ def test_no_op_when_already_configured(self, tracking_module): with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=False), patch.object(tracking_module.sys.stdout, "isatty", return_value=False), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() @@ -682,7 +682,7 @@ def test_env_var_short_circuits_consent_prompt(self, tracking_module, monkeypatc with ( patch.object(tracking_module.sys.stdin, "isatty", return_value=True), patch.object(tracking_module.sys.stdout, "isatty", return_value=True), - patch.object(tracking_module.ui, "prompt_confirm_action") as mock_prompt, + patch("comfy_cli.ui.prompt_confirm_action") as mock_prompt, ): tracking_module.prompt_tracking_consent() mock_prompt.assert_not_called() diff --git a/tests/comfy_cli/test_tracking_providers.py b/tests/comfy_cli/test_tracking_providers.py index 591cc754d..9c63a152a 100644 --- a/tests/comfy_cli/test_tracking_providers.py +++ b/tests/comfy_cli/test_tracking_providers.py @@ -271,6 +271,71 @@ def download(_ctx=None, url=None, set_civitai_api_token=None, set_hf_api_token=N assert "hf-secret" not in str(properties) +class TestLazyProviderConstruction: + """Providers must be built on first dispatch, never at module import. + + Eager construction started PostHog's consumer thread, whose atexit join + stalls every CLI exit by the full flush_interval — even for invocations + that never send a single event (e.g. ``comfy --version`` with + ``DO_NOT_TRACK=1``).""" + + def test_first_track_event_constructs_providers(self, tracking_with_two_providers): + tracking_mod, _, _ = tracking_with_two_providers + built = [MagicMock(), MagicMock()] + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.object(tracking_mod, "MixpanelProvider", return_value=built[0]), + patch.object(tracking_mod, "PostHogProvider", return_value=built[1]), + ): + assert tracking_mod.PROVIDERS is None + tracking_mod.track_event("some_event") + assert tracking_mod.PROVIDERS == built + built[0].track.assert_called_once() + built[1].track.assert_called_once() + + def test_disabled_tracking_never_constructs_providers(self, tracking_with_two_providers): + tracking_mod, _, _ = tracking_with_two_providers + tracking_mod.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "False") + with patch.object(tracking_mod, "PROVIDERS", None): + tracking_mod.track_event("some_event") + assert tracking_mod.PROVIDERS is None + + def test_env_opt_out_never_constructs_providers(self, tracking_with_two_providers): + tracking_mod, _, _ = tracking_with_two_providers + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.dict("os.environ", {"DO_NOT_TRACK": "1"}), + ): + tracking_mod.track_event("some_event") + assert tracking_mod.PROVIDERS is None + + def test_get_providers_constructs_once_and_caches(self): + import comfy_cli.tracking as tracking_mod + + built = MagicMock() + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.object(tracking_mod, "MixpanelProvider", return_value=built) as mp_cls, + patch.object(tracking_mod, "PostHogProvider", return_value=built) as ph_cls, + ): + first = tracking_mod._get_providers() + second = tracking_mod._get_providers() + assert first is second + mp_cls.assert_called_once() + ph_cls.assert_called_once() + + def test_posthog_flush_interval_is_bounded(self): + """The Posthog client must be constructed with an explicit, small + flush_interval: its atexit join waits out the full interval on an + empty queue, and the library default varies by version (0.5s → 5.0s), + which would add multi-second dead time to every CLI exit.""" + with patch("posthog.Posthog") as posthog_cls: + provider = PostHogProvider("phc_test", "https://t.comfy.org") + assert provider.enabled is True + kwargs = posthog_cls.call_args.kwargs + assert kwargs["flush_interval"] <= 0.5 + + class TestAtexitFlush: def test_flush_all_providers_calls_each_flush(self): """The module registers ``_flush_all_providers`` with ``atexit`` at import @@ -286,6 +351,22 @@ def test_flush_all_providers_calls_each_flush(self): p1.flush.assert_called_once() p2.flush.assert_called_once() + def test_flush_is_noop_when_providers_never_constructed(self): + """The atexit flush must not itself trigger provider construction: + if no provider was ever built, no event was ever dispatched, so there + is nothing to flush and no reason to pay the construction cost.""" + import comfy_cli.tracking as tracking_mod + + with ( + patch.object(tracking_mod, "PROVIDERS", None), + patch.object(tracking_mod, "MixpanelProvider") as mp_cls, + patch.object(tracking_mod, "PostHogProvider") as ph_cls, + ): + tracking_mod._flush_all_providers() + assert tracking_mod.PROVIDERS is None + mp_cls.assert_not_called() + ph_cls.assert_not_called() + def test_flush_swallows_provider_errors(self): import comfy_cli.tracking as tracking_mod diff --git a/tests/comfy_cli/test_utils.py b/tests/comfy_cli/test_utils.py index d16c01a95..88d8a8771 100644 --- a/tests/comfy_cli/test_utils.py +++ b/tests/comfy_cli/test_utils.py @@ -18,7 +18,7 @@ def read(self, amt=-1, decode_content=False): class TestDownloadUrl: - @patch("comfy_cli.utils.requests.get") + @patch("requests.get") def test_writes_file(self, mock_get, tmp_path): content = b"file contents here" mock_response = MagicMock() diff --git a/tests/comfy_cli/test_validate_lowers_ui.py b/tests/comfy_cli/test_validate_lowers_ui.py new file mode 100644 index 000000000..d4aa4d51a --- /dev/null +++ b/tests/comfy_cli/test_validate_lowers_ui.py @@ -0,0 +1,247 @@ +"""`comfy validate` must lower a frontend/canvas graph to API format first. + +Regression for the bug where ``validate_workflow`` (which only inspects the +API/prompt shape ``{id: {class_type, inputs}}``) never iterated the nodes of a +frontend ``{nodes: [...], links: [...]}`` graph and therefore returned +``valid:true`` for a structurally broken canvas workflow. The ``validate`` +command now converts a frontend graph with the SAME converter the ``run`` path +uses before calling ``validate_workflow``. + +Layered: + * direct convert + ``Graph.validate_workflow`` (the empirical core), and + * CLI-level envelope tests via ``CliRunner``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from typer.testing import CliRunner + +from comfy_cli.cql.engine import Graph +from comfy_cli.workflow_to_api import convert_ui_to_api, is_api_format + +FIXTURES = Path(__file__).parent / "fixtures" + + +# --------------------------------------------------------------------------- +# Fixtures: a small /object_info covering the SD1.5 text-to-image fixture, plus +# helpers to load and break that frontend workflow. +# --------------------------------------------------------------------------- + + +def _object_info() -> dict: + """Schemas for every node type in ``sd15_ui_workflow.json``. + + ``ckpt_name`` lists the exact checkpoint the fixture uses so the valid + graph validates clean (no spurious ``unknown_enum_value``). + """ + return { + "CheckpointLoaderSimple": { + "input": {"required": {"ckpt_name": [["v1-5-pruned-emaonly-fp16.safetensors", "sd_xl_base.safetensors"]]}}, + "input_order": {"required": ["ckpt_name"]}, + "output": ["MODEL", "CLIP", "VAE"], + "output_name": ["MODEL", "CLIP", "VAE"], + "display_name": "Load Checkpoint", + "output_node": False, + }, + "KSampler": { + "input": { + "required": { + "model": "MODEL", + "positive": "CONDITIONING", + "negative": "CONDITIONING", + "latent_image": "LATENT", + "seed": ["INT", {"default": 0, "control_after_generate": True}], + "steps": ["INT", {"default": 20}], + "cfg": ["FLOAT", {"default": 8.0}], + "sampler_name": [["euler", "euler_ancestral"]], + "scheduler": [["normal", "karras"]], + "denoise": ["FLOAT", {"default": 1.0}], + } + }, + "input_order": { + "required": [ + "model", + "positive", + "negative", + "latent_image", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + ] + }, + "output": ["LATENT"], + "output_name": ["LATENT"], + "display_name": "KSampler", + "output_node": False, + }, + "CLIPTextEncode": { + "input": {"required": {"text": ["STRING", {"multiline": True}], "clip": "CLIP"}}, + "input_order": {"required": ["clip", "text"]}, + "output": ["CONDITIONING"], + "output_name": ["CONDITIONING"], + "display_name": "CLIP Text Encode", + "output_node": False, + }, + "VAEDecode": { + "input": {"required": {"samples": "LATENT", "vae": "VAE"}}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "display_name": "VAE Decode", + "output_node": False, + }, + "SaveImage": { + "input": {"required": {"images": "IMAGE", "filename_prefix": ["STRING", {"default": "ComfyUI"}]}}, + "input_order": {"required": ["images", "filename_prefix"]}, + "output": [], + "output_name": [], + "display_name": "Save Image", + "output_node": True, + }, + "EmptyLatentImage": { + "input": { + "required": { + "width": ["INT", {"default": 512}], + "height": ["INT", {"default": 512}], + "batch_size": ["INT", {"default": 1}], + } + }, + "input_order": {"required": ["width", "height", "batch_size"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "display_name": "Empty Latent Image", + "output_node": False, + }, + } + + +def _sd15_ui() -> dict: + return json.loads((FIXTURES / "sd15_ui_workflow.json").read_text(encoding="utf-8")) + + +def _break_model_link(wf: dict) -> dict: + """Delete the litegraph link (id 1) that feeds KSampler(id=3).model. + + Mirrors a user deleting a required-input wire on the canvas: the link is + removed from ``links`` and the target input's ``link`` is nulled. After + conversion the ``model`` input becomes ABSENT (not a dangling reference). + """ + wf = json.loads(json.dumps(wf)) + wf["links"] = [link for link in wf["links"] if link[0] != 1] + for node in wf["nodes"]: + if node.get("id") == 3: + for inp in node.get("inputs", []): + if inp.get("name") == "model": + inp["link"] = None + return wf + + +# --------------------------------------------------------------------------- +# Layer 1 — the empirical core: convert a frontend graph, then validate. +# --------------------------------------------------------------------------- + + +class TestConvertThenValidate: + def test_deleted_required_link_becomes_absent_and_is_flagged(self): + """A deleted required-input wire → absent input → missing_required_input. + + This is the exact real-world failure the fix targets: the agent's + canvas graph had a required KSampler input unwired, yet the API-only + validator passed it. Verifies (a) the converter drops the input rather + than emitting a dangling edge, and (b) validate_workflow catches the + absent required input. + """ + oi = _object_info() + api = convert_ui_to_api(_break_model_link(_sd15_ui()), oi) + + # The required input is gone from the lowered node (not a dangling ref). + assert "model" not in api["3"]["inputs"] + + result = Graph.from_object_info(oi).validate_workflow(api) + assert result["valid"] is False + missing = [e for e in result["errors"] if e["code"] == "missing_required_input"] + assert any(e["node_id"] == "3" and e["field"] == "model" for e in missing) + + def test_valid_frontend_graph_lowers_and_validates_clean(self): + oi = _object_info() + api = convert_ui_to_api(_sd15_ui(), oi) + # Lowered form is API-shaped; the KSampler's model IS wired. + assert is_api_format(api) + assert api["3"]["inputs"]["model"] == ["4", 0] + + result = Graph.from_object_info(oi).validate_workflow(api) + assert result["valid"] is True, result["errors"] + + +# --------------------------------------------------------------------------- +# Layer 2 — CLI envelope tests: `comfy validate` end-to-end. +# --------------------------------------------------------------------------- + + +def _run_validate(tmp_path: Path, workflow: dict, object_info: dict): + from comfy_cli.cmdline import app + + wf_path = tmp_path / "workflow.json" + wf_path.write_text(json.dumps(workflow), encoding="utf-8") + oi_path = tmp_path / "object_info.json" + oi_path.write_text(json.dumps(object_info), encoding="utf-8") + + return CliRunner().invoke( + app, + ["validate", "--workflow", str(wf_path), "--input", str(oi_path), "--where", "local"], + env={"COMFY_OUTPUT": "json"}, + ) + + +def _envelope(result) -> dict: + lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")] + assert lines, f"no JSON envelope in output: {result.stdout!r}" + return json.loads(lines[-1]) + + +class TestValidateCLI: + def test_broken_frontend_graph_is_flagged(self, tmp_path): + result = _run_validate(tmp_path, _break_model_link(_sd15_ui()), _object_info()) + assert result.exit_code == 1 + env = _envelope(result) + assert env["ok"] is False + assert env["data"]["valid"] is False + codes = {(e["code"], e["node_id"], e["field"]) for e in env["data"]["errors"]} + assert ("missing_required_input", "3", "model") in codes + + def test_valid_frontend_graph_passes(self, tmp_path): + result = _run_validate(tmp_path, _sd15_ui(), _object_info()) + assert result.exit_code == 0 + env = _envelope(result) + assert env["ok"] is True + assert env["data"]["valid"] is True + assert env["data"]["error_count"] == 0 + + def test_already_api_format_is_validated_unchanged(self, tmp_path): + # A pre-lowered (already-API) graph must still be validated, and must + # NOT be double-converted. Feeding the lowered valid graph stays valid. + api = convert_ui_to_api(_sd15_ui(), _object_info()) + assert is_api_format(api) + result = _run_validate(tmp_path, api, _object_info()) + assert result.exit_code == 0 + assert _envelope(result)["data"]["valid"] is True + + def test_already_api_format_broken_is_still_flagged(self, tmp_path): + # Drop the model input from the already-API KSampler node: since the + # graph is already API-shaped, no conversion happens, but validation + # still runs and catches the absent required input. + api = convert_ui_to_api(_sd15_ui(), _object_info()) + del api["3"]["inputs"]["model"] + result = _run_validate(tmp_path, api, _object_info()) + assert result.exit_code == 1 + env = _envelope(result) + assert env["data"]["valid"] is False + assert any( + e["code"] == "missing_required_input" and e["node_id"] == "3" and e["field"] == "model" + for e in env["data"]["errors"] + ) diff --git a/tests/comfy_cli/test_workflow_to_api.py b/tests/comfy_cli/test_workflow_to_api.py index aa786bb1c..3637d1de2 100644 --- a/tests/comfy_cli/test_workflow_to_api.py +++ b/tests/comfy_cli/test_workflow_to_api.py @@ -1957,6 +1957,206 @@ def test_dynamic_combo_selector_reads_from_filtered_slot(self): assert inputs["shape.side"] == 10.0 +class TestSeedControlMarkerOffByOne: + """Regression for the partner-node seed + ``control_after_generate`` + off-by-one that made ``validate`` (which lowers the graph via + ``convert_ui_to_api``) read a downstream widget as the stray control + token — most visibly a Gemini / Nano Banana node whose + ``response_modalities`` was read as ``"fixed"``. + + Two independent gaps produced the same symptom: + + 1. A seed-like INT input whose name isn't literally ``seed``/``noise_seed`` + and whose schema omits the ``control_after_generate`` flag (Rodin3D's + ``Seed``, Tripo's ``image_seed``/``model_seed``/``texture_seed``, + ``rand_seed``, ``noise_seed_sde``, ``variation_seed``, ...). The old + exact-name implicit heuristic missed these, so the ``"fixed"`` marker + survived and shifted every later widget by one. + + 2. A dynamic combo (``COMFY_DYNAMICCOMBO_V3``) positioned *before* the seed + (GeminiNanoBanana2V2 / "Nano Banana 2") whose option carries a + connection-only (non-widget) sub-input. The span walk over-counted the + combo, reached the seed at the wrong index, and left the marker in + place — landing ``"fixed"`` on the ``response_modalities`` widget that + immediately follows the seed. + """ + + def test_non_canonical_seed_name_strips_control_marker(self): + # Seed-like INT named ``image_seed`` (Tripo style), unflagged, followed + # by a control marker and then a downstream COMBO. Before the fix the + # marker survived and ``response_modalities`` read "fixed". + object_info = { + "PartnerImageNode": { + "input": { + "required": { + "prompt": ["STRING", {"multiline": True}], + "image_seed": ["INT", {"default": 42}], # no control_after_generate flag + "response_modalities": [["IMAGE", "IMAGE+TEXT"], {}], + } + }, + "input_order": {"required": ["prompt", "image_seed", "response_modalities"]}, + "output_node": True, + "display_name": "Partner Image", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "PartnerImageNode", + "inputs": [], + "outputs": [], + "widgets_values": ["a cat", 12345, "fixed", "IMAGE"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["image_seed"] == 12345 + assert inputs["response_modalities"] == "IMAGE" # not "fixed" + assert "fixed" not in inputs.values() + + def test_nano_banana_pro_flagged_seed_still_strips(self): + # GeminiImage2Node / "Nano Banana Pro" shape: plain COMBO model, seed + # carries control_after_generate. This already worked; pin it. + object_info = { + "GeminiImage2Node": { + "input": { + "required": { + "prompt": ["STRING", {"multiline": True}], + "model": [["gemini-2.5-flash-image"], {}], + "seed": ["INT", {"default": 42, "control_after_generate": True}], + "aspect_ratio": [["auto", "1:1"], {}], + "resolution": [["1K", "2K"], {}], + "response_modalities": [["IMAGE", "IMAGE+TEXT"], {}], + } + }, + "input_order": { + "required": ["prompt", "model", "seed", "aspect_ratio", "resolution", "response_modalities"] + }, + "output_node": True, + "display_name": "Nano Banana Pro (Google Gemini Image)", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "GeminiImage2Node", + "inputs": [], + "outputs": [], + "widgets_values": ["a cat", "gemini-2.5-flash-image", 999, "fixed", "auto", "1K", "IMAGE"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["seed"] == 999 + assert inputs["response_modalities"] == "IMAGE" + assert "fixed" not in inputs.values() + + def test_dynamic_combo_before_seed_with_nonwidget_subinput(self): + # GeminiNanoBanana2V2 / "Nano Banana 2" shape: dynamic ``model`` combo + # precedes the seed and its option has a connection-only sub-input + # (``images`` -> IMAGE) that carries no widget value. ``response_modalities`` + # sits right after the seed, so before the fix the stray control marker + # landed on it. The non-widget sub-input must not consume a value slot. + object_info = { + "GeminiNanoBanana2V2": { + "input": { + "required": { + "prompt": ["STRING", {"multiline": True}], + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "nb2", + "inputs": { + "required": { + "aspect_ratio": [["auto", "16:9"], {}], + "resolution": [["1K", "2K"], {}], + "thinking_level": [["MINIMAL", "HIGH"], {}], + "images": ["IMAGE", {}], # connection-only, no widget value + } + }, + } + ] + }, + ], + "seed": ["INT", {"default": 42, "control_after_generate": True}], + "response_modalities": [["IMAGE", "IMAGE+TEXT"], {}], + } + }, + "input_order": {"required": ["prompt", "model", "seed", "response_modalities"]}, + "output_node": True, + "display_name": "Nano Banana 2", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "GeminiNanoBanana2V2", + "inputs": [], + "outputs": [], + # prompt, model_key, aspect_ratio, resolution, thinking_level, + # seed, control_marker, response_modalities + "widgets_values": ["a cat", "nb2", "auto", "1K", "HIGH", 999, "fixed", "IMAGE"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["seed"] == 999 + assert inputs["response_modalities"] == "IMAGE" # not "fixed" + assert inputs["model"] == "nb2" + assert inputs["model.aspect_ratio"] == "auto" + assert inputs["model.resolution"] == "1K" + assert inputs["model.thinking_level"] == "HIGH" + assert "fixed" not in inputs.values() + + def test_non_seed_int_before_control_keyword_not_stripped(self): + # Safety net: a non-seed INT (``steps``) followed by a COMBO whose value + # is literally "fixed" must NOT be treated as a control companion. + object_info = { + "PlainNode": { + "input": { + "required": { + "steps": ["INT", {"default": 20}], + "mode": [["fixed", "auto"], {}], + } + }, + "input_order": {"required": ["steps", "mode"]}, + "output_node": True, + "display_name": "Plain", + } + } + workflow = { + "nodes": [ + { + "id": 1, + "type": "PlainNode", + "inputs": [], + "outputs": [], + "widgets_values": [20, "fixed"], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, object_info) + inputs = result["1"]["inputs"] + assert inputs["steps"] == 20 + assert inputs["mode"] == "fixed" # preserved, not eaten as a control marker + + class TestDynamicPrompts: """Port of frontend's processDynamicPrompt behavior (formatUtil.ts). diff --git a/tests/e2e/verify_tracking_live.py b/tests/e2e/verify_tracking_live.py index 2928ab8a7..7b68c1488 100644 --- a/tests/e2e/verify_tracking_live.py +++ b/tests/e2e/verify_tracking_live.py @@ -62,7 +62,8 @@ def _send_smoketest_event(nonce: str) -> tuple[str, str]: if tracking._telemetry_disabled_by_env(): _die("telemetry is opted out via DO_NOT_TRACK / COMFY_NO_TELEMETRY — unset it to verify") - posthog_providers = [p for p in tracking.PROVIDERS if isinstance(p, tracking.PostHogProvider) and p.enabled] + # Providers are constructed lazily; force construction before filtering. + posthog_providers = [p for p in tracking._get_providers() if isinstance(p, tracking.PostHogProvider) and p.enabled] if not posthog_providers: _die("no enabled PostHog provider — check POSTHOG_API_KEY token") diff --git a/uv.lock b/uv.lock index ffd02f50b..07a6fba54 100644 --- a/uv.lock +++ b/uv.lock @@ -182,6 +182,7 @@ dependencies = [ { name = "cookiecutter" }, { name = "gitpython" }, { name = "httpx" }, + { name = "imageio-ffmpeg" }, { name = "mixpanel" }, { name = "packaging" }, { name = "pathspec" }, @@ -218,6 +219,7 @@ requires-dist = [ { name = "cookiecutter" }, { name = "gitpython", specifier = ">=3.1.50" }, { name = "httpx" }, + { name = "imageio-ffmpeg" }, { name = "jsonschema", marker = "extra == 'dev'" }, { name = "mixpanel" }, { name = "packaging" }, @@ -375,7 +377,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -470,6 +472,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" From baf71ad5a0340698ad00f8bda08a63e2ac051028 Mon Sep 17 00:00:00 2001 From: kishore Date: Tue, 14 Jul 2026 00:12:22 -0700 Subject: [PATCH 02/53] fix(workflow): address high/medium review findings on agent-workflow branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the two correctness bugs and the offline-catalog/validate contract regressions surfaced by the high-effort review. Each fix carries a regression test. The two intentional features flagged (env-catalog precedence; cache-first TTL) are addressed by scoping the TTL, not removing it. Widget indexing — node-aware, not first-key (silent corruption) Dynamic-combo (COMFY_DYNAMICCOMBO_V3) widget order was expanded from the schema's FIRST key, but widgets_values is laid out by the node's SELECTED key. When the selection expands to a different sub-widget count, set-widget/slots mis-indexed every widget after the combo (e.g. set-widget .seed writing into model.resolution). Add Graph.widget_order_for_node(class, widgets_values) which expands by the actual selection, and route every consumer that indexes into a real node's widgets_values through it (_widget_index + its callers, engine slots, subgraph interior read/write, recipe capture). It delegates to the static order when there's no dynamic combo or no selection. UI→API converter — don't steal the next COMBO's value (dropped widget value) The implicit seed companion heuristic consumed the next value for ANY seed-substring INT whenever that value equaled a control keyword, dropping a legitimate widget value. Peek at the next widget input: when it's a COMBO that legitimately lists the value as an option, it's that combo's value, not a phantom control_after_generate marker — keep it. validate — build the graph from the SAME env-aware catalog it lowers with validate built its Graph via Graph.load (live fetch, ignored COMFY_OBJECT_INFO_FILE) while canvas-lowering used resilient_load_object_info (which honors it). Resolve object_info ONCE through the shared loader, build the graph from it, and reuse it for lowering — consistent catalog, no double fetch. workflow edit — bad --where returns an envelope, not a traceback resolve_default(where) raises ValueError on a bad --where, but _get_graph only caught LoadError, so it escaped as a raw traceback out of every edit command. Catch it and emit the where_invalid envelope. preview — classify unknown media as unknown, not video With only imageio-ffmpeg's static ffmpeg (no ffprobe), _classify_by_ext defaulted every unrecognized extension to "video" and handed it to ffmpeg. Add a video-extension set so non-media returns "unknown" → the clean preview_unsupported_media envelope, matching the ffprobe path. object_info cache — cache-first TTL is cloud-only A cached local catalog hid a just-installed node for the whole TTL. The cloud catalog is stable and its fetch is slow (real cache win); the localhost fetch is cheap and freshness matters, so local always fetches live. The stale-cache failure fallback still applies to both. Co-Authored-By: Claude Opus 4.8 (1M context) --- comfy_cli/cmdline.py | 24 +++-- comfy_cli/command/preview.py | 17 +++- comfy_cli/command/workflow.py | 20 +++- comfy_cli/cql/engine.py | 79 ++++++++++++--- comfy_cli/cql/loader.py | 15 ++- comfy_cli/workflow_ops.py | 59 ++++++++---- comfy_cli/workflow_to_api.py | 95 +++++++++++++------ tests/comfy_cli/command/test_preview.py | 16 ++++ tests/comfy_cli/command/test_workflow_edit.py | 39 ++++++-- tests/comfy_cli/cql/test_engine.py | 81 ++++++++++++++++ tests/comfy_cli/cql/test_loader_ttl.py | 24 +++++ tests/comfy_cli/test_workflow_to_api.py | 36 +++++++ 12 files changed, 413 insertions(+), 92 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 5604df95d..9f21d03de 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -989,8 +989,16 @@ def validate( except Exception: pass + # Resolve object_info ONCE through the shared loader so validate honors the + # same catalog every other command does — an explicit --input dump, the + # COMFY_OBJECT_INFO_FILE offline catalog, or the cache-first live fetch — and + # so the graph we validate against is built from the SAME catalog used to + # lower a canvas workflow below (previously the graph came from Graph.load, + # which ignored COMFY_OBJECT_INFO_FILE, while lowering honored it). try: - graph = Graph.load(mode=mode, input_path=input_path, host=host or "127.0.0.1", port=port or 8188) + object_info = resilient_load_object_info( + mode=mode, input_path=input_path, host=host or "127.0.0.1", port=port or 8188 + ) except LoadError as e: renderer.error( code="cql_no_graph", @@ -999,6 +1007,8 @@ def validate( details=e.details, ) raise typer.Exit(code=1) from e + graph = Graph.from_object_info(object_info) + graph._try_default_annotations() # `validate_workflow` only inspects the API/prompt shape # ({id: {class_type, inputs}}) — it iterates node inputs and checks wiring, @@ -1010,18 +1020,6 @@ def validate( # (and the SAME object_info resolution) the `run` path uses, so validate # inspects exactly what the server would execute. if not is_api_format(wf_data): - try: - object_info = resilient_load_object_info( - mode=mode, input_path=input_path, host=host or "127.0.0.1", port=port or 8188 - ) - except LoadError as e: - renderer.error( - code="cql_no_graph", - message=str(e), - hint=e.details.get("hint", "pass --input , or start the server"), - details=e.details, - ) - raise typer.Exit(code=1) from e try: wf_data = convert_ui_to_api(wf_data, object_info) except WorkflowConversionError as e: diff --git a/comfy_cli/command/preview.py b/comfy_cli/command/preview.py index 91389df59..e01d3b6fb 100644 --- a/comfy_cli/command/preview.py +++ b/comfy_cli/command/preview.py @@ -137,13 +137,26 @@ def _resolve_ffmpeg() -> str | None: _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"} _AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"} +_VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".mts", ".3gp"} def _classify_by_ext(path: Path) -> dict: """Fallback classification when ffprobe is unavailable (e.g. only the - imageio-ffmpeg static ffmpeg is present): pick kind from the extension.""" + imageio-ffmpeg static ffmpeg is present): pick kind from the extension. + + An unrecognized extension is ``"unknown"`` (not blindly ``"video"``) so a + non-media file still yields the clean ``preview_unsupported_media`` envelope + instead of being handed to ffmpeg and failing with a raw ffmpeg error — the + same outcome the ffprobe path produces for a file with no media stream.""" ext = path.suffix.lower() - kind = "image" if ext in _IMAGE_EXTS else "audio" if ext in _AUDIO_EXTS else "video" + if ext in _IMAGE_EXTS: + kind = "image" + elif ext in _AUDIO_EXTS: + kind = "audio" + elif ext in _VIDEO_EXTS: + kind = "video" + else: + kind = "unknown" return {"kind": kind, "width": None, "height": None, "fps": None, "duration": None, "has_audio": None} diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index c571000ed..4a676e9a3 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -95,7 +95,13 @@ def _get_graph(input_path: str | None, host: str | None, port: int | None, on_st # Honor an explicit --where (threaded from the agent edit commands) via # the convenience wrapper, which folds in the config/project precedence. - decision = where_module.resolve_default(where) + # A bad --where value raises ValueError — surface it as the agent-first + # error envelope, not a raw traceback out of every edit command. + try: + decision = where_module.resolve_default(where) + except ValueError as e: + renderer.error(code="where_invalid", message=str(e), hint="use --where local or --where cloud") + raise typer.Exit(code=1) from e mode = "cloud" if decision.target is where_module.WhereTarget.CLOUD else "local" from comfy_cli.cql.loader import resilient_load_object_info @@ -1181,6 +1187,12 @@ def delete_cmd( app.command("set-widget", help="Set a widget by name (`.`); emits a set_widget op.")(_wedit.set_widget_cmd) app.command("delete-node", help="Delete a node and its links; emits a delete_node op.")(_wedit.delete_cmd) app.command("ls-nodes", help="List nodes (id/type/title) in a workflow file.")(_wedit.ls_nodes_cmd) -app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")(_wedit.apply_cmd) -app.command("capture", help="Project a workflow into a reusable recipe (the op-batch that rebuilds it).")(_wedit.capture_cmd) -app.command("foreach", help="Instantiate a recipe over N param-sets → N workflows (bulk generation).")(_wedit.foreach_cmd) +app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")( + _wedit.apply_cmd +) +app.command("capture", help="Project a workflow into a reusable recipe (the op-batch that rebuilds it).")( + _wedit.capture_cmd +) +app.command("foreach", help="Instantiate a recipe over N param-sets → N workflows (bulk generation).")( + _wedit.foreach_cmd +) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index e8ffcb2c0..7c014606c 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -336,18 +336,33 @@ def _parse_input_spec(spec: Any) -> tuple[str, bool, list[Any], PortOptions]: return "UNKNOWN", False, [], port_opts -def _dynamic_sub_widget_names(base: str, options: list) -> list[str]: - """Sub-widget names a dynamic combo expands to, from the first (default) key — - e.g. ``model`` → ``["model.resolution"]``. Static mirror of the converter's - value-driven ``_dynamic_combo_sub_inputs`` (uses the first key, not a selection).""" - return [name for name, _ in _dynamic_sub_widget_defaults(base, options).items()] +_FIRST_KEY = object() # sentinel: expand the first/default dynamic-combo key -def _dynamic_sub_widget_defaults(base: str, options: list) -> dict[str, Any]: - """``{f"{base}.{sub}": default}`` for the first key's sub-inputs.""" - if not options or not isinstance(options[0], dict): +def _dynamic_sub_widget_names(base: str, options: list, selected: Any = _FIRST_KEY) -> list[str]: + """Sub-widget names a dynamic combo expands to for the ``selected`` key + (default: the first/default key) — e.g. ``model`` → ``["model.resolution"]``. + Static mirror of the converter's value-driven ``_dynamic_combo_sub_inputs``.""" + return [name for name, _ in _dynamic_sub_widget_defaults(base, options, selected).items()] + + +def _dynamic_sub_widget_defaults(base: str, options: list, selected: Any = _FIRST_KEY) -> dict[str, Any]: + """``{f"{base}.{sub}": default}`` for the ``selected`` key's sub-inputs. + + Defaults to the first key (fresh nodes select it). Passing the node's actual + selected key — as ``widget_order_for_node`` does — keeps the widget order + aligned to ``widgets_values`` when a node picks an option whose sub-widget + count differs from the default. An unknown key expands to nothing, matching + the converter's ``_dynamic_combo_sub_inputs``.""" + if not options: return {} - sub_def = options[0].get("inputs") + if selected is _FIRST_KEY: + option = options[0] if isinstance(options[0], dict) else None + else: + option = next((o for o in options if isinstance(o, dict) and o.get("key") == selected), None) + if option is None: + return {} + sub_def = option.get("inputs") if not isinstance(sub_def, dict): return {} out: dict[str, Any] = {} @@ -782,6 +797,44 @@ def widget_order(self, class_name: str) -> list[str]: order.append("control_after_generate") return order + def widget_order_for_node(self, class_name: str, widgets_values: Any = None) -> list[str]: + """Widget order aligned to a SPECIFIC node's ``widgets_values``. + + Identical to :meth:`widget_order`, except a dynamic combo expands the + sub-widgets of its *currently selected* key (read from ``widgets_values``) + rather than the schema's first key. The two agree for a fresh node (which + defaults to the first key) but diverge once a node selects an option whose + sub-widget count differs — and there the static first-key order mis-indexes + every widget after the combo, so e.g. ``set-widget .seed`` would write + into ``model.resolution``. Falls back to the static order when + ``widgets_values`` is empty (a fresh/unselected node selects the first key). + """ + base = self.widget_order(class_name) + m = self._nodes.get(class_name) + values = list(widgets_values) if widgets_values else [] + # The static order is already exact unless this node BOTH has a dynamic + # combo AND carries a selection to read. Delegating otherwise keeps the + # single source of truth (and any override) for the common case. + if m is None or not base or not values or not any(p.options.dynamic_options for p in m.inputs if not p.is_link): + return base + order: list[str] = [] + vidx = 0 + for p in m.inputs: + if p.is_link: + continue + order.append(p.name) + selector_idx = vidx + vidx += 1 + if p.options.dynamic_options: + selected = values[selector_idx] if selector_idx < len(values) else _FIRST_KEY + subs = _dynamic_sub_widget_names(p.name, p.options.dynamic_options, selected) + order.extend(subs) + vidx += len(subs) + if p.options.control_after_generate: + order.append("control_after_generate") + vidx += 1 + return order + def widget_defaults(self, class_name: str) -> dict[str, Any]: """Default value per widget-order name — including dynamic-combo selectors (first key), their sub-widgets, and control_after_generate. Used by @@ -1380,8 +1433,8 @@ def _node_widget_slots(node: dict, prefix: str, graph: Graph) -> list[dict]: m = graph.node(node_type) if m is None: return [] - order = graph.widget_order(node_type) widgets = node.get("widgets_values") or [] + order = graph.widget_order_for_node(node_type, widgets) slots: list[dict] = [] for port in m.inputs: if port.is_link: @@ -1530,12 +1583,12 @@ def _resolve_proxy_value(instance: dict, subgraph: dict, input_name: str, graph: if not isinstance(inode, dict) or str(inode.get("id", "")) != interior_id: continue interior_class = inode.get("type", "") - order = graph.widget_order(interior_class) + widgets = inode.get("widgets_values") or [] + order = graph.widget_order_for_node(interior_class, widgets) try: idx = order.index(name) except ValueError: return _UNRESOLVED - widgets = inode.get("widgets_values") or [] return widgets[idx] if idx < len(widgets) else _UNRESOLVED break return _UNRESOLVED @@ -1552,7 +1605,7 @@ def _write_widget(node: dict, input_name: str, value: Any, graph: Graph, *, exte m = graph.node(node_type) if m is None: raise ValueError(f"unknown node type {node_type!r} for node {node.get('id')}") - order = graph.widget_order(node_type) + order = graph.widget_order_for_node(node_type, node.get("widgets_values")) try: widget_idx = order.index(input_name) except ValueError: diff --git a/comfy_cli/cql/loader.py b/comfy_cli/cql/loader.py index 925cde8f0..9ec6995b0 100644 --- a/comfy_cli/cql/loader.py +++ b/comfy_cli/cql/loader.py @@ -455,9 +455,18 @@ def resilient_load_object_info( host_key = _resolve_host_key(mode, host, port) - fresh = read_fresh_object_info_cache(host_key, object_info_cache_ttl()) - if fresh is not None: - return fresh + # Cache-first TTL is CLOUD-only. The cloud catalog is stable and its remote + # /object_info fetch is slow (multi-MB over the network), so a fresh cache hit + # is a real win. Local is the opposite: the localhost fetch is cheap, and a + # user installs custom nodes into their OWN server — serving a cached local + # catalog would hide a just-added node for the whole TTL. So local always + # fetches live. (The stale-cache *failure* fallback below still applies to + # both: a cache is still written on a successful local fetch so a later + # unreachable-server call can fall back to it.) + if mode == "cloud": + fresh = read_fresh_object_info_cache(host_key, object_info_cache_ttl()) + if fresh is not None: + return fresh try: data = _load_from_target(mode=mode, host=host, port=port) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index f22392f41..804bb7d84 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -282,11 +282,11 @@ def _set_widget_impl( target = _navigate_subgraph_path(workflow, segments) # read-only: current value + schema inner_type = target.get("type", "") value, norm_note = _normalize_combo(graph, inner_type, inner_widget, value) - order = graph.widget_order(inner_type) + cur = target.get("widgets_values") or [] + order = graph.widget_order_for_node(inner_type, cur) old = None if inner_widget in order: i = order.index(inner_widget) - cur = target.get("widgets_values") or [] old = cur[i] if i < len(cur) else None warnings = _validate_widget(graph, inner_type, inner_widget, value) # raises on shape mismatch if norm_note: @@ -308,9 +308,9 @@ def _set_widget_impl( node = _require(workflow, node_id) class_type = node.get("type", "") - idx = _widget_index(graph, class_type, widget) # raises on unknown widget name - value, norm_note = _normalize_combo(graph, class_type, widget, value) widgets = node.get("widgets_values") or [] + idx = _widget_index(graph, class_type, widget, widgets) # raises on unknown widget name + value, norm_note = _normalize_combo(graph, class_type, widget, value) old = widgets[idx] if idx < len(widgets) else None warnings = _validate_widget(graph, class_type, widget, value) # raises on shape mismatch if norm_note: @@ -625,9 +625,9 @@ def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | N if n.get("pos"): add["at"] = n["pos"] ops.append(add) - order = graph.widget_order(class_type) - defaults = graph.widget_defaults(class_type) widgets = n.get("widgets_values") or [] + order = graph.widget_order_for_node(class_type, widgets) + defaults = graph.widget_defaults(class_type) for i, wname in enumerate(order): if i >= len(widgets): break @@ -704,7 +704,9 @@ def _split_ref_slot(spec_val: str, aliases: dict[str, Any]) -> tuple[Any, Any]: return resolve_ref(node_part, aliases), slot -def apply_specs(workflow: dict, graph, specs: list, *, actor: str = "cli", base_version: int = 0) -> tuple[dict, list, dict]: +def apply_specs( + workflow: dict, graph, specs: list, *, actor: str = "cli", base_version: int = 0 +) -> tuple[dict, list, dict]: """Apply edit specs to ``workflow`` in order. Returns (workflow, ops, aliases).""" aliases: dict[str, Any] = {} ops: list[dict] = [] @@ -713,7 +715,9 @@ def apply_specs(workflow: dict, graph, specs: list, *, actor: str = "cli", base_ raise ValueError(f"spec #{i} must be an object with an 'op' field") kind = spec["op"] if kind == "add_node": - workflow, op = add_node(workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version) + workflow, op = add_node( + workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version + ) if spec.get("as"): aliases[spec["as"]] = op["node_id"] elif kind == "connect": @@ -722,11 +726,18 @@ def apply_specs(workflow: dict, graph, specs: list, *, actor: str = "cli", base_ workflow, op = connect(workflow, graph, fn, fs, tn, ts, actor=actor, base_version=base_version) elif kind == "set_widget": workflow, op = set_widget( - workflow, graph, resolve_ref(spec["node"], aliases), spec["widget"], spec["value"], - actor=actor, base_version=base_version, + workflow, + graph, + resolve_ref(spec["node"], aliases), + spec["widget"], + spec["value"], + actor=actor, + base_version=base_version, ) elif kind == "delete_node": - workflow, op = delete_node(workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version) + workflow, op = delete_node( + workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version + ) else: raise ValueError(f"spec #{i}: unknown op {kind!r}") ops.append(op) @@ -791,8 +802,8 @@ def _apply_set_widget(workflow: dict, op: dict, graph) -> None: node = _find(workflow, op["node_id"]) if node is None: return # target concurrently deleted => no-op (delete wins). - idx = _widget_index(graph, node.get("type", ""), op["widget"]) widgets = node.setdefault("widgets_values", []) + idx = _widget_index(graph, node.get("type", ""), op["widget"], widgets) if idx >= len(widgets): widgets.extend([None] * (idx + 1 - len(widgets))) widgets[idx] = op["value"] @@ -818,7 +829,12 @@ def _apply_connect(workflow: dict, op: dict) -> None: ins = dst.setdefault("inputs", []) to_idx = next((k for k, i in enumerate(ins) if i.get("grow_id") == op["link_id"]), None) if to_idx is None: - entry = {"name": _next_autogrow_name(ins, grow["name"]), "type": grow["type"], "link": None, "grow_id": op["link_id"]} + entry = { + "name": _next_autogrow_name(ins, grow["name"]), + "type": grow["type"], + "link": None, + "grow_id": op["link_id"], + } if grow.get("widget"): # Mark as a converted widget (ComfyUI's widget→input); value stays # in widgets_values for positional alignment, converter uses the link. @@ -858,11 +874,7 @@ def _apply_delete_node(workflow: dict, op: dict) -> None: node_id = op["node_id"] workflow["nodes"] = [n for n in workflow.get("nodes") or [] if n.get("id") != node_id] removed = set(op.get("removed_links") or []) - kept = [ - ln - for ln in workflow.get("links") or [] - if ln[0] not in removed and ln[1] != node_id and ln[3] != node_id - ] + kept = [ln for ln in workflow.get("links") or [] if ln[0] not in removed and ln[1] != node_id and ln[3] != node_id] workflow["links"] = kept kept_ids = {ln[0] for ln in kept} # Scrub dangling references so no input/output points at a gone link. @@ -1010,8 +1022,11 @@ def _build_node(node_id: int, class_type: str, m, graph, pos: list | None) -> di } -def _widget_index(graph, class_type: str, widget: str) -> int: - order = graph.widget_order(class_type) +def _widget_index(graph, class_type: str, widget: str, widgets_values=None) -> int: + # Node-aware: expand a dynamic combo's sub-widgets by this node's actual + # selected key (from ``widgets_values``), not the schema's first key, so the + # index stays aligned to ``widgets_values`` for the node's real selection. + order = graph.widget_order_for_node(class_type, widgets_values) if widget not in order: avail = [w for w in order if w != "control_after_generate"] raise ValueError( @@ -1091,7 +1106,9 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - # Dotted autogrow key (images.image0) or a base that has no concrete slot yet. if isinstance(slot, str): base = slot.split(".", 1)[0] - ag = next((i for i in ins if i.get("name") == base and str(i.get("type", "")).startswith("COMFY_AUTOGROW")), None) + ag = next( + (i for i in ins if i.get("name") == base and str(i.get("type", "")).startswith("COMFY_AUTOGROW")), None + ) if ag is not None: requested = slot if "." in slot else None return None, _plan_autogrow(ins, base, elem_type, requested=requested) diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 854985365..5320bd5aa 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1188,47 +1188,72 @@ def is_control(v: Any) -> bool: out = [] vidx = 0 input_def = _schema_input_def(schema) + # Flatten to the ordered widget inputs so the seed companion check can peek at + # the NEXT widget input (a legitimate COMBO value that equals a control + # keyword must not be mistaken for a control_after_generate marker). + widget_inputs: list[tuple[str, Any]] = [] for section in ("required", "optional"): section_def = input_def.get(section) or {} if not isinstance(section_def, dict): continue for input_name, input_spec in section_def.items(): - if vidx >= len(widget_values): - break - is_widget, is_dynamic = _is_widget_input(input_spec) - if not is_widget: - continue - if is_dynamic: - # A V3 dynamic combo (``COMFY_*COMBO*``) occupies its selector - # slot plus a variable number of sub-input slots chosen by the - # selected option. Copy the whole span through untouched and - # advance ``vidx`` in lockstep with ``_get_widget_name_order`` - # (which expands the same sub-inputs). Otherwise the walk - # treats the combo as a single slot, reaches a later seed input - # too early, checks the wrong slot for its control_after_generate - # marker, and leaves the marker in place — shifting every widget - # after the seed by one (e.g. GeminiNanoBanana2V2 / Nano Banana 2, - # whose dynamic ``model`` precedes the seed and whose - # ``response_modalities`` sits right after it, so the stray - # ``"fixed"`` lands on ``response_modalities``). - subs = _dynamic_combo_sub_inputs(input_name, input_spec, widget_values, vidx) - span = min(1 + len(subs), len(widget_values) - vidx) - out.extend(widget_values[vidx : vidx + span]) - vidx += span - continue - out.append(widget_values[vidx]) + if _is_widget_input(input_spec)[0]: + widget_inputs.append((input_name, input_spec)) + for i, (input_name, input_spec) in enumerate(widget_inputs): + if vidx >= len(widget_values): + break + _is_widget, is_dynamic = _is_widget_input(input_spec) + if is_dynamic: + # A V3 dynamic combo (``COMFY_*COMBO*``) occupies its selector + # slot plus a variable number of sub-input slots chosen by the + # selected option. Copy the whole span through untouched and + # advance ``vidx`` in lockstep with ``_get_widget_name_order`` + # (which expands the same sub-inputs). Otherwise the walk + # treats the combo as a single slot, reaches a later seed input + # too early, checks the wrong slot for its control_after_generate + # marker, and leaves the marker in place — shifting every widget + # after the seed by one (e.g. GeminiNanoBanana2V2 / Nano Banana 2, + # whose dynamic ``model`` precedes the seed and whose + # ``response_modalities`` sits right after it, so the stray + # ``"fixed"`` lands on ``response_modalities``). + subs = _dynamic_combo_sub_inputs(input_name, input_spec, widget_values, vidx) + span = min(1 + len(subs), len(widget_values) - vidx) + out.extend(widget_values[vidx : vidx + span]) + vidx += span + continue + out.append(widget_values[vidx]) + vidx += 1 + next_input_spec = widget_inputs[i + 1][1] if i + 1 < len(widget_inputs) else None + if vidx < len(widget_values) and _has_control_after_generate_companion( + input_name, input_spec, widget_values[vidx], next_input_spec + ): vidx += 1 - if vidx < len(widget_values) and _has_control_after_generate_companion( - input_name, input_spec, widget_values[vidx] - ): - vidx += 1 while vidx < len(widget_values): out.append(widget_values[vidx]) vidx += 1 return out -def _has_control_after_generate_companion(input_name: str, input_spec: Any, next_value: Any) -> bool: +def _combo_lists_option(input_spec: Any, value: Any) -> bool: + """True if ``input_spec`` is a COMBO that declares ``value`` as one of its options. + + Covers both the classic list-form combo (``[["a", "b", ...]]`` — the type IS + the option list) and the dict-form combo (``["COMBO", {"options": [...]}]``).""" + if not isinstance(input_spec, (list, tuple)) or not input_spec: + return False + type_field = input_spec[0] + if isinstance(type_field, list): + return value in type_field + if len(input_spec) >= 2 and isinstance(input_spec[1], dict): + opts = input_spec[1].get("options") + if isinstance(opts, list): + return value in opts + return False + + +def _has_control_after_generate_companion( + input_name: str, input_spec: Any, next_value: Any, next_input_spec: Any = None +) -> bool: """True if ``next_value`` should be consumed as a control_after_generate marker. Two ways the frontend adds the companion widget: @@ -1254,6 +1279,12 @@ def _has_control_after_generate_companion(input_name: str, input_spec: Any, next ``seed`` substring guard preserves the schema-aware path's protection against a legitimate non-seed INT (e.g. ``steps``) that merely happens to precede a COMBO/STRING widget whose value equals a control keyword. + + ``next_input_spec`` is the schema of the *next* widget input (when known). On + the implicit seed path we refuse to consume ``next_value`` when that next + widget is a COMBO that legitimately lists ``next_value`` as an option — there + the value is the combo's own saved selection, not a phantom companion, so + consuming it would drop a real widget value and shift every later widget. """ if not (isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES): return False @@ -1261,7 +1292,11 @@ def _has_control_after_generate_companion(input_name: str, input_spec: Any, next if options.get("control_after_generate"): return True input_type = input_spec[0] if input_spec else None - return input_type == "INT" and "seed" in input_name.lower() + if not (input_type == "INT" and "seed" in input_name.lower()): + return False + # Implicit seed path: don't steal a value that the next COMBO widget declares + # as one of its own options. + return not _combo_lists_option(next_input_spec, next_value) def _collect_widget_inputs( diff --git a/tests/comfy_cli/command/test_preview.py b/tests/comfy_cli/command/test_preview.py index 303711aaa..747560653 100644 --- a/tests/comfy_cli/command/test_preview.py +++ b/tests/comfy_cli/command/test_preview.py @@ -109,3 +109,19 @@ def test_preview_missing_file_errors(tmp_path, monkeypatch): monkeypatch.setattr("comfy_cli.tracking.track_event", lambda *a, **kw: None) with pytest.raises(typer.Exit): preview_cmd(tmp_path / "nope.png") + + +def test_classify_by_ext_unknown_for_non_media(): + """The no-ffprobe fallback must return 'unknown' for a non-media extension so + preview_cmd emits preview_unsupported_media instead of handing a .txt to ffmpeg. + Regression: it previously defaulted every unrecognized extension to 'video'.""" + from pathlib import Path + + from comfy_cli.command.preview import _classify_by_ext + + assert _classify_by_ext(Path("notes.txt"))["kind"] == "unknown" + assert _classify_by_ext(Path("archive.zip"))["kind"] == "unknown" + assert _classify_by_ext(Path("clip.mp4"))["kind"] == "video" + assert _classify_by_ext(Path("clip.mov"))["kind"] == "video" + assert _classify_by_ext(Path("pic.png"))["kind"] == "image" + assert _classify_by_ext(Path("sound.wav"))["kind"] == "audio" diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index d73272347..f9ed40981 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -145,7 +145,10 @@ def _object_info() -> dict[str, Any]: "key": "kling-v3", "inputs": { "required": { - "resolution": ["COMBO", {"default": "1080p", "options": ["4k", "1080p", "720p"]}] + "resolution": [ + "COMBO", + {"default": "1080p", "options": ["4k", "1080p", "720p"]}, + ] } }, } @@ -242,7 +245,10 @@ def _autogrow_workflow() -> dict: "id": 20, "type": "VAEDecode", "pos": [0, 0], - "inputs": [{"name": "samples", "type": "LATENT", "link": None}, {"name": "vae", "type": "VAE", "link": None}], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], "widgets_values": [], }, @@ -250,7 +256,10 @@ def _autogrow_workflow() -> dict: "id": 21, "type": "VAEDecode", "pos": [0, 100], - "inputs": [{"name": "samples", "type": "LATENT", "link": None}, {"name": "vae", "type": "VAE", "link": None}], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], "widgets_values": [], }, @@ -280,7 +289,10 @@ def _convergence_base() -> dict: "id": nid, "type": "VAEDecode", "pos": [150, y], - "inputs": [{"name": "samples", "type": "LATENT", "link": None}, {"name": "vae", "type": "VAE", "link": None}], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], "widgets_values": [], } @@ -740,7 +752,6 @@ def test_deletes_node_and_incident_links(self, patched_graph, tmp_path, capsys): latent = next(i for i in ks["inputs"] if i["name"] == "latent_image") assert latent["link"] is None - def test_nested_subgraph_address_missing_interior_node_errors(self, patched_graph, tmp_path, capsys): # A nested address into a graph with no such subgraph/interior node fails # cleanly (the top-level workflow here has no subgraph instance 10). @@ -908,7 +919,9 @@ def test_param_substitution_is_typed(self, patched_graph, tmp_path, capsys): path = self._empty(tmp_path) rp = tmp_path / "r.json" rp.write_text(json.dumps(self._recipe()), encoding="utf-8") - env = _run(["apply", str(path), "--ops", str(rp), "--param", "positive=quiet forest", "--param", "steps=35"], capsys) + env = _run( + ["apply", str(path), "--ops", str(rp), "--param", "positive=quiet forest", "--param", "steps=35"], capsys + ) assert env["ok"] is True, env wf = json.loads(path.read_text()) g = _graph() @@ -1450,3 +1463,17 @@ def test_unknown_value_is_left_for_validate_to_flag(self): _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "totally_made_up") assert op["value"] == "totally_made_up" # not silently changed assert any(w.get("code") == "unknown_enum_value" for w in op.get("warnings", [])) + + +class TestWhereInvalid: + """A bad --where surfaces the agent-first error envelope, not a raw traceback. + + Regression: _get_graph only caught LoadError, so resolve_default's ValueError + on an invalid --where escaped uncaught out of every edit command. + """ + + def test_set_widget_bad_where_emits_envelope(self, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["set-widget", str(path), "3.seed", "5", "--where", "clowd"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "where_invalid" diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 2357f3b46..1a84792f5 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -297,6 +297,85 @@ def test_unknown_node_returns_empty(self, graph: Graph): assert order == [] +class TestWidgetOrderForNode: + """graph.widget_order_for_node — dynamic combos expand by the node's ACTUAL + selected key, not the schema's first key (regression for set-widget writing + into the wrong slot when the selection expands to a different sub-widget count).""" + + @staticmethod + def _dyn_graph() -> Graph: + # `model` is a dynamic combo: key "a" → 1 sub-widget, key "b" → 2. `seed` + # follows it, so its slot index depends on which key is selected. + return Graph.from_object_info( + { + "DynNode": { + "input": { + "required": { + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "a", + "inputs": { + "required": {"res": ["COMBO", {"options": ["x", "y"], "default": "x"}]} + }, + }, + { + "key": "b", + "inputs": { + "required": { + "res": ["COMBO", {"options": ["x", "y"], "default": "x"}], + "quality": ["COMBO", {"options": ["lo", "hi"], "default": "lo"}], + } + }, + }, + ] + }, + ], + "seed": ["INT", {"default": 0}], + } + }, + "input_order": {"required": ["model", "seed"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "test", + "display_name": "Dyn", + "python_module": "nodes", + } + } + ) + + def test_static_order_uses_first_key(self): + g = self._dyn_graph() + assert g.widget_order("DynNode") == ["model", "model.res", "seed"] + + def test_node_order_expands_selected_key(self): + g = self._dyn_graph() + # Selecting "b" adds model.quality, pushing seed to index 3. + order = g.widget_order_for_node("DynNode", ["b", "x", "hi", 12345]) + assert order == ["model", "model.res", "model.quality", "seed"] + assert order.index("seed") == 3 + + def test_node_order_first_key_matches_static(self): + g = self._dyn_graph() + assert g.widget_order_for_node("DynNode", ["a", "x", 999]) == ["model", "model.res", "seed"] + + def test_empty_widgets_falls_back_to_static(self): + g = self._dyn_graph() + assert g.widget_order_for_node("DynNode", []) == g.widget_order("DynNode") + + def test_set_widget_writes_seed_to_selected_slot(self): + """End-to-end: set-widget on a "b"-selected node must land seed at index 3, + not overwrite model.quality at index 2.""" + from comfy_cli import workflow_ops + + g = self._dyn_graph() + wf = {"nodes": [{"id": 3, "type": "DynNode", "widgets_values": ["b", "x", "hi", 111]}]} + workflow_ops.set_widget(wf, g, 3, "seed", 424242, actor="cli", base_version=0) + assert wf["nodes"][0]["widgets_values"] == ["b", "x", "hi", 424242] + + # =========================================================================== # TestTraversal # =========================================================================== @@ -1305,6 +1384,7 @@ class TestComboNormalizationAndSuggestions: def _port(self): from comfy_cli.cql.engine import Port + return Port( name="ckpt_name", type="COMBO", @@ -1333,6 +1413,7 @@ def test_canonical_unknown_returns_none(self): def test_canonical_ambiguous_basename_returns_none(self): from comfy_cli.cql.engine import Port + p = Port(name="ckpt_name", type="COMBO", enum_values=["a/dup.safetensors", "b/dup.safetensors"]) assert p.canonical_combo("dup.safetensors") is None # two matches → don't guess diff --git a/tests/comfy_cli/cql/test_loader_ttl.py b/tests/comfy_cli/cql/test_loader_ttl.py index 46deccb42..9e0d63e05 100644 --- a/tests/comfy_cli/cql/test_loader_ttl.py +++ b/tests/comfy_cli/cql/test_loader_ttl.py @@ -173,6 +173,30 @@ def _live(**kw): assert loader.read_object_info_cache(LOCAL_KEY) == LIVE +def test_local_never_serves_fresh_cache(monkeypatch): + """Cache-first TTL is cloud-only: a fresh LOCAL entry is NOT served, so a + node just installed into the user's own server is visible immediately. The + live fetch still runs (and rewrites the cache for the failure fallback).""" + import comfy_cli.cql.engine as engine + + _pin_host_key(monkeypatch, LOCAL_KEY) + loader.write_object_info_cache(LOCAL_KEY, CACHED) # fresh local entry + + calls = {"n": 0} + + def _live(**kw): + calls["n"] += 1 + return LIVE + + monkeypatch.setattr(engine, "_load_from_target", _live) + + result = loader.resilient_load_object_info(mode="local", host="127.0.0.1", port=8188) + + assert result == LIVE # live, not the fresh cache + assert calls["n"] == 1 + assert loader.read_object_info_cache(LOCAL_KEY) == LIVE # cache rewritten for failure fallback + + # --------------------------------------------------------------------------- # expired entry + fetch failure → stale fallback still works # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/test_workflow_to_api.py b/tests/comfy_cli/test_workflow_to_api.py index 3637d1de2..8911af31c 100644 --- a/tests/comfy_cli/test_workflow_to_api.py +++ b/tests/comfy_cli/test_workflow_to_api.py @@ -1254,6 +1254,20 @@ class TestImplicitSeedCompanion: "output_node": True, "display_name": "RegularInt", }, + # A seed-substring INT (unflagged, no real companion) immediately followed + # by a COMBO that legitimately lists a control keyword among its options. + "SeedThenCombo": { + "input": { + "required": { + "variation_seed": ["INT", {"default": 0}], + "mode": [["fixed", "auto", "manual"], {}], + "strength": ["FLOAT", {"default": 1.0}], + } + }, + "input_order": {"required": ["variation_seed", "mode", "strength"]}, + "output_node": True, + "display_name": "SeedThenCombo", + }, } def test_seed_named_input_strips_implicit_companion(self): @@ -1332,6 +1346,28 @@ def test_regular_int_input_does_not_strip_control_value(self): assert result["1"]["inputs"]["value"] == 99 assert result["1"]["inputs"]["label"] == "randomize" + def test_seed_does_not_steal_next_combos_control_keyword_value(self): + # `variation_seed` is a seed-substring INT with NO real companion; the + # NEXT widget is a COMBO whose legitimate saved value is "fixed" (one of + # its own options). The converter must keep "fixed" as the combo's value + # rather than consuming it as a phantom control_after_generate marker + # (which would drop it and shift `strength` into the wrong slot). + workflow = { + "nodes": [ + { + "id": 1, + "type": "SeedThenCombo", + "inputs": [], + "outputs": [], + "widgets_values": [7, "fixed", 0.5], + "mode": 0, + } + ], + "links": [], + } + result = convert_ui_to_api(workflow, self.OI) + assert result["1"]["inputs"] == {"variation_seed": 7, "mode": "fixed", "strength": 0.5} + class TestNodeNameForSAndRAlias: """When a node carries ``properties["Node name for S&R"]`` pointing at a From e24be64d51c5f4ce1cde67446981fc46a2d3ff74 Mon Sep 17 00:00:00 2001 From: kishore Date: Tue, 14 Jul 2026 00:15:07 -0700 Subject: [PATCH 03/53] chore: ruff 0.15.15 format (match CI pin) CI pins ruff==0.15.15, which line-wraps a few long signatures/comprehensions differently than the locally-installed 0.15.12. Purely cosmetic; no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) --- comfy_cli/command/assets_library.py | 6 ++-- comfy_cli/command/workflow_edit.py | 28 ++++++++++++++----- tests/comfy_cli/command/test_run.py | 4 ++- .../command/test_workflow_edit_cloud.py | 4 ++- tests/comfy_cli/cql/test_object_info_env.py | 7 +++-- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/comfy_cli/command/assets_library.py b/comfy_cli/command/assets_library.py index 3c1f7be62..233b2c86c 100644 --- a/comfy_cli/command/assets_library.py +++ b/comfy_cli/command/assets_library.py @@ -33,7 +33,9 @@ def ls_cmd( ] = None, tags: Annotated[ str | None, - typer.Option("--tags", show_default=False, help="Comma-separated tags; assets must have ALL of them (e.g. input,output)."), + typer.Option( + "--tags", show_default=False, help="Comma-separated tags; assets must have ALL of them (e.g. input,output)." + ), ] = None, limit: Annotated[int, typer.Option("--limit", help="Cap rows returned (max 500).")] = 20, where: Annotated[str | None, typer.Option("--where", show_default=False)] = None, @@ -47,7 +49,7 @@ def ls_cmd( params: list[tuple[str, Any]] = [("limit", min(max(limit, 1), 500))] if name: params.append(("name_contains", name)) - for t in (tags.split(",") if tags else []): + for t in tags.split(",") if tags else []: t = t.strip() if t: params.append(("include_tags", t)) diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index 8aa476580..038ebd0fc 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -93,7 +93,9 @@ def add_node_cmd( input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, + where: Annotated[ + str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") + ] = None, ): renderer = get_renderer() renderer.command = "workflow add-node" @@ -132,7 +134,9 @@ def set_widget_cmd( input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, + where: Annotated[ + str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") + ] = None, ): renderer = get_renderer() renderer.command = "workflow set-widget" @@ -174,7 +178,9 @@ def connect_cmd( input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, + where: Annotated[ + str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") + ] = None, ): renderer = get_renderer() renderer.command = "workflow connect" @@ -207,7 +213,9 @@ def delete_cmd( input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, + where: Annotated[ + str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") + ] = None, ): renderer = get_renderer() renderer.command = "workflow delete-node" @@ -283,7 +291,9 @@ def capture_cmd( input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, + where: Annotated[ + str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") + ] = None, ): """Project a workflow into a reusable recipe — the op-batch that rebuilds it. `apply` that recipe onto an empty graph to reproduce the workflow; edit a value @@ -356,7 +366,9 @@ def apply_cmd( input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, + where: Annotated[ + str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") + ] = None, ): """Apply a batch of edits in one pass — the catalog loads once, and an `add_node` spec may set `"as": ""` so later specs reference the @@ -473,7 +485,9 @@ def foreach_cmd( input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] = None, + where: Annotated[ + str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") + ] = None, ): """Instantiate a recipe over N param-sets → N ready-to-run workflows (bulk). Run them with `comfy run --workflow --where cloud`.""" diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index 7ea95eac6..b42e6f70a 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -1059,7 +1059,9 @@ def test_ui_workflow_converts_and_submits(self, ui_workflow_file, fake_target): submitted_args, _ = mock_client.submit_prompt.call_args assert submitted_args[0] == self.CONVERTED - def test_ui_workflow_conversion_honors_object_info_file_env(self, ui_workflow_file, fake_target, tmp_path, monkeypatch): + def test_ui_workflow_conversion_honors_object_info_file_env( + self, ui_workflow_file, fake_target, tmp_path, monkeypatch + ): """Both cloud object_info loads on this path (UI→API conversion, then preflight-validate) are routed through resilient_load_object_info, so COMFY_OBJECT_INFO_FILE — a pre-warmed/baked catalog an agent host diff --git a/tests/comfy_cli/command/test_workflow_edit_cloud.py b/tests/comfy_cli/command/test_workflow_edit_cloud.py index 4ac177491..c36b8a532 100644 --- a/tests/comfy_cli/command/test_workflow_edit_cloud.py +++ b/tests/comfy_cli/command/test_workflow_edit_cloud.py @@ -121,7 +121,9 @@ def test_build_txt2img_against_live_cloud_catalog(): assert api[str(ids["ckpt"])]["inputs"]["ckpt_name"] is not None -@pytest.mark.skipif(not os.environ.get("COMFY_CLOUD_E2E_RUN"), reason="submit spends credits: set COMFY_CLOUD_E2E_RUN=1") +@pytest.mark.skipif( + not os.environ.get("COMFY_CLOUD_E2E_RUN"), reason="submit spends credits: set COMFY_CLOUD_E2E_RUN=1" +) def test_submit_built_graph_to_cloud(tmp_path): """RED→GREEN (credit-gated): a primitive-built graph submits to cloud and returns a prompt_id. Runs the real `comfy run --where cloud`.""" diff --git a/tests/comfy_cli/cql/test_object_info_env.py b/tests/comfy_cli/cql/test_object_info_env.py index d1adea0c7..fda1ae65a 100644 --- a/tests/comfy_cli/cql/test_object_info_env.py +++ b/tests/comfy_cli/cql/test_object_info_env.py @@ -22,8 +22,11 @@ def fake_load(p): monkeypatch.setattr(engine, "_load_from_file", fake_load) # The network path must NOT run when the env dump is set. monkeypatch.setattr( - engine, "_load_from_target", - lambda **_: (_ for _ in ()).throw(AssertionError("network fetch should not run with COMFY_OBJECT_INFO_FILE set")), + engine, + "_load_from_target", + lambda **_: (_ for _ in ()).throw( + AssertionError("network fetch should not run with COMFY_OBJECT_INFO_FILE set") + ), ) monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(dump)) From d8d76b67b578b8ec09b516c50c91adf966fb4f60 Mon Sep 17 00:00:00 2001 From: kishore Date: Tue, 14 Jul 2026 00:19:50 -0700 Subject: [PATCH 04/53] fix(security): derive subgraph fork id with SHA-256, not SHA-1 CodeQL (py/weak-sensitive-data-hashing) flags SHA-1 hashing of an id. The fork id is a deterministic derivation, not a security boundary, but SHA-256 is an equally deterministic drop-in and clears the alert. Co-Authored-By: Claude Opus 4.8 (1M context) --- comfy_cli/cql/engine.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 7c014606c..c3c417af6 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -1702,9 +1702,11 @@ def _isolate_shared_subgraph(workflow: dict, instance: dict, defs_by_id: dict[st def _deterministic_fork_id(def_id: str, instance_id: Any) -> str: """A stable id for the isolated copy of ``def_id`` owned by ``instance_id``. Deterministic across processes (``hashlib``, not the salted builtin ``hash``) - so replaying the same op anywhere yields the same id.""" + so replaying the same op anywhere yields the same id. SHA-256 (not SHA-1) — + this isn't a security boundary, but there's no reason to reach for a broken + hash, and it keeps the scanners quiet.""" seed = f"{def_id}\x00{instance_id}".encode() - return "sg-" + _hashlib.sha1(seed).hexdigest()[:32] + return "sg-" + _hashlib.sha256(seed).hexdigest()[:32] def _suggest_slots_for_input(workflow: dict, input_name: str, graph: Graph, *, limit: int = 6) -> list[str]: From 6513fa2f62ef3077fcf34ec360ae386f6ec7d1ee Mon Sep 17 00:00:00 2001 From: kishore Date: Tue, 14 Jul 2026 00:23:45 -0700 Subject: [PATCH 05/53] fix(error-codes): register the normalized_value warning code test_every_raised_code_is_registered failed: workflow_ops.py raises the normalized_value warning code (nearest-COMBO match in set-widget) but it was never added to error_codes.REGISTRY. This was already red on the feature commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- comfy_cli/error_codes.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index 6d1e58ea3..6b31f0144 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -418,6 +418,12 @@ class ErrorCode: "unknown class_type, missing node, bad slot/widget name, or malformed address.", "run `comfy workflow slots ` for widget addresses or `comfy nodes types` for class_types", ), + ErrorCode( + "normalized_value", + "Warning (not fatal): a set-widget value wasn't an exact COMBO option, so " + "the nearest matching option was used. Surfaced in the op's `warnings`.", + "see the warning's `from`/`to`; pass an exact option to avoid the fuzzy match", + ), # --- workflow fragments / compose --------------------------------------- ErrorCode( "fragment_invalid", From 0c37bd0f8749c6fdb3b8d6180869853ae918e834 Mon Sep 17 00:00:00 2001 From: kishore Date: Tue, 14 Jul 2026 00:55:54 -0700 Subject: [PATCH 06/53] fix(run,workflow): telemetry cloud lifecycle + address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry (comfy run): - Successful CLOUD submissions never emitted execution_success: the cloud branch `return`ed after execute_cloud, skipping the try's `else`. Fall through instead so cloud matches local (execution_start → execution_success). Local unaffected. - Record the RESOLVED routing target (cloud|local) on the execution events so a submission is attributable even when --where was defaulted (PostHog already tags source=cli; this adds which backend the workflow was submitted to). CodeRabbit findings: - apply_specs: reject a duplicate `as` alias instead of silently clobbering the earlier node; wrap a missing-field KeyError as "spec #i (op) is missing ". - foreach: surface files already written (hint + details.written) on a mid-batch failure instead of leaving the caller blind to partial output. - workflow_edit: extract the 7-way-duplicated Annotated option boilerplate (--where/--input/host/port/--actor/--base-version/--stdout) into shared aliases. - SKILL.md: fix contradictory guidance — set-widget DOES resolve subgraph values (flat 57.text / nested 57/27.text); stop sending agents to set-slot/decompose. Regression tests added for each behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) --- comfy_cli/cmdline.py | 54 +++++--- comfy_cli/command/workflow_edit.py | 131 ++++++++++-------- comfy_cli/skills/comfy/SKILL.md | 6 +- comfy_cli/workflow_ops.py | 64 +++++---- tests/comfy_cli/command/test_workflow_edit.py | 41 ++++++ .../comfy_cli/test_run_execution_lifecycle.py | 38 +++++ 6 files changed, 223 insertions(+), 111 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 9f21d03de..3f9db0bab 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -835,6 +835,11 @@ def run( renderer.error(code="where_invalid", message=str(e), hint="use --where local or --where cloud") raise typer.Exit(code=1) + # Record the RESOLVED routing target so submission analytics can tell a + # cloud run from a local one even when --where was defaulted (the raw + # `where` kwarg is None then). Rides on the execution_success/_error events. + _track_props["target"] = "cloud" if decision.target is where_module.WhereTarget.CLOUD else "local" + # Default for --notify: on when a human is at the terminal, off for # agents (they shouldn't get surprise side-channel processes they didn't # ask for). The user can override either way with --notify/--no-notify. @@ -873,6 +878,10 @@ def run( if decision.target is where_module.WhereTarget.CLOUD: where_module.cloud_preflight_or_exit() # Cloud path uses HTTPS + Bearer auth; host/port aren't applicable. + # NOTE: do NOT `return` here — falling through to the try's `else` + # is what fires `execution_success`. An early return skipped it, so + # successful cloud submissions emitted `execution_start` but never + # `execution_success` (local runs were unaffected). run_inner.execute_cloud( workflow, wait=wait, @@ -883,29 +892,28 @@ def run( workflow_id=workflow_id, preloaded=preloaded, ) - return - - from comfy_cli.host_port import parse_host_port_arg, resolve_host_port - - if host: - host, parsed_port = parse_host_port_arg(host) - if not port and parsed_port is not None: - port = parsed_port - - host, port = resolve_host_port(host, port) - - run_inner.execute( - workflow, - host, - port, - wait=wait, - verbose=verbose, - timeout=timeout, - notify=effective_notify, - api_key=api_key, - print_prompt=print_prompt, - preloaded=preloaded, - ) + else: + from comfy_cli.host_port import parse_host_port_arg, resolve_host_port + + if host: + host, parsed_port = parse_host_port_arg(host) + if not port and parsed_port is not None: + port = parsed_port + + host, port = resolve_host_port(host, port) + + run_inner.execute( + workflow, + host, + port, + wait=wait, + verbose=verbose, + timeout=timeout, + notify=effective_notify, + api_key=api_key, + print_prompt=print_prompt, + preloaded=preloaded, + ) except typer.Exit as e: if (e.exit_code or 0) == 0: tracking.track_event("execution_success", _track_props) diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index 038ebd0fc..b0addf4ef 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -27,6 +27,18 @@ ) from comfy_cli.output import get_renderer, rprint +# Shared option aliases — the edit commands (add-node/set-widget/connect/ +# delete-node/capture/apply/foreach) all take the same catalog + CRDT-stamping +# options. Declaring each once here means a help string or default can't drift +# between the 7 near-identical signatures. +ActorOpt = Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] +BaseVersionOpt = Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] +StdoutOpt = Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] +InputOpt = Annotated[str | None, typer.Option("--input", show_default=False)] +HostOpt = Annotated[str | None, typer.Option(show_default=False)] +PortOpt = Annotated[int | None, typer.Option(show_default=False)] +WhereOpt = Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] + def _split_addr(addr: str, renderer) -> tuple[Any, str]: """Split ``.`` → (node_id, name). node_id is int when numeric.""" @@ -87,15 +99,13 @@ def add_node_cmd( str | None, typer.Option("--at", show_default=False, help="Canvas position 'x,y' for the new node."), ] = None, - actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", - base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, - stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, - input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, - host: Annotated[str | None, typer.Option(show_default=False)] = None, - port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[ - str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") - ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, ): renderer = get_renderer() renderer.command = "workflow add-node" @@ -128,15 +138,13 @@ def set_widget_cmd( file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], addr: Annotated[str, typer.Argument(help="Widget address `.`.")], value: Annotated[str, typer.Argument(help="New value (parsed as JSON, else literal string).")], - actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", - base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, - stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, - input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, - host: Annotated[str | None, typer.Option(show_default=False)] = None, - port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[ - str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") - ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, ): renderer = get_renderer() renderer.command = "workflow set-widget" @@ -172,15 +180,13 @@ def connect_cmd( file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], source: Annotated[str, typer.Argument(help="Source `.` (slot name or index).")], target: Annotated[str, typer.Argument(help="Target `.` (slot name or index).")], - actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", - base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, - stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, - input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, - host: Annotated[str | None, typer.Option(show_default=False)] = None, - port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[ - str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") - ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, ): renderer = get_renderer() renderer.command = "workflow connect" @@ -207,15 +213,13 @@ def connect_cmd( def delete_cmd( file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], node: Annotated[str, typer.Argument(help="Node id to delete.")], - actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", - base_version: Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] = 0, - stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, - input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, - host: Annotated[str | None, typer.Option(show_default=False)] = None, - port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[ - str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") - ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, ): renderer = get_renderer() renderer.command = "workflow delete-node" @@ -288,12 +292,10 @@ def capture_cmd( str | None, typer.Option("--out", "-o", show_default=False, help="Write the recipe JSON here (else stdout)."), ] = None, - input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, - host: Annotated[str | None, typer.Option(show_default=False)] = None, - port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[ - str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") - ] = None, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, ): """Project a workflow into a reusable recipe — the op-batch that rebuilds it. `apply` that recipe onto an empty graph to reproduce the workflow; edit a value @@ -360,15 +362,13 @@ def apply_cmd( list[str] | None, typer.Option("--param", show_default=False, help="Recipe param as key=value; repeatable."), ] = None, - actor: Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] = "cli", - base_version: Annotated[int, typer.Option("--base-version", help="Draft version this batch is based on.")] = 0, - stdout: Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] = False, - input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, - host: Annotated[str | None, typer.Option(show_default=False)] = None, - port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[ - str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") - ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, ): """Apply a batch of edits in one pass — the catalog loads once, and an `add_node` spec may set `"as": ""` so later specs reference the @@ -480,14 +480,12 @@ def foreach_cmd( typer.Option("--params", help="Param-sets: a JSON array of objects, one object, or JSONL; '-' for stdin."), ], out_dir: Annotated[str, typer.Option("--out-dir", help="Directory to write the N materialized workflows.")], - actor: Annotated[str, typer.Option("--actor")] = "cli", - base_version: Annotated[int, typer.Option("--base-version")] = 0, - input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, - host: Annotated[str | None, typer.Option(show_default=False)] = None, - port: Annotated[int | None, typer.Option(show_default=False)] = None, - where: Annotated[ - str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.") - ] = None, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, ): """Instantiate a recipe over N param-sets → N ready-to-run workflows (bulk). Run them with `comfy run --workflow --where cloud`.""" @@ -531,7 +529,20 @@ def foreach_cmd( _atomic_write_text(target, json.dumps(wf, indent=2)) written.append(str(target)) except (workflow_ops.RecipeError, ValueError, KeyError) as e: - renderer.error(code="workflow_edit_invalid", message=f"foreach failed: {e}") + # foreach writes one file per param-set as it goes, so a mid-batch failure + # leaves the earlier files on disk. Surface them (in the hint AND machine- + # readable details) so the caller isn't blind to the partial output. + renderer.error( + code="workflow_edit_invalid", + message=f"foreach failed: {e}", + hint=( + f"{len(written)} workflow(s) were written to {out} before the failure — " + "delete them or fix the failing param-set and re-run" + ) + if written + else None, + details={"written": written} if written else None, + ) raise typer.Exit(code=1) from e payload = {"recipe": name, "count": len(written), "out_dir": str(out), "written": written} diff --git a/comfy_cli/skills/comfy/SKILL.md b/comfy_cli/skills/comfy/SKILL.md index a65f84dff..3e0e6257f 100644 --- a/comfy_cli/skills/comfy/SKILL.md +++ b/comfy_cli/skills/comfy/SKILL.md @@ -396,8 +396,10 @@ comfy --json download --out-dir ./out < run.json # pull the in place. - **`delete-node` ≠ `delete`:** `delete-node` removes a *node from the graph file*; `comfy workflow delete` deletes a *saved workflow from Comfy Cloud*. Do not confuse them. -- These operate on **top-level** nodes of frontend-format graphs. For values - *inside a subgraph*, use `set-slot`'s nested address (`10/9.prompt`) or decompose. +- `add-node`/`connect`/`delete-node` operate on **top-level** nodes only. + `set-widget` additionally resolves values **inside a subgraph** directly — use + the flat promoted address `slots` advertises (e.g. `57.text`) or the nested + form (`57/27.text`); no decompose needed. --- diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 804bb7d84..808b3d435 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -714,32 +714,44 @@ def apply_specs( if not isinstance(spec, dict) or "op" not in spec: raise ValueError(f"spec #{i} must be an object with an 'op' field") kind = spec["op"] - if kind == "add_node": - workflow, op = add_node( - workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version - ) - if spec.get("as"): - aliases[spec["as"]] = op["node_id"] - elif kind == "connect": - fn, fs = _split_ref_slot(spec["from"], aliases) - tn, ts = _split_ref_slot(spec["to"], aliases) - workflow, op = connect(workflow, graph, fn, fs, tn, ts, actor=actor, base_version=base_version) - elif kind == "set_widget": - workflow, op = set_widget( - workflow, - graph, - resolve_ref(spec["node"], aliases), - spec["widget"], - spec["value"], - actor=actor, - base_version=base_version, - ) - elif kind == "delete_node": - workflow, op = delete_node( - workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version - ) - else: - raise ValueError(f"spec #{i}: unknown op {kind!r}") + # A missing required field surfaces as a bare KeyError (just the key name); + # wrap it so the batch/recipe caller learns WHICH spec and op are malformed. + try: + if kind == "add_node": + workflow, op = add_node( + workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version + ) + alias = spec.get("as") + if alias: + # A duplicate alias would silently clobber the earlier node, so a + # later `${alias}` reference resolves to the wrong node. Recipes + # are generated/templated, so an accidental repeat is plausible — + # fail loudly instead. + if alias in aliases: + raise ValueError(f"spec #{i}: alias {alias!r} is already defined by an earlier spec") + aliases[alias] = op["node_id"] + elif kind == "connect": + fn, fs = _split_ref_slot(spec["from"], aliases) + tn, ts = _split_ref_slot(spec["to"], aliases) + workflow, op = connect(workflow, graph, fn, fs, tn, ts, actor=actor, base_version=base_version) + elif kind == "set_widget": + workflow, op = set_widget( + workflow, + graph, + resolve_ref(spec["node"], aliases), + spec["widget"], + spec["value"], + actor=actor, + base_version=base_version, + ) + elif kind == "delete_node": + workflow, op = delete_node( + workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version + ) + else: + raise ValueError(f"spec #{i}: unknown op {kind!r}") + except KeyError as e: + raise ValueError(f"spec #{i} ({kind}) is missing required field {e}") from e ops.append(op) return workflow, ops, aliases diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index f9ed40981..014774e6d 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -858,6 +858,32 @@ def test_batch_is_atomic_on_failure(self, patched_graph, tmp_path, capsys): assert env["error"]["code"] == "workflow_edit_invalid" assert path.read_text() == before, "failed batch must not write a partial graph" + def test_duplicate_alias_is_rejected(self, patched_graph, tmp_path, capsys): + """A repeated `as` name would silently clobber the earlier node — reject it.""" + path = self._empty(tmp_path) + before = path.read_text() + specs = [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, # duplicate alias + ] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert "ks" in env["error"]["message"] and "already defined" in env["error"]["message"] + assert path.read_text() == before + + def test_missing_field_names_the_spec(self, patched_graph, tmp_path, capsys): + """A bare KeyError becomes an actionable `spec #i (...) is missing ...`.""" + path = self._empty(tmp_path) + specs = [{"op": "add_node", "class_type": "KSampler", "as": "ks"}, {"op": "set_widget", "node": "ks"}] + ops_path = tmp_path / "ops.json" + ops_path.write_text(json.dumps(specs), encoding="utf-8") + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is False + assert "spec #1" in env["error"]["message"] and "missing required field" in env["error"]["message"] + # --------------------------------------------------------------------------- # dynamic combo (COMFY_DYNAMICCOMBO_V3) — set_widget on model + model.resolution @@ -1034,6 +1060,21 @@ def test_foreach_bad_param_set_fails(self, patched_graph, tmp_path, capsys): assert env["ok"] is False assert "positive" in env["error"]["message"] + def test_foreach_surfaces_partial_writes_on_mid_batch_failure(self, patched_graph, tmp_path, capsys): + """foreach writes per param-set; a mid-batch failure leaves earlier files + on disk, so the error must surface them (not leave the caller blind).""" + rp = self._recipe(tmp_path) + params = tmp_path / "sets.jsonl" + # #0 valid → written; #1 missing required `positive` → fails. + params.write_text('{"positive":"a cat"}\n{"steps":10}\n', encoding="utf-8") + out = tmp_path / "out" + env = _run(["foreach", str(rp), "--params", str(params), "--out-dir", str(out)], capsys) + assert env["ok"] is False + written = env["error"]["details"]["written"] + assert len(written) == 1 and written[0].endswith("_000.json") + assert list(out.glob("*.json")) # the partial file really is on disk + assert "before the failure" in env["error"]["hint"] + # --------------------------------------------------------------------------- # capture — project a graph into a recipe; round-trips through apply diff --git a/tests/comfy_cli/test_run_execution_lifecycle.py b/tests/comfy_cli/test_run_execution_lifecycle.py index fb5779cf6..4b43a9b03 100644 --- a/tests/comfy_cli/test_run_execution_lifecycle.py +++ b/tests/comfy_cli/test_run_execution_lifecycle.py @@ -157,3 +157,41 @@ def test_typer_exit_0_is_treated_as_success(self, runner, tracked_run): assert result.exit_code == 0 assert _event_names(tracked_run) == ["execution_start", "execution_success"] + + +class TestRunCloudLifecycle: + """Cloud submissions must emit the SAME start→success lifecycle as local. + + Regression: the cloud branch `return`ed after `execute_cloud`, skipping the + try's `else` — so a successful cloud run fired `execution_start` but never + `execution_success`. Local runs (which fall through) were unaffected. + """ + + def test_cloud_success_emits_start_then_success(self, runner, tracked_run, monkeypatch): + from comfy_cli.cmdline import app + + monkeypatch.setattr("comfy_cli.cmdline.where_module.cloud_preflight_or_exit", lambda: None) + with patch("comfy_cli.cmdline.run_inner.execute_cloud") as mock_cloud: + mock_cloud.return_value = None + result = runner.invoke(app, ["run", "--workflow", "wf.json", "--where", "cloud"]) + + assert result.exit_code == 0, f"stdout={result.output!r} exc={result.exception!r}" + assert _event_names(tracked_run) == ["execution_start", "execution_success"] + mock_cloud.assert_called_once() + + def test_resolved_target_rides_on_terminal_events(self, runner, tracked_run, monkeypatch): + """The resolved routing target (cloud vs local) is recorded so a + submission is attributable even when --where was defaulted.""" + from comfy_cli.cmdline import app + + monkeypatch.setattr("comfy_cli.cmdline.where_module.cloud_preflight_or_exit", lambda: None) + with patch("comfy_cli.cmdline.run_inner.execute_cloud"): + runner.invoke(app, ["run", "--workflow", "wf.json", "--where", "cloud"]) + cloud_success = next(props for name, props in _events(tracked_run) if name == "execution_success") + assert cloud_success.get("target") == "cloud" + + tracked_run.clear() + with patch("comfy_cli.cmdline.run_inner.execute"): + runner.invoke(app, ["run", "--workflow", "wf.json", "--where", "local"]) + local_success = next(props for name, props in _events(tracked_run) if name == "execution_success") + assert local_success.get("target") == "local" From 1fde5087ff7a9f1c2279105d9de4273774c8c40a Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 23 Jul 2026 00:51:39 -0700 Subject: [PATCH 07/53] fix(workflow): reject malformed autogrow connect targets instead of growing a bad slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connect that targets a COMFY_AUTOGROW input by a dotted key only ever wired cleanly when that key was the exact next sequential slot (images.imageN). An index gap (images.image2), a doubled prefix (images.images.image0), or a stray element (images.foo) was silently grown into a bogus input that passed connect AND validate but failed only at submit time — the top agent workflow-edit failure class in prod. _resolve_input_target now accepts a dotted autogrow target ONLY when it names the canonical next slot; anything else raises the same workflow_edit_invalid the rest of the connect path uses, quoting the base to auto-append and the exact next free key. The bare base still auto-appends. _plan_autogrow drops its now-unused `requested` param (the caller validates before growing). Tests: TestConnect.test_autogrow_rejects_malformed_slot_targets (gap / double prefix / stray element / trailing dot -> workflow_edit_invalid, nothing minted) and test_autogrow_accepts_the_exact_next_slot_key. Co-Authored-By: Claude Opus 4.8 --- comfy_cli/workflow_ops.py | 28 ++++++++++----- tests/comfy_cli/command/test_workflow_edit.py | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 808b3d435..11b710168 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -1122,8 +1122,20 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - (i for i in ins if i.get("name") == base and str(i.get("type", "")).startswith("COMFY_AUTOGROW")), None ) if ag is not None: - requested = slot if "." in slot else None - return None, _plan_autogrow(ins, base, elem_type, requested=requested) + grow = _plan_autogrow(ins, base, elem_type) # canonical next sequential slot + # Addressing the bare base auto-appends. A dotted key is accepted ONLY if + # it names that exact next slot; an index gap (images.image4), a doubled + # prefix (images.images.image0), or a stray element (images.foo) would mint + # a key the server can't map — reject it with the fix instead of growing it. + if "." in slot and slot != grow["name"]: + grown = [i.get("name") for i in ins if str(i.get("name", "")).startswith(base + ".")] + raise ValueError( + f"input {slot!r} is not a valid autogrow slot on node {node.get('id')}; " + f"autogrow input {base!r} appends one sequential slot per connection " + f"(existing: {grown}) — connect to the base {base!r} to auto-append, " + f"or use the next free key {grow['name']!r}" + ) + return None, grow # Widget-backed input: convert the widget to a linked input. if graph is not None and isinstance(slot, str) and slot in graph.widget_order(node.get("type", "")): return None, {"name": slot, "type": elem_type or "*", "widget": slot} @@ -1131,11 +1143,11 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") -def _plan_autogrow(ins: list, base: str, elem_type: str | None, requested: str | None = None) -> dict: +def _plan_autogrow(ins: list, base: str, elem_type: str | None) -> dict: + """The canonical next autogrow slot for ``base`` — one sequential + ``{base}.{elem}{N}`` per existing slot, minted with the source ``elem_type``. + Callers validate any explicitly requested key against this name before growing.""" existing = [i for i in ins if str(i.get("name", "")).startswith(base + ".")] - if requested and not any(i.get("name") == requested for i in ins): - name = requested - else: - elem = base[:-1] if base.endswith("s") else base - name = f"{base}.{elem}{len(existing)}" + elem = base[:-1] if base.endswith("s") else base + name = f"{base}.{elem}{len(existing)}" return {"name": name, "type": elem_type or "*"} diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 014774e6d..962af2885 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -683,6 +683,40 @@ def test_autogrow_input_grows_a_slot_per_connection(self, patched_graph, tmp_pat assert {i["name"] for i in grown} == {"images.image0", "images.image1"} assert all(i["link"] is not None and i["type"] == "IMAGE" for i in grown) + def test_autogrow_rejects_malformed_slot_targets(self, patched_graph, tmp_path, capsys): + """A dotted autogrow target that is not the next sequential slot — an index gap + (images.image2), a doubled prefix (images.images.image0), a stray element + (images.foo), or a trailing dot — is rejected with the fix, not silently grown + into a key the server cannot map. Regression: prod connects mis-addressed + autogrow slots and the CLI grew bogus inputs that failed only at submit time.""" + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + src = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + batch = _run(["add-node", str(path), "BatchImagesNode"], capsys)["data"]["op"]["node_id"] + + for bad in ("images.image2", "images.images.image0", "images.foo", "images."): + env = _run(["connect", str(path), f"{src}.IMAGE", f"{batch}.{bad}"], capsys) + assert env["ok"] is False, (bad, env) + assert env["error"]["code"] == "workflow_edit_invalid", (bad, env) + # Actionable: names the base and the exact next free key to use instead. + assert "images.image0" in env["error"]["message"], (bad, env) + # And the bogus target is never minted onto the node. + bn = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == batch) + assert not any(i["name"] == bad for i in bn["inputs"]), (bad, bn["inputs"]) + + def test_autogrow_accepts_the_exact_next_slot_key(self, patched_graph, tmp_path, capsys): + """The bare base auto-appends, and the EXACT next sequential key is also accepted, + so an agent addressing images.image0 then images.image1 still wires cleanly.""" + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + a = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + b = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + batch = _run(["add-node", str(path), "BatchImagesNode"], capsys)["data"]["op"]["node_id"] + e1 = _run(["connect", str(path), f"{a}.IMAGE", f"{batch}.images.image0"], capsys) # exact next + assert e1["ok"] is True, e1 + assert e1["data"]["op"]["grow"]["name"] == "images.image0" + e2 = _run(["connect", str(path), f"{b}.IMAGE", f"{batch}.images.image1"], capsys) # exact next + assert e2["ok"] is True, e2 + assert e2["data"]["op"]["grow"]["name"] == "images.image1" + def test_connect_converts_widget_to_input(self, patched_graph, tmp_path, capsys): """connect onto a widget-backed input (KSampler.cfg) converts it to a link; widgets_values stays intact and the converter uses the link (fps-style wiring).""" From 5b3292df622d3fefe1892873419c15f6c7e361d4 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 03:00:28 -0700 Subject: [PATCH 08/53] fix(workflow): resolve bare autogrow element names (image1) against the base The top alpha workflow-edit failure: agents guess the classic batch-node slot shape (image0/image1) against autogrow bases whose canonical keys are images.imageN, and the generic not-found error never mentioned autogrow. A bare element name now maps onto the dotted key it implies under the same next-sequential rule: the canonical next slot grows, anything else is rejected with the base and the exact next free key. --- comfy_cli/workflow_ops.py | 23 ++++++++++++++ tests/comfy_cli/command/test_workflow_edit.py | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 11b710168..b53a0b457 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -1139,6 +1139,29 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - # Widget-backed input: convert the widget to a linked input. if graph is not None and isinstance(slot, str) and slot in graph.widget_order(node.get("type", "")): return None, {"name": slot, "type": elem_type or "*", "widget": slot} + # Bare autogrow ELEMENT name (`image1` for base `images`) — the guess agents + # make on classic batch nodes, and the top workflow-edit failure in alpha + # traffic. Map it onto the dotted key it implies and hold it to the same + # next-sequential rule as an explicit dotted target: the canonical next slot + # grows; anything else is rejected with the base and the exact next free key, + # instead of the generic not-found that never mentions autogrow at all. + if isinstance(slot, str): + for ag in ins: + base = ag.get("name") + if not base or not str(ag.get("type", "")).startswith("COMFY_AUTOGROW"): + continue + elem = base[:-1] if base.endswith("s") else base + if not re.fullmatch(re.escape(elem) + r"\d+", slot): + continue + grow = _plan_autogrow(ins, base, elem_type) + if f"{base}.{slot}" == grow["name"]: + return None, grow + grown = [i.get("name") for i in ins if str(i.get("name", "")).startswith(base + ".")] + raise ValueError( + f"input {slot!r} addresses autogrow input {base!r} on node {node.get('id')} " + f"but is not the next sequential slot (existing: {grown}) — connect to the " + f"base {base!r} to auto-append, or use the next free key {grow['name']!r}" + ) names = [i.get("name") for i in ins] raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 962af2885..0d26cee8a 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -717,6 +717,36 @@ def test_autogrow_accepts_the_exact_next_slot_key(self, patched_graph, tmp_path, assert e2["ok"] is True, e2 assert e2["data"]["op"]["grow"]["name"] == "images.image1" + def test_autogrow_accepts_bare_element_names(self, patched_graph, tmp_path, capsys): + """A bare element name (`image0`, `image1` — no `images.` prefix) is the guess + agents make on classic batch nodes, and was the top alpha workflow-edit + failure. The canonical next element grows; a non-next element is rejected + with an error that names the base and the exact next free key.""" + path = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + a = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + b = _run(["add-node", str(path), "VAEDecode"], capsys)["data"]["op"]["node_id"] + batch = _run(["add-node", str(path), "BatchImagesNode"], capsys)["data"]["op"]["node_id"] + + # 1-indexed first guess (`image1` on an empty base) is rejected, and the + # error teaches both recovery paths. + env = _run(["connect", str(path), f"{a}.IMAGE", f"{batch}.image1"], capsys) + assert env["ok"] is False, env + assert env["error"]["code"] == "workflow_edit_invalid", env + assert "images.image0" in env["error"]["message"], env + assert "'images'" in env["error"]["message"], env + + # 0-indexed sequential guesses just work. + e0 = _run(["connect", str(path), f"{a}.IMAGE", f"{batch}.image0"], capsys) + assert e0["ok"] is True, e0 + assert e0["data"]["op"]["grow"]["name"] == "images.image0" + e1 = _run(["connect", str(path), f"{b}.IMAGE", f"{batch}.image1"], capsys) + assert e1["ok"] is True, e1 + assert e1["data"]["op"]["grow"]["name"] == "images.image1" + + # A name unrelated to any autogrow element still gets the generic error. + env = _run(["connect", str(path), f"{a}.IMAGE", f"{batch}.frames3"], capsys) + assert env["ok"] is False and "not found" in env["error"]["message"], env + def test_connect_converts_widget_to_input(self, patched_graph, tmp_path, capsys): """connect onto a widget-backed input (KSampler.cfg) converts it to a link; widgets_values stays intact and the converter uses the link (fps-style wiring).""" From 9dea33be17782bfa4025e858695b2420d49e67c1 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 14:28:54 -0700 Subject: [PATCH 09/53] fix(workflow,validate): unify subgraph interior id namespaces across validate and edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI→API lowering flattens subgraph interiors to composite ids (57:3), and `comfy validate` reported errors keyed by them — but the edit surface (slots / set-widget) speaks 57/3, so feeding a validate error's node id back into set-widget dead-ended with "node 57:3 not found in workflow". Live agent trajectories show exactly this loop (set-widget 285:288 → not found, right after validate named 285:288). Two complementary fixes, both fail-closed: - validate: when the input was a canvas graph (i.e. we lowered it and the caller never saw the flattened ids), rewrite each issue's node_id to the editable 57/3 form and keep the raw id as api_node_id for correlating with server node_errors. Already-API input passes through untouched. - set-widget: accept the colon form as an alias for the interior path (unless a literal node carries that id), so ids copied out of run/server node_errors — which stay in the flattened namespace — still resolve. Co-Authored-By: Claude Fable 5 --- comfy_cli/cmdline.py | 15 ++++ comfy_cli/workflow_ops.py | 7 ++ tests/comfy_cli/command/test_workflow_edit.py | 29 ++++++++ tests/comfy_cli/test_validate_lowers_ui.py | 74 +++++++++++++++++++ 4 files changed, 125 insertions(+) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 3f9db0bab..0d1b01014 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -1027,6 +1027,7 @@ def validate( # workflow MUST be lowered to API format FIRST, using the SAME converter # (and the SAME object_info resolution) the `run` path uses, so validate # inspects exactly what the server would execute. + lowered = False if not is_api_format(wf_data): try: wf_data = convert_ui_to_api(wf_data, object_info) @@ -1037,9 +1038,23 @@ def validate( hint="check that every node's required inputs are connected", ) raise typer.Exit(code=1) from e + lowered = True result = graph.validate_workflow(wf_data) + # When the caller handed us a CANVAS graph, they have never seen the + # flattened ids the lowering mints for subgraph interiors (`57:3`) — their + # edit surface (slots / set-widget) speaks `57/3`. Key every issue by the + # editable address so a validate error can be acted on directly; keep the + # raw API id alongside for anyone correlating with server node_errors. An + # already-API input skips this: its ids address the document as given. + if lowered: + for issue in (*result["errors"], *result["warnings"]): + nid = str(issue.get("node_id", "")) + if ":" in nid: + issue["api_node_id"] = nid + issue["node_id"] = nid.replace(":", "/") + payload = { "workflow": str(wf_path), "valid": result["valid"], diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index b53a0b457..321fbd3eb 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -351,6 +351,13 @@ def _subgraph_write_target(workflow: dict, node_id: Any, widget: str) -> tuple[l # Nested interior form: the interior path is explicit. if _engine._SUBGRAPH_PATH_SEP in node_str: return node_str.split(_engine._SUBGRAPH_PATH_SEP), widget + # Flattened composite form ("57:27"): the id namespace UI→API lowering mints + # (workflow_to_api composes inner ids as `:`), which is what + # `validate` output and server node_errors carry. Callers copy those ids + # back into edit commands, so accept them as an alias for the editable + # `/` path — unless a literal node really has that id. + if ":" in node_str and _find_by_str(workflow, node_str) is None: + return node_str.split(":"), widget defs_by_id = _engine._subgraph_defs_by_id(workflow) if not defs_by_id: diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 0d26cee8a..a602897ee 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -590,6 +590,35 @@ def test_nested_interior_address_writes_interior_node(self, patched_graph, tmp_p wf = json.loads(path.read_text()) assert _interior(wf, 27)["widgets_values"][0] == "a nested cat" + def test_flattened_colon_address_writes_interior_node(self, patched_graph, tmp_path, capsys): + """`57:27.text` — the FLATTENED id namespace `validate` and server + node_errors report after UI→API lowering (workflow_to_api composes inner + ids as `:`) — is accepted as an alias for `57/27.text`. + Without this, an agent that copies a node id out of a validate error gets + `node 57:27 not found in workflow` from the very tool that should fix it.""" + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57:27.text", "a flattened cat"], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["path"] == ["57", "27"] + assert op["inner_widget"] == "text" + wf = json.loads(path.read_text()) + assert _interior(wf, 27)["widgets_values"][0] == "a flattened cat" + + def test_flattened_colon_converges_with_nested_form(self): + """`57:27.text` and `57/27.text` resolve to the SAME CRDT write target.""" + wf = _subgraph_workflow() + _, colon = workflow_ops.set_widget(copy.deepcopy(wf), _graph(), "57:27", "text", "A") + _, nested = workflow_ops.set_widget(copy.deepcopy(wf), _graph(), "57/27", "text", "B") + assert workflow_ops._write_target(colon) == workflow_ops._write_target(nested) + + def test_flattened_colon_address_unknown_interior_errors(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _subgraph_workflow()) + env = _run(["set-widget", str(path), "57:99.text", "x"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert "interior node 99 not found" in env["error"]["message"] + def test_flat_and_nested_share_a_conflict_target(self): """Flat `57.text` and nested `57/27.text` land on the same interior widget, so their ops must resolve to the SAME CRDT write target (they diff --git a/tests/comfy_cli/test_validate_lowers_ui.py b/tests/comfy_cli/test_validate_lowers_ui.py index d4aa4d51a..001f0c6a9 100644 --- a/tests/comfy_cli/test_validate_lowers_ui.py +++ b/tests/comfy_cli/test_validate_lowers_ui.py @@ -204,6 +204,80 @@ def _envelope(result) -> dict: return json.loads(lines[-1]) +_SG_UUID = "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1" + + +def _subgraph_ui_workflow() -> dict: + """A frontend graph whose only real node is a KSampler INSIDE a subgraph + instance (id 57), with none of its link inputs wired — so lowering expands + it to the composite id ``57:3`` and validation flags its missing inputs.""" + return { + "last_node_id": 60, + "last_link_id": 0, + "nodes": [ + { + "id": 57, + "type": _SG_UUID, + "pos": [0, 0], + "inputs": [], + "outputs": [], + "properties": {}, + } + ], + "links": [], + "definitions": { + "subgraphs": [ + { + "id": _SG_UUID, + "name": "Sampler", + "inputs": [], + "outputs": [], + "nodes": [ + { + "id": 3, + "type": "KSampler", + "inputs": [], + "widgets_values": [42, "fixed", 20, 8.0, "euler", "normal", 1.0], + } + ], + "links": [], + } + ] + }, + } + + +class TestValidateSubgraphIdTranslation: + """Validate errors on subgraph interiors must be keyed by the EDITABLE + address (`57/3` — what set-widget/slots speak), not the flattened API id + (`57:3` — what lowering mints). Callers only ever saw the pre-flatten ids; + feeding `57:3` back into the edit surface used to dead-end with + `node 57:3 not found in workflow`.""" + + def test_lowered_subgraph_error_uses_editable_address(self, tmp_path): + result = _run_validate(tmp_path, _subgraph_ui_workflow(), _object_info()) + assert result.exit_code == 1 + env = _envelope(result) + missing = [e for e in env["data"]["errors"] if e["code"] == "missing_required_input"] + assert missing, env["data"]["errors"] + for e in missing: + assert e["node_id"] == "57/3", e + assert e["api_node_id"] == "57:3", e + + def test_api_format_input_keeps_raw_ids(self, tmp_path): + # A caller that hands us an already-lowered API doc addresses THAT doc; + # its ids pass through untouched (no api_node_id annotation). + api = {"57:3": {"class_type": "KSampler", "inputs": {}}} + result = _run_validate(tmp_path, api, _object_info()) + assert result.exit_code == 1 + env = _envelope(result) + missing = [e for e in env["data"]["errors"] if e["code"] == "missing_required_input"] + assert missing + for e in missing: + assert e["node_id"] == "57:3", e + assert "api_node_id" not in e, e + + class TestValidateCLI: def test_broken_frontend_graph_is_flagged(self, tmp_path): result = _run_validate(tmp_path, _break_model_link(_sd15_ui()), _object_info()) From 919420cab16d92eda6cf96ea2d20b08c7c3c7a77 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 16:13:37 -0700 Subject: [PATCH 10/53] feat(layout): deterministic placement primitives for CLI-minted nodes --- comfy_cli/layout.py | 74 ++++++++++++++++++++++++++++++++++ tests/comfy_cli/test_layout.py | 32 +++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 comfy_cli/layout.py create mode 100644 tests/comfy_cli/test_layout.py diff --git a/comfy_cli/layout.py b/comfy_cli/layout.py new file mode 100644 index 000000000..1c3edd214 --- /dev/null +++ b/comfy_cli/layout.py @@ -0,0 +1,74 @@ +"""Deterministic canvas placement for CLI-minted nodes. + +Positions are decided at op-mint time (add_node / apply_specs) and frozen into +the emitted ops, so replay stays convergent. Everything here is a pure function +of its inputs — no randomness, no clock, no I/O. Existing nodes are NEVER moved: +layout only chooses positions for nodes being minted in the current call. +""" + +from __future__ import annotations + +COL_GAP = 80.0 +ROW_GAP = 40.0 +NODE_W = 240.0 +HEADER_H = 30.0 +SLOT_H = 20.0 +WIDGET_H = 24.0 +PAD_H = 12.0 +MIN_H = 60.0 +ORIGIN = (40.0, 60.0) +DEFAULT_SIZE = (210.0, 100.0) +_MARGIN = 10.0 +_GUARD = 1000 # bounded collision-shift loop + + +def estimate_size(n_link_inputs: int, n_outputs: int, n_widgets: int) -> list[float]: + h = HEADER_H + SLOT_H * max(n_link_inputs, n_outputs) + WIDGET_H * n_widgets + PAD_H + return [NODE_W, max(h, MIN_H)] + + +def _rect(node: dict) -> tuple[float, float, float, float]: + pos = node.get("pos") or [0.0, 0.0] + size = node.get("size") or list(DEFAULT_SIZE) + try: + return (float(pos[0]), float(pos[1]), float(size[0]), float(size[1])) + except (TypeError, ValueError, IndexError): + return (0.0, 0.0, *DEFAULT_SIZE) + + +def _overlaps(a: tuple, b: tuple, margin: float = _MARGIN) -> bool: + ax, ay, aw, ah = a + bx, by, bw, bh = b + return not ( + ax + aw + margin <= bx + or bx + bw + margin <= ax + or ay + ah + margin <= by + or by + bh + margin <= ay + ) + + +def _bbox(nodes: list) -> tuple[float, float, float, float] | None: + rects = [_rect(n) for n in nodes if isinstance(n, dict)] + if not rects: + return None + return ( + min(r[0] for r in rects), + min(r[1] for r in rects), + max(r[0] + r[2] for r in rects), + max(r[1] + r[3] for r in rects), + ) + + +def cascade_pos(workflow: dict, size: list[float]) -> list[float]: + """Default position for a single minted node: right of the graph's bounding + box, top-aligned, sliding down past any collision.""" + nodes = [n for n in workflow.get("nodes") or [] if isinstance(n, dict)] + box = _bbox(nodes) + if box is None: + return list(ORIGIN) + x, y = box[2] + COL_GAP, box[1] + for _ in range(_GUARD): + if not any(_overlaps((x, y, size[0], size[1]), _rect(n)) for n in nodes): + break + y += ROW_GAP + return [x, y] diff --git a/tests/comfy_cli/test_layout.py b/tests/comfy_cli/test_layout.py new file mode 100644 index 000000000..8064ffb9f --- /dev/null +++ b/tests/comfy_cli/test_layout.py @@ -0,0 +1,32 @@ +from comfy_cli import layout + + +def _node(nid, pos, size=(210, 100)): + return {"id": nid, "type": "X", "pos": list(pos), "size": list(size)} + + +def test_estimate_size_grows_with_slots_and_widgets(): + small = layout.estimate_size(1, 1, 0) + big = layout.estimate_size(4, 2, 5) + assert big[1] > small[1] + assert small[1] >= 60 # never below a renderable minimum + assert small[0] == big[0] == layout.NODE_W + + +def test_cascade_pos_empty_graph_is_origin(): + wf = {"nodes": [], "links": []} + assert layout.cascade_pos(wf, [210, 100]) == list(layout.ORIGIN) + + +def test_cascade_pos_places_right_of_bbox_without_overlap(): + wf = {"nodes": [_node(1, (0, 0)), _node(2, (300, 200))], "links": []} + x, y = layout.cascade_pos(wf, [210, 100]) + assert x >= 300 + 210 # strictly right of the rightmost node's right edge + new_rect = (x, y, 210, 100) + for n in wf["nodes"]: + assert not layout._overlaps(new_rect, layout._rect(n)) + + +def test_cascade_pos_is_deterministic(): + wf = {"nodes": [_node(1, (50, 80))], "links": []} + assert layout.cascade_pos(wf, [240, 120]) == layout.cascade_pos(wf, [240, 120]) From 7289a5aeb95612a2c45681198aa9e4086fcdb4ac Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 16:17:36 -0700 Subject: [PATCH 11/53] style: ruff-format layout module --- comfy_cli/layout.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/comfy_cli/layout.py b/comfy_cli/layout.py index 1c3edd214..a66e514e4 100644 --- a/comfy_cli/layout.py +++ b/comfy_cli/layout.py @@ -39,12 +39,7 @@ def _rect(node: dict) -> tuple[float, float, float, float]: def _overlaps(a: tuple, b: tuple, margin: float = _MARGIN) -> bool: ax, ay, aw, ah = a bx, by, bw, bh = b - return not ( - ax + aw + margin <= bx - or bx + bw + margin <= ax - or ay + ah + margin <= by - or by + bh + margin <= ay - ) + return not (ax + aw + margin <= bx or bx + bw + margin <= ax or ay + ah + margin <= by or by + bh + margin <= ay) def _bbox(nodes: list) -> tuple[float, float, float, float] | None: From 3a6e555bb22e825ee5555671e6b7338af1b0a246 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 16:25:33 -0700 Subject: [PATCH 12/53] feat(workflow): layout-aware default position + real size estimate for add-node; --at arity check Co-Authored-By: Claude Fable 5 --- comfy_cli/command/workflow_edit.py | 6 ++- comfy_cli/workflow_ops.py | 20 +++++++-- tests/comfy_cli/command/test_workflow_edit.py | 41 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index b0addf4ef..3b5cb606a 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -113,8 +113,12 @@ def add_node_cmd( graph = _graph_or_exit(input_path, host, port, renderer, where) pos = None if at: + parts = [s.strip() for s in at.split(",")] + if len(parts) != 2: + renderer.error(code="workflow_edit_invalid", message=f"--at must be 'x,y', got {at!r}") + raise typer.Exit(code=1) try: - pos = [float(x) for x in at.split(",", 1)] + pos = [float(x) for x in parts] except ValueError as e: renderer.error(code="workflow_edit_invalid", message=f"--at must be 'x,y': {e}") raise typer.Exit(code=1) from e diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 321fbd3eb..eea547b89 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -58,6 +58,8 @@ import uuid from typing import Any +from comfy_cli import layout + # New ids live in [2**40, 2**53): always large (never collides with small # frontend counter ids), always inside JS Number.MAX_SAFE_INTEGER. _ID_FLOOR = 1 << 40 @@ -202,7 +204,17 @@ def add_node( m = graph.node(class_type) if m is None: raise ValueError(f"unknown node type {class_type!r}") - node = _build_node(mint_id(), class_type, m, graph, pos) + size = layout.estimate_size( + len([p for p in m.inputs if p.is_link]), + len(m.outputs), + len(graph.widget_order(class_type)), + ) + if pos is None: + # Layout-aware default: right of the current graph, collision-free. + # Decided at mint time so the position freezes into the op and replay + # stays convergent (P1). Existing nodes are never moved. + pos = layout.cascade_pos(workflow, size) + node = _build_node(mint_id(), class_type, m, graph, pos, size) op = _new_op( "add_node", actor, @@ -1019,7 +1031,7 @@ def strip_internal(workflow: dict) -> dict: # --------------------------------------------------------------------------- -def _build_node(node_id: int, class_type: str, m, graph, pos: list | None) -> dict: +def _build_node(node_id: int, class_type: str, m, graph, pos: list, size: list) -> dict: inputs = [{"name": p.name, "type": p.type, "link": None} for p in m.inputs if p.is_link] outputs = [{"name": p.name, "type": p.type, "links": []} for p in m.outputs] # Widget values in positional order, including dynamic-combo selectors and @@ -1029,8 +1041,8 @@ def _build_node(node_id: int, class_type: str, m, graph, pos: list | None) -> di return { "id": node_id, "type": class_type, - "pos": list(pos) if pos else [0, 0], - "size": [210, 100], + "pos": list(pos), + "size": list(size), "flags": {}, "order": 0, "mode": 0, diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index a602897ee..b866fed8b 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -441,6 +441,47 @@ def test_ids_are_large_ints_and_collision_free(self, patched_graph, tmp_path, ca # Not a small sequential counter value — minted from a large space. assert id1 > 10_000 and id2 > 10_000 + def test_add_node_without_at_does_not_stack(self): + """No explicit `pos` → the layout-aware cascade default, not the old + blind [0, 0] every node used to land on (they used to stack exactly).""" + g = _graph() + wf = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, op1 = workflow_ops.add_node(wf, g, "KSampler") + wf, op2 = workflow_ops.add_node(wf, g, "KSampler") + n1, n2 = wf["nodes"][-2], wf["nodes"][-1] + assert n1["pos"] != [0, 0] and n2["pos"] != [0, 0] + assert n1["pos"] != n2["pos"] + # position is frozen into the op for convergent replay + assert op1["pos"] == n1["pos"] + assert op2["pos"] == n2["pos"] + + def test_add_node_explicit_pos_is_honored(self): + """`pos=` still passes straight through unchanged — Task 3's pre-pass + depends on this.""" + g = _graph() + wf = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, op = workflow_ops.add_node(wf, g, "KSampler", pos=[400, 200]) + assert wf["nodes"][-1]["pos"] == [400, 200] + assert op["pos"] == [400, 200] + + def test_add_node_size_reflects_widget_count(self): + """Size is estimated from the node's real inputs/outputs/widgets, not + the old blind [210, 100] default.""" + g = _graph() + wf = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, _ = workflow_ops.add_node(wf, g, "KSampler") + node = wf["nodes"][-1] + assert node["size"] != [210, 100] # no longer the blind default + assert node["size"][1] >= 60 + + def test_add_node_cmd_rejects_single_coordinate(self, patched_graph, tmp_path, capsys): + """`--at 400` (no comma, so `--at=400`) used to silently become a + length-1 `pos` list instead of erroring — arity must be enforced.""" + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "KSampler", "--at", "400"], capsys) + assert env["ok"] is False, env + assert env["error"]["code"] == "workflow_edit_invalid" + # --------------------------------------------------------------------------- # set-widget (name-addressed) From 6c3f0f77ac220c16f6c85a680515f98deb00e145 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 16:36:24 -0700 Subject: [PATCH 13/53] feat(workflow): topology-aware batch layout pre-pass in apply_specs Co-Authored-By: Claude Fable 5 --- comfy_cli/layout.py | 90 +++++++++++++++++++ comfy_cli/workflow_ops.py | 1 + tests/comfy_cli/command/test_workflow_edit.py | 14 +++ tests/comfy_cli/test_layout.py | 52 +++++++++++ 4 files changed, 157 insertions(+) diff --git a/comfy_cli/layout.py b/comfy_cli/layout.py index a66e514e4..ad03f0242 100644 --- a/comfy_cli/layout.py +++ b/comfy_cli/layout.py @@ -67,3 +67,93 @@ def cascade_pos(workflow: dict, size: list[float]) -> list[float]: break y += ROW_GAP return [x, y] + + +def assign_positions(workflow: dict, graph, specs: list) -> list: + """Fill `at` on every add_node spec that lacks one, using the batch's own + connects for dataflow layering. Returns spec copies; non-add specs and + explicit `at` values pass through untouched. Pure: same inputs → same output.""" + out = [dict(s) if isinstance(s, dict) else s for s in specs] + adds: dict[str, dict] = {} + order: list[str] = [] + for i, spec in enumerate(out): + if not (isinstance(spec, dict) and spec.get("op") == "add_node"): + continue + m = graph.node(spec.get("class_type") or "") + if m is not None: + size = estimate_size( + len([p for p in m.inputs if p.is_link]), + len(m.outputs), + len(graph.widget_order(spec["class_type"])), + ) + else: + size = list(DEFAULT_SIZE) # unknown type: apply_specs will error later + key = spec.get("as") or f"__new{i}" + adds[key] = {"i": i, "size": size, "depth": 0, "pinned": spec.get("at")} + order.append(key) + if not adds: + return out + + existing = {n.get("id"): n for n in workflow.get("nodes") or [] if isinstance(n, dict)} + edges: list[tuple[str, str]] = [] + anchors: list[dict] = [] + + def endpoint(ref): + node_part = str(ref).partition(".")[0].strip() + if node_part in adds: + return ("new", node_part) + nid = int(node_part) if node_part.lstrip("-").isdigit() else node_part + if nid in existing: + return ("old", nid) + return (None, None) + + for spec in out: + if not (isinstance(spec, dict) and spec.get("op") == "connect"): + continue + skind, s = endpoint(spec.get("from", "")) + tkind, t = endpoint(spec.get("to", "")) + if skind == "old": + anchors.append(existing[s]) + if tkind == "old" and skind == "new": + anchors.append(existing[t]) + if tkind == "new": + if skind == "new": + edges.append((s, t)) + elif skind == "old": + adds[t]["depth"] = max(adds[t]["depth"], 1) + + # Longest-path layering over new→new edges. Aliases are defined before use + # in a valid batch, so spec-order passes converge; run twice for safety. + for _ in range(2): + for s, t in edges: + adds[t]["depth"] = max(adds[t]["depth"], adds[s]["depth"] + 1) + + if anchors: + arects = [_rect(a) for a in anchors] + base_x = max(r[0] + r[2] for r in arects) + COL_GAP + base_y = min(r[1] for r in arects) + else: + box = _bbox(list(existing.values())) + base_x, base_y = (box[2] + COL_GAP, box[1]) if box else ORIGIN + + col_y: dict[int, float] = {} + movable = [k for k in order if adds[k]["pinned"] is None] + for k in movable: + a = adds[k] + x = base_x + a["depth"] * (NODE_W + COL_GAP) + y = col_y.get(a["depth"], base_y) + col_y[a["depth"]] = y + a["size"][1] + ROW_GAP + a["pos"] = [x, y] + + def collides() -> bool: + return any(_overlaps((*adds[k]["pos"], *adds[k]["size"]), _rect(n)) for k in movable for n in existing.values()) + + for _ in range(_GUARD): + if not movable or not collides(): + break + for k in movable: # shift the whole new block, never existing nodes + adds[k]["pos"][1] += ROW_GAP + + for k in movable: + out[adds[k]["i"]]["at"] = adds[k]["pos"] + return out diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index eea547b89..5fc140bdf 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -727,6 +727,7 @@ def apply_specs( workflow: dict, graph, specs: list, *, actor: str = "cli", base_version: int = 0 ) -> tuple[dict, list, dict]: """Apply edit specs to ``workflow`` in order. Returns (workflow, ops, aliases).""" + specs = layout.assign_positions(workflow, graph, specs) aliases: dict[str, Any] = {} ops: list[dict] = [] for i, spec in enumerate(specs): diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index b866fed8b..07b778ca7 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -1018,6 +1018,20 @@ def test_missing_field_names_the_spec(self, patched_graph, tmp_path, capsys): assert env["ok"] is False assert "spec #1" in env["error"]["message"] and "missing required field" in env["error"]["message"] + def test_apply_specs_batch_has_no_stacked_nodes(self): + """apply_specs must layout-assign positions for a batch of add_nodes + that don't specify `at`, so they don't all land stacked at the origin.""" + wf: dict = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + specs = [ + {"op": "add_node", "class_type": "KSampler", "as": "a"}, + {"op": "add_node", "class_type": "KSampler", "as": "b"}, + {"op": "add_node", "class_type": "KSampler", "as": "c"}, + ] + wf, ops, _ = workflow_ops.apply_specs(wf, _graph(), specs) + positions = [tuple(n["pos"]) for n in wf["nodes"]] + assert len(set(positions)) == len(positions) + assert (0, 0) not in positions + # --------------------------------------------------------------------------- # dynamic combo (COMFY_DYNAMICCOMBO_V3) — set_widget on model + model.resolution diff --git a/tests/comfy_cli/test_layout.py b/tests/comfy_cli/test_layout.py index 8064ffb9f..d91c7a49d 100644 --- a/tests/comfy_cli/test_layout.py +++ b/tests/comfy_cli/test_layout.py @@ -30,3 +30,55 @@ def test_cascade_pos_places_right_of_bbox_without_overlap(): def test_cascade_pos_is_deterministic(): wf = {"nodes": [_node(1, (50, 80))], "links": []} assert layout.cascade_pos(wf, [240, 120]) == layout.cascade_pos(wf, [240, 120]) + + +class _FakeMeta: + def __init__(self, n_in, n_out): + self.inputs = [type("P", (), {"is_link": True, "name": f"i{k}", "type": "X"})() for k in range(n_in)] + self.outputs = [type("P", (), {"name": f"o{k}", "type": "X"})() for k in range(n_out)] + + +class _FakeGraph: + def node(self, class_type): + return _FakeMeta(2, 1) + + def widget_order(self, class_type): + return ["a", "b"] + + +def test_assign_positions_layers_by_dataflow(): + wf = {"nodes": [], "links": []} + specs = [ + {"op": "add_node", "class_type": "Loader", "as": "l"}, + {"op": "add_node", "class_type": "Sampler", "as": "s"}, + {"op": "add_node", "class_type": "Save", "as": "v"}, + {"op": "connect", "from": "l.0", "to": "s.model"}, + {"op": "connect", "from": "s.0", "to": "v.images"}, + ] + out = layout.assign_positions(wf, _FakeGraph(), specs) + xl, xs, xv = (out[i]["at"][0] for i in range(3)) + assert xl < xs < xv # left-to-right by dataflow depth + + +def test_assign_positions_anchors_right_of_existing_source(): + wf = {"nodes": [_node(7, (100, 100), (200, 120))], "links": []} + specs = [ + {"op": "add_node", "class_type": "Upscale", "as": "u"}, + {"op": "connect", "from": "7.0", "to": "u.image"}, + ] + out = layout.assign_positions(wf, _FakeGraph(), specs) + assert out[0]["at"][0] >= 100 + 200 # right of the anchor's right edge + # and it must not overlap the anchor + assert not layout._overlaps((*out[0]["at"], layout.NODE_W, 100), layout._rect(wf["nodes"][0])) + + +def test_assign_positions_respects_explicit_at_and_is_deterministic(): + wf = {"nodes": [], "links": []} + specs = [ + {"op": "add_node", "class_type": "A", "as": "a", "at": [999, 999]}, + {"op": "add_node", "class_type": "B", "as": "b"}, + ] + out1 = layout.assign_positions(wf, _FakeGraph(), specs) + out2 = layout.assign_positions(wf, _FakeGraph(), specs) + assert out1[0]["at"] == [999, 999] + assert out1 == out2 From d6977a750e7556dc4db433e18864eef5a92726d6 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 16:44:09 -0700 Subject: [PATCH 14/53] fix(layout): directional anchors + full longest-path relaxation in assign_positions Co-Authored-By: Claude Fable 5 --- comfy_cli/layout.py | 53 ++++++++++++++++++++++------------ tests/comfy_cli/test_layout.py | 27 +++++++++++++++++ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/comfy_cli/layout.py b/comfy_cli/layout.py index ad03f0242..86795ac44 100644 --- a/comfy_cli/layout.py +++ b/comfy_cli/layout.py @@ -96,7 +96,8 @@ def assign_positions(workflow: dict, graph, specs: list) -> list: existing = {n.get("id"): n for n in workflow.get("nodes") or [] if isinstance(n, dict)} edges: list[tuple[str, str]] = [] - anchors: list[dict] = [] + src_anchors: list[dict] = [] # existing nodes that feed a new node (old -> new) + dst_anchors: list[dict] = [] # existing nodes fed by a new node (new -> old) def endpoint(ref): node_part = str(ref).partition(".")[0].strip() @@ -112,32 +113,48 @@ def endpoint(ref): continue skind, s = endpoint(spec.get("from", "")) tkind, t = endpoint(spec.get("to", "")) - if skind == "old": - anchors.append(existing[s]) - if tkind == "old" and skind == "new": - anchors.append(existing[t]) - if tkind == "new": - if skind == "new": - edges.append((s, t)) - elif skind == "old": - adds[t]["depth"] = max(adds[t]["depth"], 1) - - # Longest-path layering over new→new edges. Aliases are defined before use - # in a valid batch, so spec-order passes converge; run twice for safety. - for _ in range(2): + if skind == "old" and tkind == "new": + src_anchors.append(existing[s]) + adds[t]["depth"] = max(adds[t]["depth"], 1) + elif skind == "new" and tkind == "old": + dst_anchors.append(existing[t]) + elif skind == "new" and tkind == "new": + edges.append((s, t)) + + # Longest-path layering over new→new edges via relaxation to a fixpoint, + # bounded by the worst-case chain length. A valid batch has no cycles + # among new nodes, so this always converges within the bound regardless + # of the order connects appear in the spec list. + passes = max(1, len(adds) - 1) + for _ in range(passes): + changed = False for s, t in edges: - adds[t]["depth"] = max(adds[t]["depth"], adds[s]["depth"] + 1) + cand = adds[s]["depth"] + 1 + if cand > adds[t]["depth"]: + adds[t]["depth"] = cand + changed = True + if not changed: + break + + movable = [k for k in order if adds[k]["pinned"] is None] - if anchors: - arects = [_rect(a) for a in anchors] + if src_anchors: + # New nodes fed by existing ones: place right of the feeders, as before. + arects = [_rect(a) for a in src_anchors] base_x = max(r[0] + r[2] for r in arects) + COL_GAP base_y = min(r[1] for r in arects) + elif dst_anchors: + # New nodes that feed INTO existing ones: place the whole new block to + # the left so the edge still reads left-to-right, not backwards. + drects = [_rect(a) for a in dst_anchors] + max_depth = max((adds[k]["depth"] for k in movable), default=0) + base_x = min(r[0] for r in drects) - (max_depth + 1) * (NODE_W + COL_GAP) + base_y = min(r[1] for r in drects) else: box = _bbox(list(existing.values())) base_x, base_y = (box[2] + COL_GAP, box[1]) if box else ORIGIN col_y: dict[int, float] = {} - movable = [k for k in order if adds[k]["pinned"] is None] for k in movable: a = adds[k] x = base_x + a["depth"] * (NODE_W + COL_GAP) diff --git a/tests/comfy_cli/test_layout.py b/tests/comfy_cli/test_layout.py index d91c7a49d..5f4a14cad 100644 --- a/tests/comfy_cli/test_layout.py +++ b/tests/comfy_cli/test_layout.py @@ -82,3 +82,30 @@ def test_assign_positions_respects_explicit_at_and_is_deterministic(): out2 = layout.assign_positions(wf, _FakeGraph(), specs) assert out1[0]["at"] == [999, 999] assert out1 == out2 + + +def test_assign_positions_new_source_into_existing_target_goes_left(): + wf = {"nodes": [_node(7, (500, 100), (200, 120))], "links": []} + specs = [ + {"op": "add_node", "class_type": "Upscale", "as": "u"}, + {"op": "connect", "from": "u.0", "to": "7.image"}, + ] + out = layout.assign_positions(wf, _FakeGraph(), specs) + assert out[0]["at"][0] + layout.NODE_W <= 500 # fully left of the anchor + assert not layout._overlaps((*out[0]["at"], layout.NODE_W, 100), layout._rect(wf["nodes"][0])) + + +def test_assign_positions_reverse_order_connects_full_depth(): + wf = {"nodes": [], "links": []} + specs = [ + {"op": "add_node", "class_type": "A", "as": "a"}, + {"op": "add_node", "class_type": "B", "as": "b"}, + {"op": "add_node", "class_type": "C", "as": "c"}, + {"op": "add_node", "class_type": "D", "as": "d"}, + {"op": "connect", "from": "c.0", "to": "d.in"}, + {"op": "connect", "from": "b.0", "to": "c.in"}, + {"op": "connect", "from": "a.0", "to": "b.in"}, + ] + out = layout.assign_positions(wf, _FakeGraph(), specs) + xa, xb, xc, xd = (out[i]["at"][0] for i in range(4)) + assert xa < xb < xc < xd From 9f603061a0cb1de0d73c6b85ed5968b1735c0e38 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 24 Jul 2026 16:51:25 -0700 Subject: [PATCH 15/53] feat(workflow): first-class clear command (single clear op, ids stay monotonic) Co-Authored-By: Claude Fable 5 --- comfy_cli/command/workflow.py | 1 + comfy_cli/command/workflow_edit.py | 20 ++++++++++ comfy_cli/workflow_ops.py | 18 +++++++++ tests/comfy_cli/command/test_workflow_edit.py | 39 +++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index 4a676e9a3..de0ad18c3 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -1186,6 +1186,7 @@ def delete_cmd( app.command("connect", help="Wire an output slot to an input slot; emits a connect op.")(_wedit.connect_cmd) app.command("set-widget", help="Set a widget by name (`.`); emits a set_widget op.")(_wedit.set_widget_cmd) app.command("delete-node", help="Delete a node and its links; emits a delete_node op.")(_wedit.delete_cmd) +app.command("clear", help="Remove every node, link, and group; emits one clear op.")(_wedit.clear_cmd) app.command("ls-nodes", help="List nodes (id/type/title) in a workflow file.")(_wedit.ls_nodes_cmd) app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")( _wedit.apply_cmd diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index 3b5cb606a..d737288f2 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -238,6 +238,26 @@ def delete_cmd( _finish(renderer, p, workflow, op, base_version, stdout, "workflow delete") +# --------------------------------------------------------------------------- +# clear +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def clear_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + where: WhereOpt = None, # accepted for caller uniformity; clear needs no catalog +): + renderer = get_renderer() + renderer.command = "workflow clear" + p, workflow = _load_workflow_or_fail(renderer, file) + workflow, op = workflow_ops.clear(workflow, actor=actor, base_version=base_version) + _finish(renderer, p, workflow, op, base_version, stdout, "workflow clear") + + # --------------------------------------------------------------------------- # ls-nodes — recover node ids/types (so an agent can address minted nodes) # --------------------------------------------------------------------------- diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 5fc140bdf..1b68fe77c 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -487,6 +487,15 @@ def _connect_impl( return apply_op(workflow, op, graph), op +def clear(workflow: dict, *, actor: str = "cli", base_version: int = 0) -> tuple[dict, dict]: + """Remove every node, link, and group in one op. last_node_id/last_link_id + are preserved so ids minted after a clear stay monotonic (id reuse would + let a merge resurrect a deleted node's identity).""" + removed = [n.get("id") for n in workflow.get("nodes") or [] if isinstance(n, dict)] + op = _new_op("clear", actor, base_version, removed_nodes=removed) + return apply_op(workflow, op, None), op + + def delete_node( workflow: dict, graph, @@ -796,6 +805,8 @@ def apply_op(workflow: dict, op: dict, graph) -> dict: _apply_connect(workflow, op) elif kind == "delete_node": _apply_delete_node(workflow, op) + elif kind == "clear": + _apply_clear(workflow, op) else: raise ValueError(f"unknown op {kind!r}") applied.append(op["op_id"]) @@ -918,6 +929,13 @@ def _apply_delete_node(workflow: dict, op: dict) -> None: out["links"] = [lid for lid in (out.get("links") or []) if lid in kept_ids] +def _apply_clear(workflow: dict, op: dict) -> None: + workflow["nodes"] = [] + workflow["links"] = [] + if "groups" in workflow: + workflow["groups"] = [] + + # --------------------------------------------------------------------------- # conflict detection + canonicalization (for ask-to-merge / convergence checks) # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 07b778ca7..8d2057f6b 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -895,6 +895,45 @@ def test_nested_subgraph_address_missing_interior_node_errors(self, patched_grap assert env["error"]["code"] == "workflow_edit_invalid" +# --------------------------------------------------------------------------- +# clear +# --------------------------------------------------------------------------- + + +class TestClear: + def test_clear_empties_graph_but_preserves_id_counters(self): + g = _graph() + wf = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, _ = workflow_ops.add_node(wf, g, "KSampler") + wf, _ = workflow_ops.add_node(wf, g, "KSampler") + wf["groups"] = [{"title": "g"}] + last_node = wf["last_node_id"] + wf, op = workflow_ops.clear(wf) + assert wf["nodes"] == [] and wf["links"] == [] and wf["groups"] == [] + assert wf["last_node_id"] == last_node # ids stay monotonic across a clear + assert op["op"] == "clear" + + def test_clear_op_replays_idempotently(self): + g = _graph() + wf = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, _ = workflow_ops.add_node(wf, g, "KSampler") + cleared, op = workflow_ops.clear(copy.deepcopy(wf)) + replay = workflow_ops.apply_op(copy.deepcopy(wf), op, g) + replay = workflow_ops.apply_op(replay, op, g) # second apply is a no-op + assert replay["nodes"] == cleared["nodes"] == [] + + def test_clear_cmd_empties_workflow_and_emits_op(self, patched_graph, tmp_path, capsys): + wf = _base_workflow() + wf["groups"] = [{"title": "g"}] + path = _write(tmp_path, wf) + env = _run(["clear", str(path)], capsys) + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "clear" + on_disk = json.loads(path.read_text()) + assert on_disk["nodes"] == [] and on_disk["links"] == [] and on_disk["groups"] == [] + + # --------------------------------------------------------------------------- # invariant: the edit surface operates on UI (frontend) format ONLY # (API format is a throwaway produced only at `run`) From e5f4b8e2cc472b7bede7a8d6afa47b4ad0864df8 Mon Sep 17 00:00:00 2001 From: kishore Date: Mon, 27 Jul 2026 18:06:57 -0700 Subject: [PATCH 16/53] fix(workflow): a failed batch must not advertise ids the rollback discards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply_specs` threads an accumulating workflow dict through the batch, so when spec #N fails that dict already holds the nodes added by specs #0..N-1, and `_enrich_resolution_error` renders its "Nodes in this workflow" / "Did you mean" inventory from it. Every caller then throws that graph away — `apply` is atomic, `foreach` drops the failing param-set — so the ids in the hint are fictional the moment the command returns. The hint is phrased as an instruction ("Use an id from `comfy workflow slots` / `ls-nodes` — never rebuild it"), so a model reasonably treats those ids as authoritative and addresses them next. Measured on prod comfy-agent Langfuse traces (2026-07-27): 16/16 of every "node not found in workflow" edit failure used an id an earlier FAILED batch had advertised this way. One trace (ff11b86931f3fbaa) burned seven consecutive `connect` calls on ids that never existed, then had to re-read the graph and rebuild the whole batch. Now a batch failure strips the mid-batch inventory, restates it from the pre-batch graph, and says plainly that nothing was applied: before: input 'image' not found on node 2453232609257464; inputs: ['images']. Nodes in this workflow: 3 (KSampler), 7 (EmptyLatentImage), 3909203706911903 (VAEDecode), 2377033782696159 (BatchImagesNode). Use an id from `comfy workflow slots` / `ls-nodes` — never rebuild it. after: input 'image' not found on node 2453232609257464; inputs: ['images']. No changes were applied — the batch was discarded. The workflow still contains: 3 (KSampler), 7 (EmptyLatentImage). Any node id minted by this batch is gone; re-read ids with `comfy workflow slots` / `ls-nodes` before addressing nodes. Tests reproduce the prod shape (two adds, then a connect into a non-existent autogrow input), and cover the message contract, punctuation across the clause strip, and that a successful batch is unaffected. --- comfy_cli/workflow_ops.py | 130 ++++++++++++------ .../command/test_workflow_apply_rollback.py | 112 +++++++++++++++ 2 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 tests/comfy_cli/command/test_workflow_apply_rollback.py diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 1b68fe77c..64a3be62f 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -140,6 +140,44 @@ def _enrich_resolution_error(e: ValueError, workflow: dict, graph, *, widget: An return e +# Both enrichment forms above append their hint at the END of the message, so +# truncating from the first marker strips the whole (now-stale) clause. +_MIDBATCH_HINT_RE = re.compile(r"\.\s*(?:Nodes in this workflow:|Did you mean:).*\Z", re.S) + + +def _rehint_discarded_batch(e: Exception, pre_batch_hint: str) -> ValueError: + """Re-render a batch failure's identifier hint against the PRE-batch graph. + + ``apply_specs`` threads an accumulating ``workflow`` through the batch, so by + the time spec #N fails that dict already holds the nodes added by specs + #0..N-1, and :func:`_enrich_resolution_error` renders its "Nodes in this + workflow" / "Did you mean" inventory from it. Every caller then discards that + graph — ``apply`` is atomic, ``foreach`` drops the failing param-set — so the + ids in the hint are fictional the moment the command returns. + + The hint is phrased as an instruction ("Use an id from ... never rebuild + it"), so a model reasonably treats those ids as authoritative and addresses + them next. Measured on prod comfy-agent traces (2026-07-27): 16/16 of every + "node not found in workflow" edit failure used an id an earlier FAILED + batch had advertised this way; one trace burned seven consecutive connects + on ids that never existed. + + So: strip the mid-batch inventory, restate it from the graph as it actually + stands, and say plainly that nothing was applied. + """ + # The regex eats the separator before the stripped clause, so re-punctuate + # rather than emit "inputs: ['images'] No changes were applied". + msg = _MIDBATCH_HINT_RE.sub("", str(e)).rstrip().rstrip(".") + suffix = ". No changes were applied — the batch was discarded." + if pre_batch_hint: + suffix += ( + f" The workflow still contains: {pre_batch_hint}. " + "Any node id minted by this batch is gone; re-read ids with " + "`comfy workflow slots` / `ls-nodes` before addressing nodes." + ) + return ValueError(msg + suffix) + + def _find_by_str(workflow: dict, node_id: Any) -> dict | None: """Locate a node comparing ids as strings — subgraph op paths carry string ids while top-level node ids are ints.""" @@ -737,51 +775,57 @@ def apply_specs( ) -> tuple[dict, list, dict]: """Apply edit specs to ``workflow`` in order. Returns (workflow, ops, aliases).""" specs = layout.assign_positions(workflow, graph, specs) + # Snapshot the inventory BEFORE any op mutates the graph — on failure the + # caller discards everything below, so this is what actually survives. + pre_batch_hint = _available_nodes_hint(workflow) aliases: dict[str, Any] = {} ops: list[dict] = [] - for i, spec in enumerate(specs): - if not isinstance(spec, dict) or "op" not in spec: - raise ValueError(f"spec #{i} must be an object with an 'op' field") - kind = spec["op"] - # A missing required field surfaces as a bare KeyError (just the key name); - # wrap it so the batch/recipe caller learns WHICH spec and op are malformed. - try: - if kind == "add_node": - workflow, op = add_node( - workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version - ) - alias = spec.get("as") - if alias: - # A duplicate alias would silently clobber the earlier node, so a - # later `${alias}` reference resolves to the wrong node. Recipes - # are generated/templated, so an accidental repeat is plausible — - # fail loudly instead. - if alias in aliases: - raise ValueError(f"spec #{i}: alias {alias!r} is already defined by an earlier spec") - aliases[alias] = op["node_id"] - elif kind == "connect": - fn, fs = _split_ref_slot(spec["from"], aliases) - tn, ts = _split_ref_slot(spec["to"], aliases) - workflow, op = connect(workflow, graph, fn, fs, tn, ts, actor=actor, base_version=base_version) - elif kind == "set_widget": - workflow, op = set_widget( - workflow, - graph, - resolve_ref(spec["node"], aliases), - spec["widget"], - spec["value"], - actor=actor, - base_version=base_version, - ) - elif kind == "delete_node": - workflow, op = delete_node( - workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version - ) - else: - raise ValueError(f"spec #{i}: unknown op {kind!r}") - except KeyError as e: - raise ValueError(f"spec #{i} ({kind}) is missing required field {e}") from e - ops.append(op) + try: + for i, spec in enumerate(specs): + if not isinstance(spec, dict) or "op" not in spec: + raise ValueError(f"spec #{i} must be an object with an 'op' field") + kind = spec["op"] + # A missing required field surfaces as a bare KeyError (just the key name); + # wrap it so the batch/recipe caller learns WHICH spec and op are malformed. + try: + if kind == "add_node": + workflow, op = add_node( + workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version + ) + alias = spec.get("as") + if alias: + # A duplicate alias would silently clobber the earlier node, so a + # later `${alias}` reference resolves to the wrong node. Recipes + # are generated/templated, so an accidental repeat is plausible — + # fail loudly instead. + if alias in aliases: + raise ValueError(f"spec #{i}: alias {alias!r} is already defined by an earlier spec") + aliases[alias] = op["node_id"] + elif kind == "connect": + fn, fs = _split_ref_slot(spec["from"], aliases) + tn, ts = _split_ref_slot(spec["to"], aliases) + workflow, op = connect(workflow, graph, fn, fs, tn, ts, actor=actor, base_version=base_version) + elif kind == "set_widget": + workflow, op = set_widget( + workflow, + graph, + resolve_ref(spec["node"], aliases), + spec["widget"], + spec["value"], + actor=actor, + base_version=base_version, + ) + elif kind == "delete_node": + workflow, op = delete_node( + workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version + ) + else: + raise ValueError(f"spec #{i}: unknown op {kind!r}") + except KeyError as e: + raise ValueError(f"spec #{i} ({kind}) is missing required field {e}") from e + ops.append(op) + except (ValueError, KeyError) as e: + raise _rehint_discarded_batch(e, pre_batch_hint) from e return workflow, ops, aliases diff --git a/tests/comfy_cli/command/test_workflow_apply_rollback.py b/tests/comfy_cli/command/test_workflow_apply_rollback.py new file mode 100644 index 000000000..20aabb608 --- /dev/null +++ b/tests/comfy_cli/command/test_workflow_apply_rollback.py @@ -0,0 +1,112 @@ +"""A FAILED atomic `workflow apply` batch must not advertise node ids that the +rollback discards. + +Regression guard for a bug measured in prod comfy-agent Langfuse traces +(2026-07-27). Sequence in trace ff11b86931f3fbaa: + + 03:34:58 apply_ops ok=false "batch failed: input 'image' not found on node + 868300940744052; inputs: ['images','files']. + Nodes in this workflow: 3686911078754972 + (LoadImage), 868300940744052 (GeminiNanoBanana2), + 4045462123562940 (KlingStartEndFrameNode), …" + 03:35:13 connect ok=false "node 3686911078754972 not found in workflow" + … six more connects, all "not found", every one using an id from that hint + +Across the 78-trace prod sample, 16/16 (100%) of "node not found in +workflow" edit failures used an id that an earlier FAILED batch had advertised. + +Cause: `apply_specs` threads an accumulating `workflow` dict through the batch, +so when spec #N fails that dict already holds the nodes added by specs #0..N-1, +and `_enrich_resolution_error` renders its inventory from it. The batch is +atomic, so those ids never reach disk — but the hint is phrased as an +instruction ("Use an id from ... never rebuild it"), so a model treats them as +real and addresses them next. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from test_workflow_edit import ( # type: ignore[import-not-found] + _base_workflow, + _run, + _write, + patched_graph, # noqa: F401 (pytest fixture) + reset_singleton, # noqa: F401 (autouse fixture) +) + + +def _failing_batch(tmp_path) -> Path: + """add VAEDecode + BatchImagesNode, then connect into 'image' — which does + not exist on BatchImagesNode (its autogrow input is 'images'). Same shape as + the prod failure: two adds succeed in memory, the third spec fails.""" + ops = tmp_path / "ops.json" + ops.write_text( + json.dumps( + [ + {"op": "add_node", "class_type": "VAEDecode", "as": "dec"}, + {"op": "add_node", "class_type": "BatchImagesNode", "as": "batch"}, + {"op": "connect", "from": "dec.IMAGE", "to": "batch.image"}, + ] + ) + ) + return ops + + +def test_failed_batch_does_not_advertise_discarded_node_ids(patched_graph, tmp_path, capsys): # noqa: F811 + path = _write(tmp_path, _base_workflow()) + ids_before = {n["id"] for n in json.loads(Path(path).read_text())["nodes"]} + + env = _run(["apply", str(path), "--ops", str(_failing_batch(tmp_path))], capsys) + assert env["ok"] is False, env + msg = env["error"]["message"] + assert "batch failed" in msg, msg + + # The batch is atomic — the file is untouched. + after = {n["id"] for n in json.loads(Path(path).read_text())["nodes"]} + assert after == ids_before, "batch must be atomic" + + # Every id the message names as present must actually exist. + advertised = {int(m) for m in re.findall(r"(\d+) \(", msg)} + assert not (advertised - ids_before), ( + f"failed batch advertised discarded node ids: {sorted(advertised - ids_before)}\n{msg}" + ) + + +def test_failed_batch_states_nothing_was_applied(patched_graph, tmp_path, capsys): # noqa: F811 + """The caller must be told the graph is unchanged, and be pointed at the + real inventory — otherwise it re-addresses ids from the failed batch.""" + path = _write(tmp_path, _base_workflow()) + env = _run(["apply", str(path), "--ops", str(_failing_batch(tmp_path))], capsys) + msg = env["error"]["message"] + + assert "No changes were applied" in msg, msg + assert "The workflow still contains:" in msg, msg + # The pre-batch nodes ARE named, so the hint stays actionable. + assert "3 (KSampler)" in msg and "7 (EmptyLatentImage)" in msg, msg + # And the stale mid-batch inventory is gone. + assert "Nodes in this workflow:" not in msg, msg + # Punctuation survives the clause strip. + assert "] No changes" not in msg, f"missing separator before the suffix: {msg}" + + +def test_successful_batch_is_unaffected(patched_graph, tmp_path, capsys): # noqa: F811 + """The re-hint must only fire on failure — a good batch still applies.""" + path = _write(tmp_path, _base_workflow()) + ops = tmp_path / "ok.json" + ops.write_text( + json.dumps( + [ + {"op": "add_node", "class_type": "VAEDecode", "as": "dec"}, + {"op": "add_node", "class_type": "BatchImagesNode", "as": "batch"}, + {"op": "connect", "from": "dec.IMAGE", "to": "batch.images"}, + ] + ) + ) + env = _run(["apply", str(path), "--ops", str(ops)], capsys) + assert env["ok"] is True, env + after = json.loads(Path(path).read_text()) + types = {n["type"] for n in after["nodes"]} + assert {"VAEDecode", "BatchImagesNode"} <= types, types From 25ee3b9cbdb3cc0fd8049561964b166280ae3019 Mon Sep 17 00:00:00 2001 From: kishore Date: Tue, 28 Jul 2026 14:45:03 -0700 Subject: [PATCH 17/53] fix(workflow): accept a connect whose target input declares a type UNION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComfyUI expresses a multi-type input as a COMMA-SEPARATED UNION, e.g. `MESH,FILE_3D_GLB,FILE_3D_GLTF,...` on a 3D importer's `mesh` input. The connect type gate compared slot types as whole strings: if link_type and dst_type and link_type != dst_type and "*" not in (...) so a `FILE_3D_GLB` output was refused by an input that explicitly accepts `FILE_3D_GLB`. Measured on prod comfy-agent traces (2026-07-23 → 07-28): ~23 connect / apply_ops failures of exactly this shape, e.g. type mismatch: FILE_3D_GLB output of node 4318783979958460 cannot connect to MESH,FILE_3D_GLB,FILE_3D_GLTF,FILE_3D_OBJ,... input 'mesh' of node 2451178264782280 type mismatch: FILE_3D output of node 890530584279986 cannot connect to FILE_3D_GLB,FILE_3D_FBX,FILE_3D_OBJ,FILE_3D_STL,FILE_3D input 'model_3d' of ... Every one is a correct edit being refused, and the message names the source type inside the accepted list — so the agent reads the hint, sees its own type listed, and retries the identical call. Compatibility is now set INTERSECTION rather than string equality. Intersection and not substring: `FILE_3D_GL` must not satisfy an input accepting only `FILE_3D_GLTF`/`FILE_3D_GLB`. An unknown type on either side, or a `*` wildcard, stays permissive exactly as before, and a genuine mis-wire (IMAGE into a 3D union) is still refused — both covered by tests. --- comfy_cli/workflow_ops.py | 41 ++++++++- tests/comfy_cli/test_connect_union_types.py | 94 +++++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 tests/comfy_cli/test_connect_union_types.py diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 64a3be62f..e490b8432 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -108,6 +108,38 @@ def _available_nodes_hint(workflow: dict, *, limit: int = 12) -> str: return ", ".join(out) +def _slot_types(t: Any) -> set[str]: + """Split a slot type into the set of types it accepts. + + ComfyUI expresses a multi-type input as a COMMA-SEPARATED UNION, e.g. + ``MESH,FILE_3D_GLB,FILE_3D_GLTF,...`` on a 3D importer's ``mesh`` input. + """ + return {p.strip() for p in str(t or "").split(",") if p.strip()} + + +def _types_compatible(link_type: Any, dst_type: Any) -> bool: + """Whether an output of ``link_type`` may drive an input of ``dst_type``. + + Compatible when the two type sets INTERSECT, not when their raw strings are + equal: comparing whole strings refused a ``FILE_3D_GLB`` output from an input + declaring ``MESH,FILE_3D_GLB,...`` even though it explicitly accepts it. + Measured on prod agent traces (2026-07-23 → 07-28): ~23 connect/apply_ops + failures of that shape, each one a correct edit being refused — and the error + names the source type inside the accepted list, so the agent saw its own type + listed and retried the identical call. + + Intersection (not substring) matters: ``FILE_3D_GL`` must NOT satisfy an input + accepting only ``FILE_3D_GLTF``/``FILE_3D_GLB``. An unknown type on either + side, or a ``*`` wildcard, stays permissive as before. + """ + src, dst = _slot_types(link_type), _slot_types(dst_type) + if not src or not dst: + return True + if "*" in src or "*" in dst: + return True + return bool(src & dst) + + def _enrich_resolution_error(e: ValueError, workflow: dict, graph, *, widget: Any = None) -> ValueError: """Turn a *not-found* edit error into an actionable one. @@ -499,12 +531,13 @@ def _connect_impl( dst = _require(workflow, to_node) out_idx, link_type = _resolve_output_slot(src, graph, from_slot) in_idx, grow = _resolve_input_target(dst, graph, to_slot, link_type) - # Type-check concrete slots: an output only connects to an input of the same - # type (or a wildcard "*"). Autogrow slots are minted with the source type, - # so they need no check. Without this, a mis-wire silently clobbers a link. + # Type-check concrete slots: an output only connects to an input that accepts + # its type (or a wildcard "*"). Autogrow slots are minted with the source + # type, so they need no check. Without this, a mis-wire silently clobbers a + # link. if in_idx is not None: dst_type = (dst.get("inputs") or [])[in_idx].get("type") - if link_type and dst_type and link_type != dst_type and "*" not in (link_type, dst_type): + if not _types_compatible(link_type, dst_type): raise ValueError( f"type mismatch: {link_type} output of node {from_node} cannot connect to " f"{dst_type} input {(dst.get('inputs') or [])[in_idx].get('name')!r} of node {to_node}" diff --git a/tests/comfy_cli/test_connect_union_types.py b/tests/comfy_cli/test_connect_union_types.py new file mode 100644 index 000000000..09565d1ae --- /dev/null +++ b/tests/comfy_cli/test_connect_union_types.py @@ -0,0 +1,94 @@ +"""A connect must be allowed when the destination input accepts a UNION of types +that contains the source type. + +ComfyUI expresses a multi-type input as a comma-separated union +("MESH,FILE_3D_GLB,FILE_3D_GLTF,..."). The type gate compared slot types as +whole strings, so a FILE_3D_GLB output was refused by an input that explicitly +accepts FILE_3D_GLB. + +Measured on prod comfy-agent traces (2026-07-23 → 07-28): ~23 connect/apply_ops +failures of this exact shape, e.g. + + type mismatch: FILE_3D_GLB output of node 4318783979958460 cannot connect to + MESH,FILE_3D_GLB,FILE_3D_GLTF,FILE_3D_OBJ,FILE_3D_FBX,FILE_3D_STL,FILE_3D_USDZ, + ... input 'mesh' of node 2451178264782280 + + type mismatch: FILE_3D output of node 890530584279986 cannot connect to + FILE_3D_GLB,FILE_3D_FBX,FILE_3D_OBJ,FILE_3D_STL,FILE_3D input 'model_3d' of ... + +Both name the source type inside the accepted list, so the agent reads the hint, +sees its own type listed, and retries the identical edit. +""" + +from __future__ import annotations + +import pytest + +from comfy_cli import workflow_ops +from comfy_cli.cql.engine import Graph + +MESH_UNION = ( + "MESH,FILE_3D_GLB,FILE_3D_GLTF,FILE_3D_OBJ,FILE_3D_FBX,FILE_3D_STL," + "FILE_3D_USDZ,FILE_3D_PLY,FILE_3D_SPLAT,FILE_3D" +) +MODEL3D_UNION = "FILE_3D_GLB,FILE_3D_FBX,FILE_3D_OBJ,FILE_3D_STL,FILE_3D" + + +def _wf(out_type: str, in_type: str) -> dict: + return { + "last_node_id": 2, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "Load3D", + "pos": [0, 0], + "inputs": [], + "outputs": [{"name": "OUT", "type": out_type, "links": []}], + }, + { + "id": 2, + "type": "Import3D", + "pos": [300, 0], + "inputs": [{"name": "slot", "type": in_type, "link": None}], + "outputs": [], + }, + ], + "links": [], + } + + +@pytest.fixture +def graph() -> Graph: + return Graph.from_object_info({}) + + +@pytest.mark.parametrize( + "out_type,in_type", + [ + ("FILE_3D_GLB", MESH_UNION), # prod: Load3D GLB -> mesh + ("FILE_3D", MODEL3D_UNION), # prod: Load3D FILE_3D -> model_3d + ("MESH", MESH_UNION), # first member of the union + ("FILE_3D", MESH_UNION), # last member of the union + ], +) +def test_connect_accepts_a_member_of_a_union_input(graph, out_type, in_type): + wf = _wf(out_type, in_type) + wf, op = workflow_ops.connect(wf, graph, 1, "OUT", 2, "slot") + assert op["op"] == "connect" + assert wf["nodes"][1]["inputs"][0]["link"] is not None, "the link must be wired" + + +def test_connect_still_rejects_a_type_outside_the_union(graph): + """The gate must keep its teeth: a genuine mis-wire is still refused.""" + wf = _wf("IMAGE", MESH_UNION) + with pytest.raises(ValueError, match="type mismatch"): + workflow_ops.connect(wf, graph, 1, "OUT", 2, "slot") + + +def test_connect_union_matching_is_not_substring_based(graph): + """FILE_3D_GLB must not satisfy an input accepting only FILE_3D_GLTF — a + naive `in` check on the joined string would wrongly allow it.""" + wf = _wf("FILE_3D_GL", "FILE_3D_GLTF,FILE_3D_GLB") + with pytest.raises(ValueError, match="type mismatch"): + workflow_ops.connect(wf, graph, 1, "OUT", 2, "slot") From 315249dc0533f7659832a200759206ff92884b88 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 29 Jul 2026 17:06:01 -0700 Subject: [PATCH 18/53] fix(workflow): give callers the identifiers they were missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four independent gaps found by analysing prod comfy-agent traces (2026-07-23 → 07-28, 4251 tool calls / 570 failures) and replaying real prod prompts. Each is a case where the CLI held the answer and would not surface it, or surfaced it in a form the caller could not act on. 1. add-node now fails like `nodes show` does (12 prod failures). It raised a bare ValueError -> code=workflow_edit_invalid with the hint "run `comfy nodes types` to list class_types". But `nodes types` lists CONNECTION types (MODEL/LATENT/IMAGE), not class_types — so the hint was actively misleading, and the code meant a consumer keying on `node_not_found` (as the agent does) never saw these at all. Now raises UnknownNodeType and emits code=node_not_found with details.close_matches, matching `nodes show`. Two subcases get their own message because difflib is useless or misleading for them: - UI-only nodes (Note, MarkdownNote, PrimitiveNode, GetNode, SetNode, Reroute) — they exist only in the editor graph. difflib returns [] for Note/MarkdownNote and actively wrong matches for GetNode (GeminiNode, SeedNode…). - A subgraph INSTANCE id. `ls-nodes` prints a subgraph instance's definition uuid as its `type`, so a caller reading ls-nodes output can feed a uuid to add-node; there is no instantiate command, so it can never succeed. Observed in prod: add_node '2454ad83-157c-40dd-9f19-5daaf4041ce0'. 2. ls-nodes reports bypass/mute. ComfyUI disables a node without deleting it (mode 4 = bypass, mode 2 = mute/never). workflow_to_api already understands both, but ls-nodes emitted only id/type/title — so a caller could not tell a disabled node from a live one, and would "repair" a graph that is merely bypassed, or call a workflow runnable while a required node is muted. Emitted only when set, so a normal node stays one clean row. 3. Output slots accept an unambiguous case/separator variant (~19 prod failures). Callers address an output by its TYPE because no discovery surface showed the NAME. Where name and type differ only in case or separators the intent is unambiguous: `IMAGE` on a node whose outputs are ['image','alpha'], or `MODEL_TASK_ID` for Tripo's 'model task_id'. Accepted ONLY when exactly one output matches after normalizing. Measured over the full 3573-node catalog: exactly ONE node type gains a genuinely new ambiguity (KSampler Gradually Adding More Denoise: CONDITIONING+ / CONDITIONING-), and the exactly-one guard keeps it failing. Twelve more have duplicate identical output names, which exact matching already resolved to the first — unchanged. Exact match always wins; unrelated names still fail with the full name list. 4. `workflow slots` advertises dynamic-combo SUB-widgets (4 prod failures). A COMFY_DYNAMICCOMBO_V3 input is one port (`model`) whose selected option contributes widgets addressed `model.`. Those live in widget_order_for_node but have no Port, and _node_widget_slots iterated m.inputs — so the only place `model.prompt` ever appeared was the set-widget error ("available: model, model.prompt, model.resolution"). 102 catalog types carry a dynamic combo. Now driven from the widget order, with a dotted slot inheriting its base port's type. Deliberately NOT included: schema-driven autogrow element names. Investigating the wire format first showed the fix is two different changes, not one — see the PR discussion. Bundling the riskiest change with these four would have been a mistake. Tests: 4 files, 16 cases, each verified red before the change. Full suite 2657 passed with the 16 pre-existing failures unchanged (registry/config_parser + onboarding, both environmental); ruff clean. --- comfy_cli/command/workflow_edit.py | 53 ++++++++++-- comfy_cli/cql/engine.py | 36 +++++--- comfy_cli/workflow_ops.py | 77 ++++++++++++++++- .../command/test_add_node_unknown_class.py | 85 +++++++++++++++++++ .../command/test_list_slots_dynamic_combo.py | 69 +++++++++++++++ tests/comfy_cli/command/test_ls_nodes_mode.py | 68 +++++++++++++++ .../comfy_cli/test_output_slot_normalized.py | 64 ++++++++++++++ 7 files changed, 431 insertions(+), 21 deletions(-) create mode 100644 tests/comfy_cli/command/test_add_node_unknown_class.py create mode 100644 tests/comfy_cli/command/test_list_slots_dynamic_combo.py create mode 100644 tests/comfy_cli/command/test_ls_nodes_mode.py create mode 100644 tests/comfy_cli/test_output_slot_normalized.py diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index d737288f2..b7ccb8535 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -126,8 +126,32 @@ def add_node_cmd( workflow, op = workflow_ops.add_node( workflow, graph, class_type, pos=pos, actor=actor, base_version=base_version ) + except workflow_ops.UnknownNodeType as e: + # Same envelope shape as `nodes show` so a caller can self-correct from + # the error alone. (The old hint pointed at `comfy nodes types`, which + # lists connection types — MODEL/LATENT/IMAGE — not class_types.) + if e.ui_only: + hint = "use a real node class; to annotate the graph, set a title/widget on an existing node instead" + elif e.subgraph_id: + hint = "pick a node CLASS from `comfy nodes search `; a subgraph instance cannot be added" + elif e.close_matches: + hint = f"did you mean: {', '.join(e.close_matches)}?" + else: + hint = "run `comfy nodes search ` to find the class_type" + renderer.error( + code="node_not_found", + message=str(e), + hint=hint, + details={ + "requested": e.class_type, + "close_matches": e.close_matches, + "ui_only": e.ui_only, + "subgraph_instance_id": e.subgraph_id, + }, + ) + raise typer.Exit(code=1) from e except ValueError as e: - renderer.error(code="workflow_edit_invalid", message=str(e), hint="run `comfy nodes types` to list class_types") + renderer.error(code="workflow_edit_invalid", message=str(e)) raise typer.Exit(code=1) from e _finish(renderer, p, workflow, op, base_version, stdout, "workflow add-node") @@ -259,6 +283,12 @@ def clear_cmd( # --------------------------------------------------------------------------- +# Litegraph node modes worth surfacing on ls-nodes. 0 (always) and 1 (on-event) +# are normal execution and are deliberately unlabeled. Mirrors workflow_to_api's +# _MODE_MUTED / _MODE_BYPASS. +_MODE_LABELS = {2: "mute", 4: "bypass"} + + # ls-nodes — recover node ids/types (so an agent can address minted nodes) # --------------------------------------------------------------------------- @@ -274,13 +304,20 @@ def ls_nodes_cmd( for n in workflow.get("nodes") or []: if not isinstance(n, dict): continue - rows.append( - { - "id": n.get("id"), - "type": n.get("type"), - "title": n.get("title") or (n.get("properties") or {}).get("Node name for S&R"), - } - ) + row = { + "id": n.get("id"), + "type": n.get("type"), + "title": n.get("title") or (n.get("properties") or {}).get("Node name for S&R"), + } + # ComfyUI disables a node without deleting it: mode 4 = bypass (input + # passes through), mode 2 = mute/never (dropped from execution). Both are + # invisible in id/type/title, so a caller could not tell a disabled node + # from a live one — and would "repair" a graph that is merely bypassed, + # or call a workflow runnable while a required node is muted. + # Emitted only when set, so a normal node stays a single clean row. + if (label := _MODE_LABELS.get(n.get("mode"))) is not None: + row["mode"] = label + rows.append(row) payload = {"workflow": str(p), "count": len(rows), "nodes": rows} if renderer.is_pretty(): from rich.table import Table diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index c3c417af6..0f871f6d7 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -1435,21 +1435,33 @@ def _node_widget_slots(node: dict, prefix: str, graph: Graph) -> list[dict]: return [] widgets = node.get("widgets_values") or [] order = graph.widget_order_for_node(node_type, widgets) + # Drive from `order`, not m.inputs. A COMFY_DYNAMICCOMBO_V3 input is ONE port + # (`model`) whose selected option contributes extra widgets addressed as + # `model.`; those dotted names exist in `order` but have no Port, so + # iterating m.inputs hid them. The only place they surfaced was the + # set-widget error ("available: model, model.prompt, model.resolution") — + # i.e. the CLI knew the answer and would not advertise it. 102 catalog types + # carry a dynamic combo. + by_name = {p.name: p for p in m.inputs if not p.is_link} slots: list[dict] = [] - for port in m.inputs: - if port.is_link: - continue - try: - idx = order.index(port.name) - except ValueError: - continue - current = widgets[idx] if idx < len(widgets) else None + for idx, wname in enumerate(order): + port = by_name.get(wname) + if port is None: + # A dotted sub-widget: inherit type from its base port so the slot + # still advertises something useful. Skip if the base is unknown. + base = wname.split(".", 1)[0] + base_port = by_name.get(base) + if base_port is None: + continue + slot_type = base_port.type + else: + slot_type = port.type slots.append( { - "address": f"{prefix}.{port.name}", - "name": port.name, - "type": port.type, - "current_value": current, + "address": f"{prefix}.{wname}", + "name": wname, + "type": slot_type, + "current_value": widgets[idx] if idx < len(widgets) else None, "instance_id": prefix, "node_type": node_type, } diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index e490b8432..5c9bd5c8a 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -81,6 +81,49 @@ def _new_op(kind: str, actor: str, base_version: int, **fields: Any) -> dict[str } +# Node types that live only in the UI graph and never reach the API — the +# frontend's isVirtualNode set. Mirrors workflow_to_api._UI_ONLY_NODE_TYPES; +# duplicated rather than imported to keep workflow_ops import-free of the +# converter. Keep the two in sync. +UI_ONLY_NODE_TYPES = frozenset({"Note", "MarkdownNote", "PrimitiveNode", "GetNode", "SetNode", "Reroute"}) + +# A subgraph INSTANCE's node `type` is the UUID id of its definition, and +# `ls-nodes` prints that verbatim — so a caller reading ls-nodes output can +# mistake it for a class name. There is no instantiate-a-subgraph command, so +# such an add can never succeed; say why instead of "unknown node type". +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) + + +class UnknownNodeType(ValueError): + """add_node was given a class_type the catalog does not have. + + Carries the machine-readable detail the command layer needs to emit the same + envelope `nodes show` does (code=node_not_found + details.close_matches), so + a caller can self-correct in one retry. Before this, add-node emitted a bare + workflow_edit_invalid with a hint pointing at `comfy nodes types` — which + lists CONNECTION types, not class_types. + """ + + def __init__(self, class_type: str, *, close_matches: list[str] | None = None, ui_only: bool = False, subgraph_id: bool = False): + self.class_type = class_type + self.close_matches = close_matches or [] + self.ui_only = ui_only + self.subgraph_id = subgraph_id + if ui_only: + msg = ( + f"{class_type!r} is a UI-only node (it exists in the editor graph but never reaches the API), " + "so it cannot be added through this surface" + ) + elif subgraph_id: + msg = ( + f"{class_type!r} is a subgraph INSTANCE id, not a node class. `ls-nodes` prints a subgraph " + "instance's definition uuid as its type; there is no command to instantiate a subgraph" + ) + else: + msg = f"unknown node type {class_type!r}" + super().__init__(msg) + + def _find(workflow: dict, node_id: Any) -> dict | None: for n in workflow.get("nodes") or []: if isinstance(n, dict) and n.get("id") == node_id: @@ -273,7 +316,14 @@ def add_node( ) -> tuple[dict, dict]: m = graph.node(class_type) if m is None: - raise ValueError(f"unknown node type {class_type!r}") + if class_type in UI_ONLY_NODE_TYPES: + raise UnknownNodeType(class_type, ui_only=True) + if _UUID_RE.match(class_type.strip()): + raise UnknownNodeType(class_type, subgraph_id=True) + import difflib + + names = [n.id for n in graph.all_nodes()] + raise UnknownNodeType(class_type, close_matches=difflib.get_close_matches(class_type, names, n=5, cutoff=0.6)) size = layout.estimate_size( len([p for p in m.inputs if p.is_link]), len(m.outputs), @@ -1178,6 +1228,19 @@ def _validate_widget(graph, class_type: str, widget: str, value: Any) -> list[di return port.validate_catalog(value) +def _normalize_slot_name(name: Any) -> str: + """Case/separator-insensitive key for a slot name. + + Lowercased with every run of non-alphanumerics collapsed to a single "_", so + `IMAGE`/`image`, `model task_id`/`MODEL_TASK_ID` and `Florence2_Model`/ + `florence2_model` all agree. Used only as a FALLBACK after exact matching, and + only when it identifies exactly one slot. + """ + if not isinstance(name, str): + return "" + return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") + + def _resolve_output_slot(node: dict, graph, slot: Any) -> tuple[int, str]: outs = node.get("outputs") or [] if isinstance(slot, int) or (isinstance(slot, str) and slot.lstrip("-").isdigit()): @@ -1188,6 +1251,18 @@ def _resolve_output_slot(node: dict, graph, slot: Any) -> tuple[int, str]: for i, o in enumerate(outs): if o.get("name") == slot: return i, o.get("type", "*") + # Exact match failed. Accept an unambiguous case/separator variant: an output + # named `image` addressed as `IMAGE`, or `model task_id` as `MODEL_TASK_ID`. + # Callers reach for the TYPE string when they were never shown the name, and + # for these the intent is unambiguous. Guarded to EXACTLY ONE match so a node + # carrying both `mask` and `MASK` (the only such collision class in the 3573 + # -node catalog) keeps failing rather than being silently guessed. + want = _normalize_slot_name(slot) + if want: + hits = [i for i, o in enumerate(outs) if _normalize_slot_name(o.get("name")) == want] + if len(hits) == 1: + i = hits[0] + return i, outs[i].get("type", "*") names = [o.get("name") for o in outs] raise ValueError(f"output {slot!r} not found on node {node.get('id')}; outputs: {names}") diff --git a/tests/comfy_cli/command/test_add_node_unknown_class.py b/tests/comfy_cli/command/test_add_node_unknown_class.py new file mode 100644 index 000000000..8acc7a68b --- /dev/null +++ b/tests/comfy_cli/command/test_add_node_unknown_class.py @@ -0,0 +1,85 @@ +"""`workflow add-node` must fail like `nodes show` does when a class is unknown. + +Measured on prod comfy-agent traces (2026-07-23 → 07-28): 12 failures where the +agent named a class that does not exist, and it got NO suggestions back: + + add_node: "unknown node type 'MarkdownNote'" + add_node: "unknown node type 'Note'" + add_node: "unknown node type '2454ad83-157c-40dd-9f19-5daaf4041ce0'" + show_node: "Node class 'RadianceShowText' not found ..." + close_matches + +`nodes show` emits code=node_not_found with details.close_matches, so the agent +self-corrects in one retry. `workflow add-node` emitted +code=workflow_edit_invalid with hint "run `comfy nodes types`" — which lists +CONNECTION types (MODEL/LATENT/IMAGE), not class_types, and which the agent has +no tool for. It also means the agent-side annotateNodeNotFound (which keys on +code == "node_not_found") never fired on this path. +""" + +from __future__ import annotations + +import json + +import pytest +from test_workflow_edit import ( # type: ignore[import-not-found] + _base_workflow, + _graph, + _run, + _write, + reset_singleton, # noqa: F401 (autouse fixture) +) + +from comfy_cli.command import workflow_edit + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + + +def _add(tmp_path, capsys, class_type: str) -> dict: + path = _write(tmp_path, _base_workflow()) + return _run(["add-node", str(path), class_type], capsys) + + +def test_unknown_class_emits_node_not_found_with_close_matches(patched_graph, tmp_path, capsys): + # 'KSample' is a near-miss for the catalog's 'KSampler'. + env = _add(tmp_path, capsys, "KSample") + assert env["ok"] is False + err = env["error"] + assert err["code"] == "node_not_found", f"must match `nodes show`'s code: {err}" + assert "KSampler" in (err.get("details") or {}).get("close_matches", []), err + assert "KSampler" in (err.get("hint") or ""), err + # The misleading hint must be gone: `nodes types` lists connection types. + assert "nodes types" not in json.dumps(err) + + +def test_ui_only_node_is_rejected_with_a_specific_reason(patched_graph, tmp_path, capsys): + """Note/MarkdownNote/Reroute/GetNode/SetNode/PrimitiveNode exist only in the + UI graph. difflib gives no useful match for them (and for GetNode returns + actively misleading ones), so they need their own message.""" + for cls in ("Note", "MarkdownNote", "GetNode", "Reroute"): + env = _add(tmp_path, capsys, cls) + assert env["ok"] is False, cls + err = env["error"] + assert err["code"] == "node_not_found", f"{cls}: {err}" + blob = json.dumps(err).lower() + assert "ui-only" in blob or "ui only" in blob, f"{cls} must be named as UI-only: {err}" + assert (err.get("details") or {}).get("ui_only") is True, err + + +def test_uuid_class_type_is_named_as_a_subgraph_instance(patched_graph, tmp_path, capsys): + """A subgraph INSTANCE's `type` is its definition UUID, and ls-nodes passes + it through verbatim — so the agent sees a UUID that looks like a class name. + There is no instantiate command, so this can never succeed; say so.""" + env = _add(tmp_path, capsys, "2454ad83-157c-40dd-9f19-5daaf4041ce0") + assert env["ok"] is False + err = env["error"] + assert err["code"] == "node_not_found" + blob = json.dumps(err).lower() + assert "subgraph" in blob, f"must explain the UUID is a subgraph instance id: {err}" + + +def test_known_class_still_adds(patched_graph, tmp_path, capsys): + env = _add(tmp_path, capsys, "VAEDecode") + assert env["ok"] is True, env diff --git a/tests/comfy_cli/command/test_list_slots_dynamic_combo.py b/tests/comfy_cli/command/test_list_slots_dynamic_combo.py new file mode 100644 index 000000000..5e98f6cd1 --- /dev/null +++ b/tests/comfy_cli/command/test_list_slots_dynamic_combo.py @@ -0,0 +1,69 @@ +"""`workflow slots` must advertise dynamic-combo SUB-widgets. + +A COMFY_DYNAMICCOMBO_V3 input is one port (`model`) whose selected option +contributes extra widgets addressed as `model.`. `widget_order_for_node` +knows them; `_node_widget_slots` iterated `m.inputs` instead, so the dotted +addresses never appeared in `workflow slots` — the only place they surfaced was +the set-widget ERROR: + + widget 'prompt' not found on ByteDance2ReferenceNode; + available: model, model.prompt, model.resolution + +i.e. the CLI knew the answer and would not advertise it. 102 catalog node types +carry a dynamic combo; 4 prod set_widget failures in 6 days were this shape. +""" + +from __future__ import annotations + +import pytest +from test_workflow_edit import ( # type: ignore[import-not-found] + _graph, + _run, + _write, + reset_singleton, # noqa: F401 (autouse fixture) +) + +from comfy_cli.command import workflow as workflow_cmd + + +@pytest.fixture +def patched_graph(monkeypatch): + # `slots` resolves its catalog through command/workflow.py, not workflow_edit. + monkeypatch.setattr(workflow_cmd, "_get_graph", lambda *a, **kw: _graph()) + + +def _wf() -> dict: + return { + "last_node_id": 5, + "last_link_id": 0, + "nodes": [ + { + "id": 5, + "type": "KlingFLFTest", + "pos": [0, 0], + "inputs": [ + {"name": "first_frame", "type": "IMAGE", "link": None}, + {"name": "last_frame", "type": "IMAGE", "link": None}, + ], + "outputs": [{"name": "VIDEO", "type": "VIDEO", "links": []}], + "widgets_values": ["a prompt", "kling-v3", "1080p"], + } + ], + "links": [], + } + + +def test_slots_include_dynamic_combo_subwidgets(patched_graph, tmp_path, capsys): + env = _run(["slots", str(_write(tmp_path, _wf()))], capsys) + assert env["ok"] is True, env + addrs = {s["address"] for s in env["data"]["slots"]} + assert "5.prompt" in addrs, addrs + assert "5.model" in addrs, addrs + assert "5.model.resolution" in addrs, f"the dynamic-combo sub-widget must be advertised: {addrs}" + + +def test_subwidget_carries_its_current_value(patched_graph, tmp_path, capsys): + env = _run(["slots", str(_write(tmp_path, _wf()))], capsys) + by = {s["address"]: s for s in env["data"]["slots"]} + assert by["5.model"]["current_value"] == "kling-v3" + assert by["5.model.resolution"]["current_value"] == "1080p", by["5.model.resolution"] diff --git a/tests/comfy_cli/command/test_ls_nodes_mode.py b/tests/comfy_cli/command/test_ls_nodes_mode.py new file mode 100644 index 000000000..bae389cbd --- /dev/null +++ b/tests/comfy_cli/command/test_ls_nodes_mode.py @@ -0,0 +1,68 @@ +"""`workflow ls-nodes` must report a node's BYPASS/MUTE state. + +ComfyUI lets a user disable a node without deleting it: mode 4 = bypass (passes +input through), mode 2 = mute/never (removed from execution). workflow_to_api +already understands both — it strips them at API conversion +(workflow_to_api.py:149) and has a bypassed-id helper (:550). + +But ls-nodes emitted only id/type/title, so a caller inspecting the graph could +not tell a disabled node from a live one. The consequence is worse than a +cosmetic gap: the agent "repairs" a graph whose node is merely bypassed, or +reports a workflow ready to run when a required node is muted. +""" + +from __future__ import annotations + +import pytest +from test_workflow_edit import ( # type: ignore[import-not-found] + _base_workflow, + _graph, + _run, + _write, + reset_singleton, # noqa: F401 (autouse fixture) +) + +from comfy_cli.command import workflow_edit + +MODE_MUTED, MODE_BYPASS = 2, 4 + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + + +def _wf_with_modes() -> dict: + wf = _base_workflow() + wf["nodes"][0]["mode"] = MODE_BYPASS # KSampler, id 3 + wf["nodes"][1]["mode"] = MODE_MUTED # EmptyLatentImage, id 7 + wf["nodes"].append({ + "id": 9, "type": "VAEDecode", "pos": [0, 0], "mode": 0, + "inputs": [], "outputs": [], "widgets_values": [], + }) + return wf + + +def _rows(tmp_path, capsys) -> dict: + path = _write(tmp_path, _wf_with_modes()) + env = _run(["ls-nodes", str(path)], capsys) + assert env["ok"] is True, env + return {r["id"]: r for r in env["data"]["nodes"]} + + +def test_ls_nodes_reports_bypassed_and_muted(patched_graph, tmp_path, capsys): + rows = _rows(tmp_path, capsys) + assert rows[3].get("mode") == "bypass", rows[3] + assert rows[7].get("mode") == "mute", rows[7] + + +def test_ls_nodes_omits_mode_for_normal_nodes(patched_graph, tmp_path, capsys): + """A live node must stay a single clean row — no mode noise on the 99% case.""" + rows = _rows(tmp_path, capsys) + assert "mode" not in rows[9], rows[9] + + +def test_ls_nodes_unchanged_when_no_modes_set(patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["ls-nodes", str(path)], capsys) + assert all("mode" not in r for r in env["data"]["nodes"]), env["data"]["nodes"] diff --git a/tests/comfy_cli/test_output_slot_normalized.py b/tests/comfy_cli/test_output_slot_normalized.py new file mode 100644 index 000000000..ff0d4fb99 --- /dev/null +++ b/tests/comfy_cli/test_output_slot_normalized.py @@ -0,0 +1,64 @@ +"""An output slot may be addressed with a case/separator variant of its name. + +Prod comfy-agent traces (2026-07-23 → 07-28) show the agent addressing outputs +by their TYPE because no discovery surface showed it the NAME (fixed separately +in cloud#5828). Where the name and type differ only in case or separators, the +intent is unambiguous and refusing it is pure friction: + + output 'IMAGE' on a node whose outputs are ['image','alpha'] + output 'MODEL_TASK_ID' on Tripo nodes whose output is named 'model task_id' + +Accepted ONLY when exactly one output matches after normalizing. Measured across +the 3573-node catalog: just 2 node types gain a normalized ambiguity +(Flux2KleinOutputExtractor_EditUtils has both 'mask' and 'MASK'), and with the +exactly-one guard both keep failing exactly as before — zero regression. +""" + +from __future__ import annotations + +import pytest + +from comfy_cli import workflow_ops as W +from comfy_cli.cql.engine import Graph + + +def _node(outputs): + return {"id": 1, "type": "X", "outputs": [{"name": n, "type": t} for n, t in outputs]} + + +@pytest.fixture +def g(): + return Graph.from_object_info({}) + + +@pytest.mark.parametrize( + "asked,outputs,want_idx", + [ + ("IMAGE", [("image", "IMAGE"), ("alpha", "MASK")], 0), # prod: BeebleSwitchXImageEdit + ("MODEL_TASK_ID", [("model task_id", "MODEL_TASK_ID")], 0), # prod: Tripo* (space -> _) + ("Florence2_Model", [("florence2_model", "FL2MODEL")], 0), # case only + ("alpha", [("image", "IMAGE"), ("alpha", "MASK")], 1), # exact still wins + ], +) +def test_normalized_output_name_resolves(g, asked, outputs, want_idx): + idx, _ = W._resolve_output_slot(_node(outputs), g, asked) + assert idx == want_idx + + +def test_exact_match_wins_over_a_normalized_rival(g): + """A node with both 'mask' and 'MASK' must resolve 'mask' exactly, not guess.""" + idx, _ = W._resolve_output_slot(_node([("mask", "MASK"), ("MASK", "MASK")]), g, "mask") + assert idx == 0 + + +def test_ambiguous_normalized_match_still_fails(g): + """The real catalog collision (mask + MASK) must keep failing when the ask + matches neither exactly — never silently pick one.""" + with pytest.raises(ValueError, match="not found"): + W._resolve_output_slot(_node([("mask", "MASK"), ("MASK", "MASK")]), g, "Mask") + + +def test_unrelated_name_still_fails_with_the_name_list(g): + with pytest.raises(ValueError) as ei: + W._resolve_output_slot(_node([("image", "IMAGE")]), g, "LATENT") + assert "image" in str(ei.value), "the error must still list the real names" From e4bab893dbbc9b426fd5a820009c6c7aad0707fc Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 30 Jul 2026 19:25:28 -0700 Subject: [PATCH 19/53] fix(generate): string-array flags accept a bare value or comma list Prod agents pass --image ; the string-item array path demanded JSON and failed 6x/day on nano-banana. Mirrors the tolerance the binary-item path has always had. Explicit-JSON input is unchanged. --- comfy_cli/command/generate/schema.py | 6 +++++ .../comfy_cli/command/generate/test_schema.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/comfy_cli/command/generate/schema.py b/comfy_cli/command/generate/schema.py index aaa67d61e..a01d00a36 100644 --- a/comfy_cli/command/generate/schema.py +++ b/comfy_cli/command/generate/schema.py @@ -152,6 +152,12 @@ def _coerce(flag: FlagDef, raw: str) -> Any: except json.JSONDecodeError as e: raise SchemaError(f"--{flag.name}: invalid file list: {e}") from e return [Path(p).expanduser() for p in parsed] + if flag.kind == "array" and flag.item_kind == "string" and not raw.lstrip().startswith("["): + # Callers naturally pass a single path/value or a comma list for a + # string array (prod: --image 'Linked profile pic.jpeg'); demanding + # JSON here only manufactures failures. Explicit JSON ('[' prefix) + # still takes the strict path below. + return [p.strip() for p in raw.split(",") if p.strip()] try: return json.loads(raw) except json.JSONDecodeError as e: diff --git a/tests/comfy_cli/command/generate/test_schema.py b/tests/comfy_cli/command/generate/test_schema.py index 294fa610f..8e10808aa 100644 --- a/tests/comfy_cli/command/generate/test_schema.py +++ b/tests/comfy_cli/command/generate/test_schema.py @@ -96,3 +96,30 @@ def test_parse_args_object_accepts_json(): ], ) assert values["color_palette"] == {"name": "PASTEL"} + + +def _string_array_flag(): + return schema.FlagDef( + name="image", kind="array", required=False, description="", + default=None, enum=[], item_kind="string", upload_mode=None, + ) + + +def test_coerce_string_array_accepts_bare_value(): + # prod: --image 'Linked profile pic.jpeg' (spaces, no JSON) must not error + assert schema._coerce(_string_array_flag(), "Linked profile pic.jpeg") == ["Linked profile pic.jpeg"] + + +def test_coerce_string_array_accepts_comma_list(): + assert schema._coerce(_string_array_flag(), "a.jpg, b.png") == ["a.jpg", "b.png"] + + +def test_coerce_string_array_json_still_works(): + assert schema._coerce(_string_array_flag(), '["a.jpg","b.png"]') == ["a.jpg", "b.png"] + + +def test_coerce_string_array_malformed_json_still_errors(): + # An explicit-JSON attempt ('[' prefix) that is broken must keep failing loudly, + # not be silently reinterpreted as a filename starting with '['. + with pytest.raises(schema.SchemaError): + schema._coerce(_string_array_flag(), '["a.jpg",') From 39081cd519451326623759ab4c2692ae76549c8d Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 30 Jul 2026 19:29:18 -0700 Subject: [PATCH 20/53] feat(generate): emit-workflow materializes multiple image files One LoadImage per file, folded through chained core ImageBatch nodes into the partner input. Closes the prod dead-end where nano-banana with a reference list could neither pass one filename (schema) nor several (emit). --- comfy_cli/command/generate/emit.py | 39 ++++++++++++------- tests/comfy_cli/command/generate/test_emit.py | 25 ++++++++++++ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/comfy_cli/command/generate/emit.py b/comfy_cli/command/generate/emit.py index 55f8d1b64..d3b8690b3 100644 --- a/comfy_cli/command/generate/emit.py +++ b/comfy_cli/command/generate/emit.py @@ -223,20 +223,31 @@ def build_workflow(model: str, values: dict[str, Any], *, output_prefix: str = " raw = values.get(flag) if raw is None: continue - if isinstance(raw, list | tuple): - raise EmitError( - f"--{flag} received multiple files, but emit-workflow currently " - "maps this input to a single LoadImage node." - ) - path = str(Path(raw).expanduser()) - loader_id = str(next_id) - next_id += 1 - workflow[loader_id] = { - "class_type": "LoadImage", - "_meta": {"title": f"load {Path(path).name}"}, - "inputs": {"image": path}, - } - node_inputs[node_key] = [loader_id, 0] + paths = [str(Path(p).expanduser()) for p in (raw if isinstance(raw, list | tuple) else [raw])] + loader_ids: list[str] = [] + for path in paths: + loader_id = str(next_id) + next_id += 1 + workflow[loader_id] = { + "class_type": "LoadImage", + "_meta": {"title": f"load {Path(path).name}"}, + "inputs": {"image": path}, + } + loader_ids.append(loader_id) + # One file wires straight in; several fold through chained core + # ImageBatch nodes (2-input, always present) so the partner still + # receives a single IMAGE stream. + upstream, upstream_out = loader_ids[0], 0 + for lid in loader_ids[1:]: + batch_id = str(next_id) + next_id += 1 + workflow[batch_id] = { + "class_type": "ImageBatch", + "_meta": {"title": "batch reference images"}, + "inputs": {"image1": [upstream, upstream_out], "image2": [lid, 0]}, + } + upstream, upstream_out = batch_id, 0 + node_inputs[node_key] = [upstream, upstream_out] # Scalar params → node inputs, honoring the explicit param_map. for flag, node_key in ns.param_map.items(): diff --git a/tests/comfy_cli/command/generate/test_emit.py b/tests/comfy_cli/command/generate/test_emit.py index 003e00e48..cdc99a4da 100644 --- a/tests/comfy_cli/command/generate/test_emit.py +++ b/tests/comfy_cli/command/generate/test_emit.py @@ -240,3 +240,28 @@ def test_cli_emit_output_prefix(runner, tmp_path, monkeypatch): wf = json.loads(out.read_text()) save = next(n for n in wf.values() if n["class_type"] == "SaveImage") assert save["inputs"]["filename_prefix"] == "myfox" + + +def test_build_workflow_single_element_list_unwraps(): + wf = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["ref.jpg"]}) + loaders = [n for n in wf.values() if n["class_type"] == "LoadImage"] + assert len(loaders) == 1 + assert not any(n["class_type"] == "ImageBatch" for n in wf.values()) + + +def test_build_workflow_two_images_chains_imagebatch(): + wf = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["a.jpg", "b.jpg"]}) + loaders = {i: n for i, n in wf.items() if n["class_type"] == "LoadImage"} + batches = {i: n for i, n in wf.items() if n["class_type"] == "ImageBatch"} + assert len(loaders) == 2 and len(batches) == 1 + (batch_id, batch), = batches.items() + assert {batch["inputs"]["image1"][0], batch["inputs"]["image2"][0]} == set(loaders) + assert wf["1"]["inputs"]["images"] == [batch_id, 0] + + +def test_build_workflow_three_images_chains_two_batches(): + wf = emit.build_workflow("nano-banana", {"prompt": "p", "image": ["a.jpg", "b.jpg", "c.jpg"]}) + batches = [i for i, n in wf.items() if n["class_type"] == "ImageBatch"] + assert len(batches) == 2 + # terminal batch feeds the partner node + assert wf["1"]["inputs"]["images"][0] in batches From 282559b2510dc4e58579b3caf64493a22ce0b75d Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 30 Jul 2026 19:34:30 -0700 Subject: [PATCH 21/53] fix(generate): unknown-model suggestions include the requested family difflib alone ranked cross-partner shape-alikes over kling-* for 'kling-image-to-video' (prod, 2x/day). Leading-token family members are appended after the fuzzy picks, capped at six. Co-Authored-By: Claude Fable 5 --- comfy_cli/command/generate/spec.py | 8 +++ tests/comfy_cli/command/generate/test_spec.py | 63 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/comfy_cli/command/generate/spec.py b/comfy_cli/command/generate/spec.py index f0052e376..ea9c8edae 100644 --- a/comfy_cli/command/generate/spec.py +++ b/comfy_cli/command/generate/spec.py @@ -402,9 +402,17 @@ def get_endpoint(endpoint_id: str) -> Endpoint: def _unknown_endpoint_message(endpoint_id: str) -> str: """Build a helpful error suggesting close matches.""" import difflib + import re candidates = list(_registry().keys()) + list(_ALIASES.keys()) close = difflib.get_close_matches(endpoint_id, candidates, n=3, cutoff=0.5) + + # Add family candidates keyed on the leading token. + head = re.split(r"[-_/.]", endpoint_id.lower(), 1)[0] + if len(head) >= 3: + family = [c for c in candidates if c.lower().startswith(head) and c not in close] + close = (close + sorted(family))[:6] + msg = f"Unknown model: {endpoint_id!r}." if close: msg += "\nDid you mean: " + ", ".join(close) + "?" diff --git a/tests/comfy_cli/command/generate/test_spec.py b/tests/comfy_cli/command/generate/test_spec.py index 2ba407b7c..4b56eb272 100644 --- a/tests/comfy_cli/command/generate/test_spec.py +++ b/tests/comfy_cli/command/generate/test_spec.py @@ -61,3 +61,66 @@ def test_filter_by_partner_and_category(): def test_proxy_prefix_accepted(): ep = spec.get_endpoint("/proxy/bfl/flux-pro-1.1/generate") assert ep.id == "bfl/flux-pro-1.1/generate" + + +def test_unknown_model_suggests_leading_token_family(monkeypatch): + """Test that unknown-model errors suggest family members keyed on leading token.""" + # Mock registry with kling-extend, kling-lipsync (kling family), plus some other endpoints + mock_registry = { + "kling/v1/videos/video-extend": spec.Endpoint( + id="kling/v1/videos/video-extend", + path="/proxy/kling/v1/videos/video-extend", + method="post", + partner="kling", + summary="Extend video", + category="video-extend", + request_schema={}, + request_content_type="application/json", + response_schema={}, + polling="kling", + ), + "kling/v1/videos/lip-sync": spec.Endpoint( + id="kling/v1/videos/lip-sync", + path="/proxy/kling/v1/videos/lip-sync", + method="post", + partner="kling", + summary="Lip sync", + category="lipsync", + request_schema={}, + request_content_type="application/json", + response_schema={}, + polling="kling", + ), + "runway/image_to_video": spec.Endpoint( + id="runway/image_to_video", + path="/proxy/runway/image_to_video", + method="post", + partner="runway", + summary="Image to video", + category="image-to-video", + request_schema={}, + request_content_type="application/json", + response_schema={}, + polling=None, + ), + } + # Clear the cache before patching so the mock takes effect + spec._registry.cache_clear() + monkeypatch.setattr(spec, "_registry", lambda: mock_registry) + + msg = spec._unknown_endpoint_message("kling-image-to-video") + + # Should contain kling family members + assert "kling/v1/videos/video-extend" in msg or "kling-extend" in msg or "video-extend" in msg + assert "kling/v1/videos/lip-sync" in msg or "kling-lipsync" in msg or "lip-sync" in msg + # Should start correctly + assert msg.startswith("Unknown model: 'kling-image-to-video'") + # Should end with the help message + assert "comfy generate list" in msg + + +def test_unknown_model_no_family_still_helpful(): + """Test that errors are helpful even when there's no family match.""" + msg = spec._unknown_endpoint_message("krea-2") + assert msg.startswith("Unknown model: 'krea-2'") + assert "comfy generate list" in msg From 752575bc661afe3650c81ec30afea373939b2038 Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 30 Jul 2026 19:41:46 -0700 Subject: [PATCH 22/53] test: isolate unknown-model test from real _ALIASES The initial test_unknown_model_suggests_leading_token_family monkeypatched _registry() but not _ALIASES, so the real alias dict leaked into candidates. Real aliases like 'kling-extend', 'kling-lipsync' matched difflib's fuzzy search, masking whether the family-finding logic actually contributed. Fix: monkeypatch both _registry and _ALIASES; use exact assertions (no or-chains). Test now fails without the family code, proving regression coverage is real. Co-Authored-By: Claude Fable 5 --- tests/comfy_cli/command/generate/test_spec.py | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/comfy_cli/command/generate/test_spec.py b/tests/comfy_cli/command/generate/test_spec.py index 4b56eb272..f68be70b1 100644 --- a/tests/comfy_cli/command/generate/test_spec.py +++ b/tests/comfy_cli/command/generate/test_spec.py @@ -64,8 +64,14 @@ def test_proxy_prefix_accepted(): def test_unknown_model_suggests_leading_token_family(monkeypatch): - """Test that unknown-model errors suggest family members keyed on leading token.""" - # Mock registry with kling-extend, kling-lipsync (kling family), plus some other endpoints + """Test that unknown-model errors suggest family members keyed on leading token. + + Regression test for difflib alone ranking cross-partner shape-alikes over + the caller's intended family. Monkeypatches both _registry and _ALIASES to + fully isolate the candidate pool — difflib returns no close matches, so only + the family-finding code contributes suggestions. + """ + # Mock registry with kling-extend, kling-lipsync (kling family), plus runway mock_registry = { "kling/v1/videos/video-extend": spec.Endpoint( id="kling/v1/videos/video-extend", @@ -104,23 +110,32 @@ def test_unknown_model_suggests_leading_token_family(monkeypatch): polling=None, ), } - # Clear the cache before patching so the mock takes effect + # Fully isolate candidates: mock both _registry and _ALIASES so the test + # is not driven by real-world aliases that happen to match the input. + mock_aliases = {} # Empty: no alias coincidences to mask the family logic spec._registry.cache_clear() monkeypatch.setattr(spec, "_registry", lambda: mock_registry) + monkeypatch.setattr(spec, "_ALIASES", mock_aliases) msg = spec._unknown_endpoint_message("kling-image-to-video") - # Should contain kling family members - assert "kling/v1/videos/video-extend" in msg or "kling-extend" in msg or "video-extend" in msg - assert "kling/v1/videos/lip-sync" in msg or "kling-lipsync" in msg or "lip-sync" in msg - # Should start correctly + # difflib finds no close matches (cutoff=0.5, minimal overlap). + # Only the family-finding code (leading token="kling") contributes suggestions. + # Exactly two kling family members should appear, sorted, in the message. assert msg.startswith("Unknown model: 'kling-image-to-video'") - # Should end with the help message + assert "Did you mean:" in msg + assert "kling/v1/videos/lip-sync" in msg + assert "kling/v1/videos/video-extend" in msg assert "comfy generate list" in msg -def test_unknown_model_no_family_still_helpful(): +def test_unknown_model_no_family_still_helpful(monkeypatch): """Test that errors are helpful even when there's no family match.""" + # Isolate to ensure _ALIASES is controlled + mock_aliases = {"some-unrelated": "foo/bar/baz"} + spec._registry.cache_clear() + monkeypatch.setattr(spec, "_ALIASES", mock_aliases) + msg = spec._unknown_endpoint_message("krea-2") assert msg.startswith("Unknown model: 'krea-2'") assert "comfy generate list" in msg From 330de7b364f77dfd01b30f9c39648b93fe8e9cf7 Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 30 Jul 2026 19:59:55 -0700 Subject: [PATCH 23/53] fix(workflow): autogrow slot names come from the node schema, not a pluralization heuristic template.names[N] verbatim, else prefix+N (0-based), per server finalize_prefix and frontend autogrowOrdinalToName. The {base}.{base[:-1]}{N} guess was wrong for 16 of 27 top-level V3 inputs; it remains only as the no-schema fallback. --- comfy_cli/cql/engine.py | 26 ++++ comfy_cli/workflow_ops.py | 116 ++++++++++++++---- tests/comfy_cli/command/test_workflow_edit.py | 76 ++++++++++++ 3 files changed, 193 insertions(+), 25 deletions(-) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 0f871f6d7..cae669c0f 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -44,6 +44,10 @@ class PortOptions: # engine can expand key-dependent sub-widgets (e.g. model → model.resolution), # matching the converter. None for ordinary inputs. dynamic_options: list | None = None + # For COMFY_AUTOGROW_V3: the raw ``template`` dict object_info carries for an + # autogrow input (e.g. {"input": {...}, "prefix": "image", "min": 1, "max": 50}). + # Use ``Port.autogrow_template`` to pull out just the naming fields. + template: dict | None = None @dataclass @@ -69,6 +73,26 @@ def autogrow_slot_example(self) -> str: stem = self.name[:-1] if self.name.endswith("s") else self.name return f"{self.name}.{stem}0, {self.name}.{stem}1, …" + @property + def autogrow_template(self) -> dict | None: + """The V3 autogrow element-naming template from object_info, if the + catalog carries one: ``{"names": [...]}`` verbatim, or ``{"prefix": + "..."}`` — the two never co-occur (0/108 catalog cases). None when this + port isn't autogrow, or the schema carries no template (older/partial + catalogs, offline edits), so callers fall back to the historical + ``{base[:-1]}{N}`` pluralization guess in :meth:`autogrow_slot_example`. + """ + t = self.options.template + if not self.is_autogrow or not isinstance(t, dict): + return None + names = t.get("names") + if isinstance(names, list) and names: + return {"names": list(names)} + prefix = t.get("prefix") + if isinstance(prefix, str) and prefix: + return {"prefix": prefix} + return None + def canonical_combo(self, value: Any) -> Any | None: """Map a *mangled* COMBO value to the real option it clearly means, or None if it can't be resolved unambiguously. @@ -274,6 +298,7 @@ def _derive_pack(python_module: str) -> str: def _parse_port_options(opts_raw: dict) -> PortOptions: + template_raw = opts_raw.get("template") return PortOptions( min=opts_raw.get("min"), max=opts_raw.get("max"), @@ -282,6 +307,7 @@ def _parse_port_options(opts_raw: dict) -> PortOptions: multiline=bool(opts_raw.get("multiline", False)), control_after_generate=_control_after_generate_set(opts_raw.get("control_after_generate")), force_input=bool(opts_raw.get("forceInput", False)), + template=template_raw if isinstance(template_raw, dict) else None, ) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 5c9bd5c8a..2a498f927 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -284,19 +284,57 @@ def _lww_commit(workflow: dict, op: dict) -> None: workflow.setdefault("_widget_stamps", {})[json.dumps(_write_target(op), default=str)] = _stamp_key(op) -def _next_autogrow_name(ins: list, requested: str) -> str: +def _autogrow_template(graph, node_type: str, base: str) -> dict | None: + """The schema-declared element-naming template for the ``base`` autogrow + input on ``node_type`` — looked up from the same object_info-derived + ``graph`` the connect path already resolves node schemas from (see + ``cql.engine.Port.autogrow_template``). None when ``graph`` is unavailable, + ``node_type`` isn't in the catalog (offline edit), or the catalog entry + carries no template — callers then fall back to the historical + pluralization heuristic in :func:`_autogrow_elem_name`.""" + if graph is None: + return None + m = graph.node(node_type) + if m is None: + return None + for p in m.inputs: + if p.name == base and p.is_autogrow: + return p.autogrow_template + return None + + +def _autogrow_elem_name(base: str, n: int, template: dict | None) -> str: + """The 0-based Nth autogrow element name for ``base``. Comes from the node + schema when known — ``template["names"][N]`` verbatim (overflow past the + list keeps growing as ``f"{names[-1]}{n}"``), else ``f"{prefix}{n}"`` — + falling back to the historical ``{base[:-1]}{n}`` pluralization guess only + when ``template`` is None (schema unavailable: offline edit, catalog miss).""" + if template: + names = template.get("names") + if names: + return names[n] if n < len(names) else f"{names[-1]}{n}" + prefix = template.get("prefix") + if prefix: + return f"{prefix}{n}" + stem = base[:-1] if base.endswith("s") else base + return f"{stem}{n}" + + +def _next_autogrow_name(ins: list, requested: str, template: dict | None = None) -> str: """A free autogrow slot name. Prefer the op's requested name; if a concurrent - connect already took it, grow the next sequential ``{base}.{elem}{N}`` so no - slot is ever clobbered (the server convention stays sequential).""" + connect already took it, grow the next sequential schema-derived slot (see + :func:`_autogrow_elem_name`) so no slot is ever clobbered (the server + convention stays sequential).""" taken = {i.get("name") for i in ins} if requested not in taken: return requested - base, _, stem = requested.partition(".") - elem = stem.rstrip("0123456789") or "slot" - n = 0 - while f"{base}.{elem}{n}" in taken: + base = requested.split(".", 1)[0] + n = len([i for i in ins if str(i.get("name", "")).startswith(base + ".")]) + name = f"{base}.{_autogrow_elem_name(base, n, template)}" + while name in taken: n += 1 - return f"{base}.{elem}{n}" + name = f"{base}.{_autogrow_elem_name(base, n, template)}" + return name # --------------------------------------------------------------------------- @@ -929,7 +967,7 @@ def apply_op(workflow: dict, op: dict, graph) -> dict: elif kind == "set_widget": _apply_set_widget(workflow, op, graph) elif kind == "connect": - _apply_connect(workflow, op) + _apply_connect(workflow, op, graph) elif kind == "delete_node": _apply_delete_node(workflow, op) elif kind == "clear": @@ -980,7 +1018,7 @@ def _apply_set_widget(workflow: dict, op: dict, graph) -> None: _lww_commit(workflow, op) -def _apply_connect(workflow: dict, op: dict) -> None: +def _apply_connect(workflow: dict, op: dict, graph) -> None: # Totality: either endpoint concurrently deleted => no-op (delete wins), so a # merge consumer can replay a connect and a delete in either order without a # crash or a dangling link. Resolve both before mutating anything. @@ -995,12 +1033,15 @@ def _apply_connect(workflow: dict, op: dict) -> None: # autogrow that minted the same requested name gets its own fresh slot # instead of overwriting this one, so neither connection is lost. The # slot's convergence identity is ``grow_id``; its display name stays - # sequential per the server's ``images.imageN`` convention. + # sequential per the server's ``images.imageN`` convention (or the + # schema's own element names, when the catalog carries a template). ins = dst.setdefault("inputs", []) to_idx = next((k for k, i in enumerate(ins) if i.get("grow_id") == op["link_id"]), None) if to_idx is None: + base = str(grow["name"]).split(".", 1)[0] + template = None if grow.get("widget") else _autogrow_template(graph, dst.get("type", ""), base) entry = { - "name": _next_autogrow_name(ins, grow["name"]), + "name": _next_autogrow_name(ins, grow["name"], template), "type": grow["type"], "link": None, "grow_id": op["link_id"], @@ -1296,12 +1337,14 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - the API converter reads the link and skips the widget by name. """ ins = node.get("inputs") or [] + node_type = node.get("type", "") # Concrete slot (index or exact name) that is NOT an autogrow base. try: idx = _resolve_input_slot(node, None, slot) if str(ins[idx].get("type", "")).startswith("COMFY_AUTOGROW"): base = ins[idx].get("name") - return None, _plan_autogrow(ins, base, elem_type) + template = _autogrow_template(graph, node_type, base) + return None, _plan_autogrow(ins, base, elem_type, template) return idx, None except ValueError: pass @@ -1312,7 +1355,8 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - (i for i in ins if i.get("name") == base and str(i.get("type", "")).startswith("COMFY_AUTOGROW")), None ) if ag is not None: - grow = _plan_autogrow(ins, base, elem_type) # canonical next sequential slot + template = _autogrow_template(graph, node_type, base) + grow = _plan_autogrow(ins, base, elem_type, template) # canonical next sequential slot # Addressing the bare base auto-appends. A dotted key is accepted ONLY if # it names that exact next slot; an index gap (images.image4), a doubled # prefix (images.images.image0), or a stray element (images.foo) would mint @@ -1327,7 +1371,7 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - ) return None, grow # Widget-backed input: convert the widget to a linked input. - if graph is not None and isinstance(slot, str) and slot in graph.widget_order(node.get("type", "")): + if graph is not None and isinstance(slot, str) and slot in graph.widget_order(node_type): return None, {"name": slot, "type": elem_type or "*", "widget": slot} # Bare autogrow ELEMENT name (`image1` for base `images`) — the guess agents # make on classic batch nodes, and the top workflow-edit failure in alpha @@ -1340,10 +1384,10 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - base = ag.get("name") if not base or not str(ag.get("type", "")).startswith("COMFY_AUTOGROW"): continue - elem = base[:-1] if base.endswith("s") else base - if not re.fullmatch(re.escape(elem) + r"\d+", slot): + template = _autogrow_template(graph, node_type, base) + if not _autogrow_bare_slot_pattern(base, template).fullmatch(slot): continue - grow = _plan_autogrow(ins, base, elem_type) + grow = _plan_autogrow(ins, base, elem_type, template) if f"{base}.{slot}" == grow["name"]: return None, grow grown = [i.get("name") for i in ins if str(i.get("name", "")).startswith(base + ".")] @@ -1356,11 +1400,33 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") -def _plan_autogrow(ins: list, base: str, elem_type: str | None) -> dict: - """The canonical next autogrow slot for ``base`` — one sequential - ``{base}.{elem}{N}`` per existing slot, minted with the source ``elem_type``. - Callers validate any explicitly requested key against this name before growing.""" +def _autogrow_bare_slot_pattern(base: str, template: dict | None) -> re.Pattern: + """A regex recognizing a bare element name (no ``base.`` prefix, e.g. + ``image1`` for base ``images``) as plausibly addressing this autogrow + input, so a guessed bare name resolves to the actionable fix rather than a + generic not-found. Matches the schema's element vocabulary when known — + any literal ``names`` entry, or its ``{names[-1]}N`` overflow form, or + ``{prefix}N`` — else the historical ``{stem}N`` pluralization guess when + ``template`` is None.""" + if template: + names = template.get("names") + if names: + alts = "|".join(re.escape(n) for n in names) + return re.compile(rf"(?:{alts})|{re.escape(names[-1])}\d+") + prefix = template.get("prefix") + if prefix: + return re.compile(re.escape(prefix) + r"\d+") + stem = base[:-1] if base.endswith("s") else base + return re.compile(re.escape(stem) + r"\d+") + + +def _plan_autogrow(ins: list, base: str, elem_type: str | None, template: dict | None = None) -> dict: + """The canonical next autogrow slot for ``base``. Element name comes from + the node schema when known — ``template["names"][N]`` verbatim, else + ``f"{prefix}{N}"`` (0-based) — falling back to the historical + ``{base}.{base[:-1]}{N}`` heuristic only when ``template`` is unavailable + (schema unavailable: offline edit, catalog miss). Callers validate any + explicitly requested key against this name before growing.""" existing = [i for i in ins if str(i.get("name", "")).startswith(base + ".")] - elem = base[:-1] if base.endswith("s") else base - name = f"{base}.{elem}{len(existing)}" - return {"name": name, "type": elem_type or "*"} + elem = _autogrow_elem_name(base, len(existing), template) + return {"name": f"{base}.{elem}", "type": elem_type or "*"} diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 8d2057f6b..e43cb6d2c 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -189,6 +189,21 @@ def _graph() -> Graph: return Graph.from_object_info(_object_info()) +def _object_info_with_autogrow_template(template: dict) -> dict[str, Any]: + """``_object_info()`` with ``BatchImagesNode.images`` carrying a V3 autogrow + element-naming template — the shape checked into + ``tests/comfy_cli/fixtures/subgraph_object_info.json`` for the live cloud + BatchImagesNode: ``["COMFY_AUTOGROW_V3", {"template": {"prefix": ..., ...}}]``. + ``template`` here is just the ``names``/``prefix`` pair the engine consumes.""" + info = copy.deepcopy(_object_info()) + info["BatchImagesNode"]["input"]["required"]["images"] = ["COMFY_AUTOGROW_V3", {"template": template}] + return info + + +def _graph_with_autogrow_template(template: dict) -> Graph: + return Graph.from_object_info(_object_info_with_autogrow_template(template)) + + @pytest.fixture def patched_graph(monkeypatch): monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) @@ -1470,6 +1485,67 @@ def test_p9_autogrow_connects_are_commutative(self): # ...and the two orders converge. assert ops.canonical(ab) == ops.canonical(ba) + def test_autogrow_uses_schema_prefix_zero_based(self): + """A ``{"prefix": "frame"}`` template names grown slots verbatim from + the schema, 0-based (images.frame0, images.frame1) — a prefix that + deliberately differs from the ``{base[:-1]}`` pluralization guess + ("image"), so this only passes when the name truly comes from the + schema template, not the heuristic.""" + ops = self._ops() + g = _graph_with_autogrow_template({"prefix": "frame"}) + wf = _autogrow_workflow() + wf, op1 = ops.connect(wf, g, 20, "IMAGE", 10, "images", actor="a") + wf, op2 = ops.connect(wf, g, 21, "IMAGE", 10, "images", actor="a") + assert op1["grow"]["name"] == "images.frame0" + assert op2["grow"]["name"] == "images.frame1" + grown = [i["name"] for i in next(n for n in wf["nodes"] if n["id"] == 10)["inputs"] if str(i["name"]).startswith("images.")] + assert grown == ["images.frame0", "images.frame1"] + + def test_autogrow_uses_schema_names_verbatim(self): + """A ``{"names": [...]}`` template uses the literal element names from + the schema, not a pluralization guess — e.g. a node whose V3 definition + calls its slots "first"/"second" rather than "image0"/"image1".""" + ops = self._ops() + g = _graph_with_autogrow_template({"names": ["first", "second"]}) + wf = _autogrow_workflow() + wf, op1 = ops.connect(wf, g, 20, "IMAGE", 10, "images", actor="a") + wf, op2 = ops.connect(wf, g, 21, "IMAGE", 10, "images", actor="a") + assert op1["grow"]["name"] == "images.first" + assert op2["grow"]["name"] == "images.second" + grown = [i["name"] for i in next(n for n in wf["nodes"] if n["id"] == 10)["inputs"] if str(i["name"]).startswith("images.")] + assert grown == ["images.first", "images.second"] + + def test_autogrow_without_template_keeps_heuristic(self): + """No schema template (offline edit, or a catalog entry — like this + file's ``_object_info()`` — that never populated one) keeps the + historical ``{base}.{base[:-1]}{N}`` guess. Regression: the existing + fixture's contract must not change just because the feature shipped.""" + ops = self._ops() + g = _graph() # BatchImagesNode.images is bare "COMFY_AUTOGROW_V3": no template + wf = _autogrow_workflow() + wf, op = ops.connect(wf, g, 20, "IMAGE", 10, "images", actor="a") + assert op["grow"]["name"] == "images.image0" + grown = [i["name"] for i in next(n for n in wf["nodes"] if n["id"] == 10)["inputs"] if str(i["name"]).startswith("images.")] + assert grown == ["images.image0"] + + def test_p9_autogrow_names_template_converges(self): + """Two concurrent autogrow connects onto a ``names``-templated base still + converge to the schema's two literal element names in either apply + order — the conflict-resolution path (``_next_autogrow_name``) must + derive from the schema too, not just the first-planned request.""" + ops = self._ops() + g = _graph_with_autogrow_template({"names": ["first", "second"]}) + base = _autogrow_workflow() + _, op1 = ops.connect(copy.deepcopy(base), g, 20, "IMAGE", 10, "images", actor="a") + _, op2 = ops.connect(copy.deepcopy(base), g, 21, "IMAGE", 10, "images", actor="b") + ab = ops.apply_op(ops.apply_op(copy.deepcopy(base), op1, g), op2, g) + ba = ops.apply_op(ops.apply_op(copy.deepcopy(base), op2, g), op1, g) + for out in (ab, ba): + ins = next(n for n in out["nodes"] if n["id"] == 10)["inputs"] + names = {i["name"] for i in ins if str(i["name"]).startswith("images.")} + assert names == {"images.first", "images.second"}, names + assert ops.canonical(ab) == ops.canonical(ba) + def test_p9_autogrow_grow_id_survives_api_conversion(self): """The ``grow_id`` bookkeeping (persisted on grown slots as their convergence identity) must not break API conversion — both wired sources From b02316da8ada2122bcf7015a271d72bfe06dd771 Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 30 Jul 2026 20:22:05 -0700 Subject: [PATCH 24/53] feat(workflow): connect understands the kijai inputcount family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare 1-based keys (image_3) on inputcount nodes are the CORRECT address — prod refused them on ImageBatchMulti. Growing a slot now also writes inputcount=N through the stamped widget path; out-of-sequence keys get the guided next-free-key error instead of generic not-found. Co-Authored-By: Claude Fable 5 --- comfy_cli/workflow_ops.py | 146 +++++++++++++- tests/comfy_cli/command/test_workflow_edit.py | 179 ++++++++++++++++++ 2 files changed, 322 insertions(+), 3 deletions(-) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 2a498f927..36f7ea888 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -337,6 +337,26 @@ def _next_autogrow_name(ins: list, requested: str, template: dict | None = None) return name +def _next_inputcount_name(ins: list, requested: str) -> str: + """A free ``inputcount``-family slot name (bare ``{elem}_N``, NOT the + dotted ``base.elemN`` autogrow shape). Prefers the op's requested + (mint-time-planned) name; if a concurrent connect already claimed it, + grows the next free bare key instead. Bare keys are this family's actual + wire address (see :func:`_inputcount_family_match`), so collision + resolution must stay bare too — reusing :func:`_next_autogrow_name` here + would mint a dotted name (``image_3.image_30``) the server can't map.""" + taken = {i.get("name") for i in ins} + if requested not in taken: + return requested + elem, _, n_str = requested.rpartition("_") + n = int(n_str) if n_str.isdigit() else 1 + name = f"{elem}_{n}" + while name in taken: + n += 1 + name = f"{elem}_{n}" + return name + + # --------------------------------------------------------------------------- # primitives — each returns (workflow, op); the op is applied via apply_op so # apply(base, op) == primitive(base) holds by construction (P1 fidelity). @@ -1018,6 +1038,37 @@ def _apply_set_widget(workflow: dict, op: dict, graph) -> None: _lww_commit(workflow, op) +def _apply_inputcount_bump(workflow: dict, dst: dict, op: dict, graph, widget: str, value: Any) -> None: + """Bump a kijai ``inputcount``-family widget as part of applying a connect + that grew a numbered slot (see ``_inputcount_family_match`` / + ``_resolve_input_target``). Goes through the SAME last-writer-wins gate + ``_apply_set_widget`` uses (``_lww_gate``/``_lww_commit``), stamped with + the connect op's own stamp/op_id — so this widget write shares the + connect's causal position, and a concurrent explicit + ``set_widget(..., "inputcount", ...)`` resolves deterministically + regardless of apply order. A no-op when ``graph`` is unavailable (offline + edit/merge replay without a catalog) — the slot still grows, just without + the count bump, which callers with a real catalog never hit.""" + if graph is None: + return + widget_op = { + "op": "set_widget", + "node_id": op["to_node"], + "widget": widget, + "op_id": op["op_id"], + "stamp": op.get("stamp"), + "base_version": op.get("base_version"), + } + if not _lww_gate(workflow, widget_op): + return + widgets = dst.setdefault("widgets_values", []) + idx = _widget_index(graph, dst.get("type", ""), widget, widgets) + if idx >= len(widgets): + widgets.extend([None] * (idx + 1 - len(widgets))) + widgets[idx] = value + _lww_commit(workflow, widget_op) + + def _apply_connect(workflow: dict, op: dict, graph) -> None: # Totality: either endpoint concurrently deleted => no-op (delete wins), so a # merge consumer can replay a connect and a delete in either order without a @@ -1038,10 +1089,18 @@ def _apply_connect(workflow: dict, op: dict, graph) -> None: ins = dst.setdefault("inputs", []) to_idx = next((k for k, i in enumerate(ins) if i.get("grow_id") == op["link_id"]), None) if to_idx is None: - base = str(grow["name"]).split(".", 1)[0] - template = None if grow.get("widget") else _autogrow_template(graph, dst.get("type", ""), base) + inputcount = grow.get("inputcount") + if inputcount is not None: + # Bare-key family (see _next_inputcount_name): a collision must + # still grow the next free BARE key, never autogrow's dotted + # base.elemN fallback — that name is meaningless for this family. + name = _next_inputcount_name(ins, grow["name"]) + else: + base = str(grow["name"]).split(".", 1)[0] + template = None if grow.get("widget") else _autogrow_template(graph, dst.get("type", ""), base) + name = _next_autogrow_name(ins, grow["name"], template) entry = { - "name": _next_autogrow_name(ins, grow["name"], template), + "name": name, "type": grow["type"], "link": None, "grow_id": op["link_id"], @@ -1052,6 +1111,21 @@ def _apply_connect(workflow: dict, op: dict, graph) -> None: entry["widget"] = {"name": grow["widget"]} ins.append(entry) to_idx = len(ins) - 1 + if inputcount is not None: + # Bump using the op's mint-time-planned value (NOT re-derived + # from a post-collision-renamed slot number): every op's + # contribution to this LWW register must be a static property + # of the op, independent of what else has applied first, or + # the two apply orders' winning stamp would carry DIFFERENT + # values and the graph would fail to converge (P9). Two + # concurrent connects that both minted against the same next + # slot (a genuine same-instant race) both plan the same + # value, so this still converges for that case; a slot that + # loses the bare-key naming race to a higher number is a + # known, accepted LWW-register limitation (not a monotonic + # counter) — the widget may undercount until the next + # explicit set_widget or connect on this node corrects it. + _apply_inputcount_bump(workflow, dst, op, graph, inputcount["widget"], inputcount["value"]) else: to_idx = op["to_slot"] # A concrete input holds at most one link. Replacing it must fully retire @@ -1322,6 +1396,48 @@ def _resolve_input_slot(node: dict, graph, slot: Any) -> int: raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") +_INPUTCOUNT_KEY_RE = re.compile(r"^(.+)_(\d+)$") + + +def _inputcount_family_match(graph, node_type: str, slot: str) -> tuple[str, int] | None: + """Detect a kijai ``inputcount``-family numbered key (e.g. ``image_3`` on + ImageBatchMulti) and split it into ``(elem, n)``. This family is NOT + autogrow-typed (no ``COMFY_AUTOGROW`` marker) — the schema declares fixed + inputs plus an ``inputcount`` widget the node reads at runtime, and bare + 1-based keys ARE the correct wire address (unlike autogrow's dotted + ``base.elemN``). + + Detection signal — pinned against ImageBatchMulti's production + object_info entry (``services/ingest/data/object_info.json``): the + schema declares a required INT widget named exactly ``inputcount`` PLUS + a ``{elem}_1`` sibling input for the requested element (``image_1``, + ``mask_1``, ``conditioning_1``, ``string_1``, … across the KJNodes + ``*Multi`` family — ImageBatchMulti, MaskBatchMulti, + ConditioningMultiCombine, ImageConcatMulti, JoinStringMulti, …). Both + signals must be present so a coincidentally-named ``foo_3`` input on an + unrelated node type is never misclassified. + + Returns ``None`` when ``graph`` is unavailable (offline edit), ``slot`` + isn't shaped ``{elem}_``, or the node's schema doesn't carry both + signals.""" + m = _INPUTCOUNT_KEY_RE.fullmatch(slot) + if not m or graph is None: + return None + elem, n_str = m.group(1), m.group(2) + n = int(n_str) + if n < 1: + return None + schema = graph.node(node_type) + if schema is None: + return None + has_inputcount = any(p.name == "inputcount" and p.type == "INT" and not p.is_link for p in schema.inputs) + if not has_inputcount: + return None + if not any(p.name == f"{elem}_1" for p in schema.inputs): + return None + return elem, n + + def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) -> tuple[int | None, dict | None]: """Resolve a connect target. Returns ``(index, None)`` for a concrete input, or ``(None, grow)`` where ``grow`` is the input slot to append (autogrow slot, @@ -1396,6 +1512,30 @@ def _resolve_input_target(node: dict, graph, slot: Any, elem_type: str | None) - f"but is not the next sequential slot (existing: {grown}) — connect to the " f"base {base!r} to auto-append, or use the next free key {grow['name']!r}" ) + # kijai `inputcount` family (ImageBatchMulti, MaskBatchMulti, …) — see + # _inputcount_family_match. Bare 1-based keys are the correct address; + # growing one must also bump the `inputcount` widget (carried on `grow` + # and applied through the same LWW-stamped path set_widget uses, see + # _apply_connect) or the node never reads the new slot. + if isinstance(slot, str): + fam = _inputcount_family_match(graph, node_type, slot) + if fam is not None: + elem, n = fam + existing = [i for i in ins if re.fullmatch(rf"{re.escape(elem)}_\d+", str(i.get("name", "")))] + next_n = len(existing) + 1 + if n == next_n: + return None, { + "name": slot, + "type": elem_type or "*", + "inputcount": {"widget": "inputcount", "value": n}, + } + grown = [i.get("name") for i in existing] + next_key = f"{elem}_{next_n}" + raise ValueError( + f"input {slot!r} addresses inputcount input {elem!r} on node {node.get('id')} " + f"but is not the next sequential slot (existing: {grown}) — inputcount nodes " + f"grow sequentially; use the next free key {next_key!r}" + ) names = [i.get("name") for i in ins] raise ValueError(f"input {slot!r} not found on node {node.get('id')}; inputs: {names}") diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index e43cb6d2c..c712d1c0c 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -204,6 +204,107 @@ def _graph_with_autogrow_template(template: dict) -> Graph: return Graph.from_object_info(_object_info_with_autogrow_template(template)) +def _object_info_with_inputcount() -> dict[str, Any]: + """``_object_info()`` plus ``ImageBatchMulti`` — the kijai KJNodes family + (also MaskBatchMulti, ConditioningMultiCombine, JoinStringMulti, …) that + is NOT autogrow-typed: the schema declares fixed ``image_1``/``image_2`` + inputs plus a required INT ``inputcount`` widget the node reads at + runtime to decide how many ``image_N`` slots to look at. Shape pinned + against the production catalog snapshot + (``services/ingest/data/object_info.json``, key ``ImageBatchMulti``): + + "required": { + "inputcount": ["INT", {"default": 2, "min": 2, "max": 1000, "step": 1}], + "image_1": ["IMAGE"] + }, + "optional": {"image_2": ["IMAGE"]} + + Detection signal for the connect path (see ``_inputcount_family_match`` in + ``workflow_ops.py``): a required INT widget literally named ``inputcount`` + PLUS a ``{elem}_1`` sibling input for the requested element — both must be + present so an unrelated node with a coincidental ``foo_1`` input is never + misclassified. Bare 1-based keys (``image_3``) are the CORRECT wire + address for this family — unlike autogrow's dotted ``base.elemN`` — which + is exactly what prod agents were sending and the CLI wrongly refused. + """ + info = copy.deepcopy(_object_info()) + info["ImageBatchMulti"] = { + "input": { + "required": { + "inputcount": ["INT", {"default": 2, "min": 2, "max": 1000, "step": 1}], + "image_1": ["IMAGE"], + }, + "optional": {"image_2": ["IMAGE"]}, + }, + "input_order": {"required": ["inputcount", "image_1"], "optional": ["image_2"]}, + "output": ["IMAGE"], + "output_name": ["images"], + "category": "KJNodes/image", + "display_name": "Image Batch Multi", + "python_module": "custom_nodes.ComfyUI-KJNodes", + } + return info + + +def _graph_with_inputcount() -> Graph: + return Graph.from_object_info(_object_info_with_inputcount()) + + +def _inputcount_workflow(existing: int = 2) -> dict: + """An ``ImageBatchMulti`` node (id 20) with ``existing`` numbered + ``image_N`` inputs already wired (from dummy VAEDecode sources 21, 22, + …), plus one more unwired VAEDecode source (id 20 + existing + 1) to + connect the next slot from.""" + nodes = [] + links = [] + link_id = 0 + for i in range(1, existing + 1): + src_id = 20 + i + nodes.append( + { + "id": src_id, + "type": "VAEDecode", + "pos": [0, i * 100], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [link_id]}], + "widgets_values": [], + } + ) + links.append([link_id, src_id, 0, 20, i - 1, "IMAGE"]) + link_id += 1 + batch_inputs = [ + {"name": f"image_{i}", "type": "IMAGE", "link": i - 1} for i in range(1, existing + 1) + ] + nodes.append( + { + "id": 20, + "type": "ImageBatchMulti", + "pos": [400, 0], + "inputs": batch_inputs, + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [existing], + } + ) + extra_src_id = 20 + existing + 1 + nodes.append( + { + "id": extra_src_id, + "type": "VAEDecode", + "pos": [0, (existing + 1) * 100], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + } + ) + return {"last_node_id": extra_src_id, "last_link_id": link_id, "nodes": nodes, "links": links} + + @pytest.fixture def patched_graph(monkeypatch): monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) @@ -879,6 +980,84 @@ def test_replacing_input_link_scrubs_the_old_one(self, patched_graph, tmp_path, old_src = next(n for n in wf["nodes"] if n["id"] == 7) assert 1 not in (old_src["outputs"][0]["links"] or []) + def test_inputcount_family_bare_key_grows_and_bumps_count(self): + """kijai ``inputcount`` family (ImageBatchMulti et al.): bare 1-based + ``image_3`` IS the correct wire address (unlike autogrow's dotted + ``base.elemN``) — prod agents sent exactly this and the CLI wrongly + refused it. Growing the slot must ALSO bump the ``inputcount`` widget + to N, or the node never reads the new slot at runtime. Detection + signal pinned against ImageBatchMulti's real object_info entry (see + ``_object_info_with_inputcount``): a required INT ``inputcount`` + widget plus a ``{elem}_1`` sibling input.""" + g = _graph_with_inputcount() + wf = _inputcount_workflow(existing=2) # image_1, image_2 already wired + wf, op = workflow_ops.connect(wf, g, 23, "IMAGE", 20, "image_3", actor="a") + assert op["grow"]["name"] == "image_3" + node = next(n for n in wf["nodes"] if n["id"] == 20) + assert any(i["name"] == "image_3" and i["link"] is not None for i in node["inputs"]) + idx = g.widget_order("ImageBatchMulti").index("inputcount") + assert node["widgets_values"][idx] == 3 + + def test_inputcount_family_out_of_sequence_rejected_with_next_key(self): + """Skipping ahead (``image_5`` when only 2 slots exist) is rejected + with the guided next-free-key error, mirroring autogrow's + out-of-sequence guidance — never a silent/bogus grow.""" + g = _graph_with_inputcount() + wf = _inputcount_workflow(existing=2) + with pytest.raises(ValueError, match="image_3"): + workflow_ops.connect(wf, g, 23, "IMAGE", 20, "image_5", actor="a") + node = next(n for n in wf["nodes"] if n["id"] == 20) + assert not any(i["name"] == "image_5" for i in node["inputs"]) + + def test_inputcount_family_concurrent_connects_stay_bare_and_converge(self): + """Two concurrent connects both minted against the same next slot + (``image_3``) must both survive (no clobber, mirroring autogrow's P9 + commutativity) — and the loser's collision-resolved name MUST stay a + bare inputcount key (``image_4``), never autogrow's dotted + ``base.elemN`` fallback, which the server can't map. The inputcount + widget bump uses each op's mint-time-planned value (not one re-derived + from its post-collision slot number) specifically so the two apply + orders converge to the SAME final value — a value derived from the + renamed slot would make the winning stamp carry different values in + each order and break convergence (P9).""" + g = _graph_with_inputcount() + base = _inputcount_workflow(existing=2) + extra_src_id = base["last_node_id"] # the unwired VAEDecode _inputcount_workflow adds + base["nodes"].append( + { + "id": extra_src_id + 1, + "type": "VAEDecode", + "pos": [0, 999], + "inputs": [ + {"name": "samples", "type": "LATENT", "link": None}, + {"name": "vae", "type": "VAE", "link": None}, + ], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + } + ) + _, op1 = workflow_ops.connect(copy.deepcopy(base), g, extra_src_id, "IMAGE", 20, "image_3", actor="a") + _, op2 = workflow_ops.connect(copy.deepcopy(base), g, extra_src_id + 1, "IMAGE", 20, "image_3", actor="b") + ab = workflow_ops.apply_op(workflow_ops.apply_op(copy.deepcopy(base), op1, g), op2, g) + ba = workflow_ops.apply_op(workflow_ops.apply_op(copy.deepcopy(base), op2, g), op1, g) + idx = g.widget_order("ImageBatchMulti").index("inputcount") + for out in (ab, ba): + node = next(n for n in out["nodes"] if n["id"] == 20) + names = {i["name"] for i in node["inputs"]} + assert names == {"image_1", "image_2", "image_3", "image_4"}, names # both survive, bare + assert not any("." in n for n in names) # never the dotted autogrow fallback + assert node["widgets_values"][idx] == 3 # each op planned 3 at mint time + assert workflow_ops.canonical(ab) == workflow_ops.canonical(ba) + + def test_non_family_unknown_input_error_unchanged(self): + """A non-family node (BatchImagesNode, autogrow-typed but NOT in the + inputcount family) keeps the exact generic not-found error text for a + key that matches neither autogrow nor inputcount shapes.""" + g = _graph() + wf = _autogrow_workflow() + with pytest.raises(ValueError, match="not found on node"): + workflow_ops.connect(wf, g, 20, "IMAGE", 10, "bogus_7", actor="a") + # --------------------------------------------------------------------------- # delete From 8f0d58aef4176ec21729fcf102762ed3be01f886 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 31 Jul 2026 00:41:38 -0700 Subject: [PATCH 25/53] fix(generate): empty string-array value raises a coded error, not IndexError The bare-value tolerance returned [] for --image "" where the strict path raised SchemaError; the emit loader chain then crashed with an uncoded IndexError. An empty split now fails at the coercion layer with the same error surface agents already handle. Co-Authored-By: Claude Fable 5 --- comfy_cli/command/generate/schema.py | 5 ++++- tests/comfy_cli/command/generate/test_schema.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/comfy_cli/command/generate/schema.py b/comfy_cli/command/generate/schema.py index a01d00a36..6f4eec1c0 100644 --- a/comfy_cli/command/generate/schema.py +++ b/comfy_cli/command/generate/schema.py @@ -157,7 +157,10 @@ def _coerce(flag: FlagDef, raw: str) -> Any: # string array (prod: --image 'Linked profile pic.jpeg'); demanding # JSON here only manufactures failures. Explicit JSON ('[' prefix) # still takes the strict path below. - return [p.strip() for p in raw.split(",") if p.strip()] + items = [p.strip() for p in raw.split(",") if p.strip()] + if not items: + raise SchemaError(f"--{flag.name}: expected at least one value") + return items try: return json.loads(raw) except json.JSONDecodeError as e: diff --git a/tests/comfy_cli/command/generate/test_schema.py b/tests/comfy_cli/command/generate/test_schema.py index 8e10808aa..62980771a 100644 --- a/tests/comfy_cli/command/generate/test_schema.py +++ b/tests/comfy_cli/command/generate/test_schema.py @@ -123,3 +123,15 @@ def test_coerce_string_array_malformed_json_still_errors(): # not be silently reinterpreted as a filename starting with '['. with pytest.raises(schema.SchemaError): schema._coerce(_string_array_flag(), '["a.jpg",') + + +def test_coerce_string_array_empty_raises(): + # Empty string splits and strips to no items; should raise SchemaError, not return []. + with pytest.raises(schema.SchemaError, match="expected at least one value"): + schema._coerce(_string_array_flag(), "") + + +def test_coerce_string_array_commas_only_raises(): + # Commas and whitespace split/strip to no items; should raise SchemaError, not return []. + with pytest.raises(schema.SchemaError, match="expected at least one value"): + schema._coerce(_string_array_flag(), ", ,") From 828ed3ca99cab26859ecc900e6e07be45c93e4ff Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 31 Jul 2026 02:14:03 -0700 Subject: [PATCH 26/53] fix(workflow): tolerate dict-shaped widgets_values instead of KeyError VideoHelperSuite's VHS_* nodes (VHS_LoadVideo, etc.) serialize widgets_values as a NAMED DICT, not the usual positional list. Every widget-slot site read `node.get("widgets_values") or []`, which keeps a non-empty dict (truthy), then indexed it with an int -> KeyError, surfaced to the agent as the useless "Could not extract slots: 0" (comfy_cli/command/workflow.py renders str(e)). Biggest single prod failure mode: 38 failures. Adds one shared helper, _widgets_as_list, mirroring workflow_to_api.py's existing non-list tolerance (test_tolerates_non_list_widgets_values): anything that isn't a list reads as no known positional values, rather than crashing. Applied at all four positional-index sites: cql/engine.py's _node_widget_slots and _write_widget, and workflow_ops.py's _set_widget_impl (both its direct and subgraph-interior branches) and _apply_set_widget. Reproduced against the real failing fixture (template_purz_wan22_animate_auto_character_replace/workflow.json, node 301 VHS_LoadVideo) via `comfy workflow slots` before the fix; 113 slots now extract cleanly. Co-Authored-By: Claude Fable 5 --- comfy_cli/cql/engine.py | 23 +++- comfy_cli/workflow_ops.py | 11 +- tests/comfy_cli/test_widgets_values_dict.py | 113 ++++++++++++++++++++ 3 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 tests/comfy_cli/test_widgets_values_dict.py diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index cae669c0f..2c229f7a2 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -1448,6 +1448,25 @@ def _subgraph_defs_by_id(workflow: dict) -> dict[str, dict]: return by_id +def _widgets_as_list(widgets_values: Any) -> list[Any]: + """Normalize ``widgets_values`` to a list positionally indexable by widget order. + + ComfyUI's own convention is a positional LIST, but some custom nodes — + VideoHelperSuite's ``VHS_*`` family (e.g. ``VHS_LoadVideo``) — serialize it + as a NAMED DICT instead: ``{"video": "...", "force_rate": 0, ...}``. Every + call site in this module indexes ``widgets_values`` by INTEGER position + against the schema's widget ``order``; a dict is truthy (so a bare + ``widgets_values or []`` guard doesn't catch it) and indexing it with an int + raises ``KeyError``, while ``.extend()`` on it raises ``AttributeError``. + Anything that isn't a list reads as "no positional values known" — mirrors + ``workflow_to_api.py``'s non-list handling + (``test_tolerates_non_list_widgets_values``) so the two code paths agree; + a node whose widgets can't be positionally read shows as unset rather than + crashing slot extraction or a set-widget write. + """ + return list(widgets_values) if isinstance(widgets_values, list) else [] + + def _node_widget_slots(node: dict, prefix: str, graph: Graph) -> list[dict]: """Surface a regular node's widget inputs as slots under ``prefix``. @@ -1459,7 +1478,7 @@ def _node_widget_slots(node: dict, prefix: str, graph: Graph) -> list[dict]: m = graph.node(node_type) if m is None: return [] - widgets = node.get("widgets_values") or [] + widgets = _widgets_as_list(node.get("widgets_values")) order = graph.widget_order_for_node(node_type, widgets) # Drive from `order`, not m.inputs. A COMFY_DYNAMICCOMBO_V3 input is ONE port # (`model`) whose selected option contributes extra widgets addressed as @@ -1652,7 +1671,7 @@ def _write_widget(node: dict, input_name: str, value: Any, graph: Graph, *, exte f"widget {input_name!r} not found on {node_type}; " f"available widgets: {', '.join(avail) if avail else '(none — all inputs are links)'}" ) - widgets = node.get("widgets_values") or [] + widgets = _widgets_as_list(node.get("widgets_values")) if widget_idx >= len(widgets): if not extend: raise ValueError(f"widget index {widget_idx} out of range for {node_type}") diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 36f7ea888..4862bbecf 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -460,6 +460,8 @@ def _set_widget_impl( actor: str = "cli", base_version: int = 0, ) -> tuple[dict, dict]: + from comfy_cli.cql import engine as _engine + # Subgraph-aware: a subgraph instance's *promoted* input (flat ``57.text`` — # exactly what ``comfy workflow slots`` advertises) or an interior node # (nested ``57/27.text``) resolves INTO the subgraph definition. Both forms @@ -472,7 +474,7 @@ def _set_widget_impl( target = _navigate_subgraph_path(workflow, segments) # read-only: current value + schema inner_type = target.get("type", "") value, norm_note = _normalize_combo(graph, inner_type, inner_widget, value) - cur = target.get("widgets_values") or [] + cur = _engine._widgets_as_list(target.get("widgets_values")) order = graph.widget_order_for_node(inner_type, cur) old = None if inner_widget in order: @@ -498,7 +500,7 @@ def _set_widget_impl( node = _require(workflow, node_id) class_type = node.get("type", "") - widgets = node.get("widgets_values") or [] + widgets = _engine._widgets_as_list(node.get("widgets_values")) idx = _widget_index(graph, class_type, widget, widgets) # raises on unknown widget name value, norm_note = _normalize_combo(graph, class_type, widget, value) old = widgets[idx] if idx < len(widgets) else None @@ -1030,7 +1032,10 @@ def _apply_set_widget(workflow: dict, op: dict, graph) -> None: node = _find(workflow, op["node_id"]) if node is None: return # target concurrently deleted => no-op (delete wins). - widgets = node.setdefault("widgets_values", []) + from comfy_cli.cql import engine as _engine + + widgets = _engine._widgets_as_list(node.get("widgets_values")) + node["widgets_values"] = widgets idx = _widget_index(graph, node.get("type", ""), op["widget"], widgets) if idx >= len(widgets): widgets.extend([None] * (idx + 1 - len(widgets))) diff --git a/tests/comfy_cli/test_widgets_values_dict.py b/tests/comfy_cli/test_widgets_values_dict.py new file mode 100644 index 000000000..7b6af8f42 --- /dev/null +++ b/tests/comfy_cli/test_widgets_values_dict.py @@ -0,0 +1,113 @@ +"""``widgets_values`` may be a NAMED DICT, not a positional list. + +VideoHelperSuite's ``VHS_*`` nodes (e.g. ``VHS_LoadVideo``) serialize +``widgets_values`` as a dict: + + "widgets_values": {"video": "wan22-...mp4", "force_rate": 0, ...} + +Every widget-slot call site in ``cql/engine.py`` and ``workflow_ops.py`` reads +``node.get("widgets_values") or []`` and then indexes the result by INTEGER +position against the schema's widget order. A non-empty dict is truthy, so the +``or []`` guard never fires, and indexing it with an int raises ``KeyError`` +(or ``.extend()`` raises ``AttributeError`` when the write path needs to grow +it). + +Prod: 38 failures. Surfaced to the agent as the useless message +"Could not extract slots: 0" (``comfy_cli/command/workflow.py``'s +``except (ValueError, KeyError)`` renders ``str(e)``, and ``str(KeyError(0))`` +is just ``"0"``). Reproduced directly against the real failing fixture: + + comfy workflow slots template_purz_wan22_animate_auto_character_replace/workflow.json \ + --input object_info.json + # -> KeyError: 0 at cql/engine.py's _node_widget_slots + +``workflow_to_api.py`` already tolerates non-list ``widgets_values`` +(``test_tolerates_non_list_widgets_values`` treats it as no known values); this +fix brings ``cql/engine.py`` and ``workflow_ops.py`` into agreement via one +shared helper, ``_widgets_as_list`` — same semantics: non-list (including a +dict) reads as an empty list of positional values, so the node's widgets show +as unset instead of crashing extraction or a write. +""" + +from __future__ import annotations + +import pytest + +from comfy_cli import workflow_ops as W +from comfy_cli.cql.engine import Graph, _apply_one_slot, _extract_frontend_slots + +_OBJECT_INFO = { + "EmptyLatentImage": { + "input": { + "required": { + "width": ["INT", {"default": 512}], + "height": ["INT", {"default": 512}], + "batch_size": ["INT", {"default": 1}], + }, + }, + "input_order": {"required": ["width", "height", "batch_size"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "latent", + "display_name": "Empty Latent Image", + "python_module": "nodes", + }, +} + + +@pytest.fixture +def graph() -> Graph: + return Graph.from_object_info(_OBJECT_INFO) + + +def _dict_widget_node(widgets_values: dict) -> dict: + return {"nodes": [{"id": 7, "type": "EmptyLatentImage", "widgets_values": widgets_values}], "links": []} + + +class TestListSlotsToleratesDictWidgetsValues: + def test_full_dict_does_not_crash(self, graph: Graph): + # One dict entry per real widget — the shape that used to KeyError at + # `widgets[idx] if idx < len(widgets) else None` (idx is an int, widgets + # is a dict). + wf = _dict_widget_node({"width": 512, "height": 512, "batch_size": 1}) + slots = _extract_frontend_slots(wf, graph) + names = {s["name"] for s in slots} + assert names == {"width", "height", "batch_size"} + # Values are unknown (treated as empty), not silently wrong. + assert all(s["current_value"] is None for s in slots) + + def test_partial_dict_does_not_crash(self, graph: Graph): + wf = _dict_widget_node({"width": 512}) + slots = _extract_frontend_slots(wf, graph) + assert {s["name"] for s in slots} == {"width", "height", "batch_size"} + + def test_get_template_schema_end_to_end(self, graph: Graph): + """The exact call path `comfy workflow slots` uses.""" + wf = _dict_widget_node({"width": 512, "height": 512, "batch_size": 1}) + schema = graph.get_template_schema("t", wf) + assert {s["name"] for s in schema["slots"]} == {"width", "height", "batch_size"} + + +class TestSetWidgetToleratesDictWidgetsValues: + def test_apply_one_slot_grows_past_a_short_dict(self, graph: Graph): + wf = _dict_widget_node({"width": 512}) + _apply_one_slot(wf, "7.batch_size", 4, graph) + # The dict is replaced by a real positional list; the write lands at + # batch_size's schema position. + assert wf["nodes"][0]["widgets_values"] == [None, None, 4] + + def test_apply_one_slot_within_dict_len(self, graph: Graph): + wf = _dict_widget_node({"width": 512, "height": 512, "batch_size": 1}) + _apply_one_slot(wf, "7.batch_size", 4, graph) + assert wf["nodes"][0]["widgets_values"][2] == 4 + + def test_set_widget_public_api_does_not_crash(self, graph: Graph): + wf = _dict_widget_node({"width": 512, "height": 512, "batch_size": 1}) + new_wf, op = W.set_widget(wf, graph, 7, "batch_size", 4) + assert op["op"] == "set_widget" + assert new_wf["nodes"][0]["widgets_values"][2] == 4 + + def test_set_widget_grows_past_a_short_dict(self, graph: Graph): + wf = _dict_widget_node({"width": 512}) + new_wf, op = W.set_widget(wf, graph, 7, "batch_size", 4) + assert new_wf["nodes"][0]["widgets_values"] == [None, None, 4] From 3764e39e8843d07957fe41734158d954f67f475b Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 31 Jul 2026 02:15:14 -0700 Subject: [PATCH 27/53] fix(workflow): connect no longer crashes on a never-wired output slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _apply_connect did `out_links = src["outputs"][slot].setdefault("links", [])`. A real ComfyUI-serialized never-wired output carries "links": null — the key EXISTS, so setdefault returns the existing None instead of installing a fresh list, and the next line ("if link_id not in out_links") raised TypeError: argument of type 'NoneType' is not iterable. This hits EVERY connect targeting a loaded/fetched real workflow's unwired output slot, not one node/type in particular. 5 prod failures, e.g.: workflow connect workflow.json --actor ... --base-version 3 --where cloud -- 301.audio 349.audio. Fix: explicit None check before appending, instead of relying on setdefault's "key missing" semantics. Co-Authored-By: Claude Fable 5 --- comfy_cli/workflow_ops.py | 10 +- .../test_connect_null_output_links.py | 100 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/comfy_cli/test_connect_null_output_links.py diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 4862bbecf..ce5b0609c 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -1143,7 +1143,15 @@ def _apply_connect(workflow: dict, op: dict, graph) -> None: if not any(ln[0] == op["link_id"] for ln in links): links.append(link) dst["inputs"][to_idx]["link"] = op["link_id"] - out_links = src["outputs"][op["from_slot"]].setdefault("links", []) + out_port = src["outputs"][op["from_slot"]] + # A real ComfyUI-serialized never-wired output carries `"links": null` — the + # key EXISTS, so `setdefault` returns the existing `None` instead of + # installing a fresh list, and the membership check below would raise + # `TypeError: argument of type 'NoneType' is not iterable`. Check for None + # explicitly rather than relying on setdefault's "key missing" semantics. + if out_port.get("links") is None: + out_port["links"] = [] + out_links = out_port["links"] if op["link_id"] not in out_links: out_links.append(op["link_id"]) diff --git a/tests/comfy_cli/test_connect_null_output_links.py b/tests/comfy_cli/test_connect_null_output_links.py new file mode 100644 index 000000000..2bb116b6f --- /dev/null +++ b/tests/comfy_cli/test_connect_null_output_links.py @@ -0,0 +1,100 @@ +"""``connect`` must not crash when the source output has never been wired. + +A real ComfyUI-serialized never-wired output carries `"links": null` — the +key EXISTS (unlike a freshly-added output that may omit it entirely), so +`src["outputs"][slot].setdefault("links", [])` returns the existing `None` +rather than installing a fresh list. The next line, `if op["link_id"] not in +out_links`, then raises `TypeError: argument of type 'NoneType' is not +iterable`. + +This hits EVERY connect from a loaded/fetched real workflow's unwired output +slot, not just one node/type. Prod argv: + + workflow connect workflow.json --actor ... --base-version 3 --where cloud \ + -- 301.audio 349.audio + +5 prod failures. Fix: an explicit `None` check before appending, instead of +relying on `setdefault`'s "key missing" semantics. +""" + +from __future__ import annotations + +import pytest + +from comfy_cli import workflow_ops as W +from comfy_cli.cql.engine import Graph + +_OBJECT_INFO = { + "AudioSource": { + "input": {"required": {}}, + "input_order": {"required": []}, + "output": ["AUDIO"], + "output_name": ["audio"], + "category": "audio", + "display_name": "Audio Source", + "python_module": "nodes", + }, + "AudioSink": { + "input": {"required": {"audio": ["AUDIO", {}]}}, + "input_order": {"required": ["audio"]}, + "output": [], + "output_name": [], + "category": "audio", + "display_name": "Audio Sink", + "python_module": "nodes", + }, +} + + +@pytest.fixture +def graph() -> Graph: + return Graph.from_object_info(_OBJECT_INFO) + + +def _workflow_with_null_output_links() -> dict: + return { + "nodes": [ + { + "id": 301, + "type": "AudioSource", + "outputs": [{"name": "audio", "type": "AUDIO", "links": None}], + }, + { + "id": 349, + "type": "AudioSink", + "inputs": [{"name": "audio", "type": "AUDIO", "link": None}], + }, + ], + "links": [], + } + + +def test_connect_from_a_never_wired_output_does_not_crash(graph: Graph): + wf = _workflow_with_null_output_links() + wf, op = W.connect(wf, graph, 301, "audio", 349, "audio") + assert op["op"] == "connect" + assert wf["nodes"][1]["inputs"][0]["link"] == op["link_id"] + + +def test_connect_from_a_never_wired_output_records_the_link(graph: Graph): + wf = _workflow_with_null_output_links() + wf, op = W.connect(wf, graph, 301, "audio", 349, "audio") + out_links = wf["nodes"][0]["outputs"][0]["links"] + assert out_links == [op["link_id"]] + + +def test_second_connect_from_the_same_output_appends(graph: Graph): + """Two links off the same never-wired output both survive (not just the + first) — proves the fix builds a real list, not just swallowing the crash.""" + wf = _workflow_with_null_output_links() + wf["nodes"].append( + { + "id": 350, + "type": "AudioSink", + "inputs": [{"name": "audio", "type": "AUDIO", "link": None}], + } + ) + wf, op1 = W.connect(wf, graph, 301, "audio", 349, "audio") + wf, op2 = W.connect(wf, graph, 301, "audio", 350, "audio") + out_links = wf["nodes"][0]["outputs"][0]["links"] + assert set(out_links) == {op1["link_id"], op2["link_id"]} From 636e989d52d3b0f84031a3529e85092062adff0c Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 31 Jul 2026 02:16:56 -0700 Subject: [PATCH 28/53] fix(workflow): a single-output node's output slot resolves under any name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents address an output by its TYPE when the node has exactly one output (they were never shown the real name). The case/separator tolerance already on this branch only covers a variant of the SAME name (image -> IMAGE); it does not cover an outright rename, so 5 prod failures kept erroring: LUMA_RAY32_KEYFRAME (actual name 'keyframes'), CAMERA_CONTROL x2 ('camera_control'), ELEVENLABS_VOICE ('voice'), IMAGE ('images'). Fix in _resolve_output_slot: once exact and normalized matches fail, a node with exactly ONE output has no ambiguity to guess through, so an unmatched name resolves to that output. Multi-output nodes are unaffected — an unmatched name still errors with the full name list (regression test added; updated an existing single-output-fixture test whose intent was genuine ambiguity, not single-output specifically, to use two outputs instead). Co-Authored-By: Claude Fable 5 --- comfy_cli/workflow_ops.py | 8 ++ .../comfy_cli/test_output_slot_normalized.py | 5 +- .../test_single_output_slot_alias.py | 77 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/comfy_cli/test_single_output_slot_alias.py diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index ce5b0609c..28e025eb6 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -1391,6 +1391,14 @@ def _resolve_output_slot(node: dict, graph, slot: Any) -> tuple[int, str]: if len(hits) == 1: i = hits[0] return i, outs[i].get("type", "*") + # Still unmatched. When the node has exactly ONE output there is no + # ambiguity to guess through — it's the only thing the caller could have + # meant, even when the requested name is an outright rename rather than a + # case/separator variant (prod: LUMA_RAY32_KEYFRAME -> 'keyframes', + # ELEVENLABS_VOICE -> 'voice', IMAGE -> 'images'). Multi-output nodes keep + # today's behavior: an unmatched name stays ambiguous and errors below. + if len(outs) == 1: + return 0, outs[0].get("type", "*") names = [o.get("name") for o in outs] raise ValueError(f"output {slot!r} not found on node {node.get('id')}; outputs: {names}") diff --git a/tests/comfy_cli/test_output_slot_normalized.py b/tests/comfy_cli/test_output_slot_normalized.py index ff0d4fb99..42723d1f7 100644 --- a/tests/comfy_cli/test_output_slot_normalized.py +++ b/tests/comfy_cli/test_output_slot_normalized.py @@ -59,6 +59,9 @@ def test_ambiguous_normalized_match_still_fails(g): def test_unrelated_name_still_fails_with_the_name_list(g): + # Two outputs, so the ask stays genuinely ambiguous (a single-output node + # auto-resolves any name — see test_single_output_slot_alias.py — so this + # regression guard needs a node where "unrelated" really is unrelated). with pytest.raises(ValueError) as ei: - W._resolve_output_slot(_node([("image", "IMAGE")]), g, "LATENT") + W._resolve_output_slot(_node([("image", "IMAGE"), ("alpha", "MASK")]), g, "LATENT") assert "image" in str(ei.value), "the error must still list the real names" diff --git a/tests/comfy_cli/test_single_output_slot_alias.py b/tests/comfy_cli/test_single_output_slot_alias.py new file mode 100644 index 000000000..45f9442cf --- /dev/null +++ b/tests/comfy_cli/test_single_output_slot_alias.py @@ -0,0 +1,77 @@ +"""A single-output node should accept ANY requested output name. + +Agents address an output by its TYPE when the node has exactly ONE output +(they were never shown the real name). Real prod cases (5 failures): + + LUMA_RAY32_KEYFRAME -> actual name 'keyframes' + CAMERA_CONTROL -> actual name 'camera_control' (x2) + ELEVENLABS_VOICE -> actual name 'voice' + IMAGE -> actual name 'images' + +The case/separator tolerance already on this branch +(test_output_slot_normalized.py) only accepts a variant of the SAME name +(case/underscore-insensitive) — it does not cover an outright rename, so all +five kept failing with "output 'X' not found ... outputs: [...]". + +Fix in `_resolve_output_slot`: when the node has exactly one output, an +unmatched name resolves to that one output (there is no ambiguity — it's the +only thing the caller could mean). Multi-output nodes are unaffected and keep +today's behavior: an unmatched name still errors with the full name list. +""" + +from __future__ import annotations + +import pytest + +from comfy_cli import workflow_ops as W +from comfy_cli.cql.engine import Graph + + +def _node(outputs): + return {"id": 1, "type": "X", "outputs": [{"name": n, "type": t} for n, t in outputs]} + + +@pytest.fixture +def g(): + return Graph.from_object_info({}) + + +@pytest.mark.parametrize( + "asked,name,out_type", + [ + ("LUMA_RAY32_KEYFRAME", "keyframes", "LUMA_RAY32_KEYFRAME"), # prod: LUMA_RAY32_KEYFRAME + ("CAMERA_CONTROL", "camera_control", "CAMERA_CONTROL"), # prod: CAMERA_CONTROL (x2) + ("ELEVENLABS_VOICE", "voice", "ELEVENLABS_VOICE"), # prod: ELEVENLABS_VOICE + ("IMAGE", "images", "IMAGE"), # prod: IMAGE + ("anything_at_all", "output", "SOMETYPE"), # any alias at all — not just a type name + ], +) +def test_single_output_node_accepts_any_alias(g, asked, name, out_type): + idx, resolved_type = W._resolve_output_slot(_node([(name, out_type)]), g, asked) + assert idx == 0 + assert resolved_type == out_type + + +def test_single_output_node_exact_name_still_wins(g): + idx, _ = W._resolve_output_slot(_node([("keyframes", "LUMA_RAY32_KEYFRAME")]), g, "keyframes") + assert idx == 0 + + +def test_multi_output_node_still_errors_with_the_name_list(g): + """Regression guard: a node with MORE THAN ONE output must NOT gain the + single-output auto-resolve — an unmatched name stays ambiguous and must + keep failing with the real name list, exactly as before.""" + outs = [("image", "IMAGE"), ("mask", "MASK")] + with pytest.raises(ValueError) as ei: + W._resolve_output_slot(_node(outs), g, "LATENT") + msg = str(ei.value) + assert "not found" in msg + assert "image" in msg and "mask" in msg + + +def test_multi_output_node_exact_and_normalized_matches_unaffected(g): + """Existing behavior for multi-output nodes (exact + case/separator + normalization) must be untouched by this fix.""" + outs = [("image", "IMAGE"), ("alpha", "MASK")] + idx, _ = W._resolve_output_slot(_node(outs), g, "IMAGE") + assert idx == 0 From 35c1069d1609d0cf9cfe7af0a291e2fbcce474b9 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 31 Jul 2026 17:54:02 -0700 Subject: [PATCH 29/53] fix(config): make the tmp-dir create idempotent (concurrent load() race) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigManager.load() did `if not os.path.exists(tmp): os.makedirs(tmp)` — a check-then-act that races when several `comfy` processes share one HOME and load concurrently. The loser dies with FileExistsError before its command runs. This is now reachable: comfy-agent executes read-only tool calls in parallel within a model round, so several `comfy` subprocesses start at once under the same HOME. Reproduced the pattern directly: 40 concurrent creators x 60 trials raised 107 FileExistsError with the old check-then-act and 0 with exist_ok=True. --- comfy_cli/config_manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/comfy_cli/config_manager.py b/comfy_cli/config_manager.py index b197078c1..419767a22 100644 --- a/comfy_cli/config_manager.py +++ b/comfy_cli/config_manager.py @@ -82,8 +82,11 @@ def load(self): # TODO: We need a policy for clearing the tmp directory. tmp_path = os.path.join(self.get_config_path(), "tmp") - if not os.path.exists(tmp_path): - os.makedirs(tmp_path) + # exist_ok: several `comfy` processes can share one HOME and load() + # concurrently (the comfy-agent runs read-only tool calls in parallel), + # so a check-then-create races — both see the dir missing and the loser + # dies with FileExistsError before running its command. + os.makedirs(tmp_path, exist_ok=True) if constants.CONFIG_KEY_BACKGROUND in self.config["DEFAULT"]: bg_info = self.config["DEFAULT"][constants.CONFIG_KEY_BACKGROUND].strip("()").split(",") From 499cb7f342149b7a28564c7be05164294149bd67 Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 6 Aug 2026 13:41:56 -0700 Subject: [PATCH 30/53] fix(workflow): connect + nodes show explain the subgraph boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod comfy-agent traces (2026-08-05, one session): the agent read an interior address (129/93.text) out of `comfy workflow slots`, tried to wire a link into it, and got "node 129/93 not found in workflow" + the top-level inventory + "use an id from comfy workflow slots" — an instruction to consult the exact tool that advertised the address. It retried the SAME connect seven times over eleven minutes and the turn died with no output. show_node on the instance's definition uuid failed the same way seven more times ("not found in the loaded environment"), 18 failures total from one boundary misunderstanding. connect now says what the boundary means instead of "not found": - interior address (57/27.text, or the flattened 57:27 namespace validate reports): a link cannot cross the subgraph boundary; interior widgets ARE settable via set-widget; wiring a live link needs the instance's own slots or a promoted input. Applies to both endpoints. - unknown interior (57/99): lists the definition's interior nodes, not the top-level graph. - path under a non-subgraph node (9/27): says node 9 is not a subgraph. - flat promoted widget (57.text): "right id, wrong verb" — a proxyWidgets promotion is a value, not a link input; point at set-widget 57.text. - unknown head (999/27) keeps the classic enriched not-found. nodes show now names the uuid as a subgraph type id (same treatment add-node got in UnknownNodeType) and suppresses difflib noise, pointing at slots/ls-nodes instead of the catalog. Co-Authored-By: Claude Fable 5 --- comfy_cli/command/nodes.py | 22 ++++ comfy_cli/workflow_ops.py | 101 ++++++++++++++- .../command/test_connect_subgraph_boundary.py | 121 ++++++++++++++++++ .../command/test_nodes_show_subgraph.py | 71 ++++++++++ 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 tests/comfy_cli/command/test_connect_subgraph_boundary.py create mode 100644 tests/comfy_cli/command/test_nodes_show_subgraph.py diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index 27e48989a..5daebb20c 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -326,6 +326,28 @@ def show_cmd( m = graph.node(name) if m is None: + # A subgraph instance's `type` is its definition UUID, and ls-nodes + # prints that verbatim — so callers ask show for a "class" the catalog + # can never have. `workflow add-node` already names this shape + # (UnknownNodeType subgraph_id); show was left behind with the generic + # miss, and prod agents retried it verbatim. Say what the UUID is and + # which surface CAN inspect it. difflib against a UUID is pure noise. + from comfy_cli.workflow_ops import _UUID_RE + + if _UUID_RE.match(name.strip()): + renderer.error( + code="node_not_found", + message=( + f"{name!r} is a subgraph type id, not a node class — `ls-nodes` prints a subgraph instance's " + "definition uuid as its type, and the catalog has no schema for it." + ), + hint=( + "inspect the instance's editable inputs with `comfy workflow slots ` / `ls-nodes`; " + "interior nodes are addressed `/` and written with `set-widget`." + ), + details={"requested": name, "subgraph_id": True}, + ) + raise typer.Exit(code=1) # Surface near-matches so the agent can self-correct from the error. all_names = [n.id for n in graph.all_nodes()] close = difflib.get_close_matches(name, all_names, n=5, cutoff=0.6) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 28e025eb6..aba662fac 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -605,6 +605,95 @@ def _navigate_subgraph_path(workflow: dict, segments: list[str]) -> dict: return node +def _subgraph_boundary_error(workflow: dict, node_id: Any) -> ValueError | None: + """Explain a connect endpoint that addresses a subgraph interior. + + ``comfy workflow slots`` deliberately advertises interior addresses + (``57/27.text``) so agents can slot-edit inside opaque template subgraphs, + and set-widget accepts them — but a LINK cannot cross a subgraph boundary, + so connect never can. Before this guard, such an endpoint fell through to + the generic "node 57/27 not found in workflow" + the top-level node + inventory + "use an id from `comfy workflow slots`" — an instruction to + consult the exact tool that advertised the address. Measured on prod + comfy-agent traces (2026-08-05): one session burned SEVEN identical + connects on ``129/93.text`` and the turn died. Say what the boundary means + and which verb works instead. + + Returns ``None`` when the endpoint is not an interior address (including + when its head segment doesn't exist — the canonical not-found error is + right for that). + """ + from comfy_cli.cql import engine as _engine + + node_str = str(node_id) + if _find_by_str(workflow, node_str) is not None: + return None # a literal node really has this id + if _engine._SUBGRAPH_PATH_SEP in node_str: + segments = node_str.split(_engine._SUBGRAPH_PATH_SEP) + elif ":" in node_str: + # The flattened namespace UI→API lowering mints (`:`), + # accepted everywhere set-widget accepts the `/` form. + segments = node_str.split(":") + else: + return None + head = _find_by_str(workflow, segments[0]) + if head is None: + return None + sg = _engine._subgraph_defs_by_id(workflow).get(str(head.get("type", ""))) + if sg is None: + return ValueError( + f"node {segments[0]} is not a subgraph, so {node_str} does not address a node — " + f"connect to node {segments[0]}'s own slots instead (see `comfy workflow slots`)" + ) + canonical = "/".join(segments) + try: + _navigate_subgraph_path(workflow, segments) + except ValueError: + interior = ", ".join( + f"{n.get('id')} ({n.get('type', '?')})" for n in sg.get("nodes") or [] if isinstance(n, dict) + ) + return ValueError( + f"no node {segments[-1]} inside subgraph {segments[0]} — its interior nodes: {interior or '(none)'}" + ) + return ValueError( + f"node {canonical} is inside subgraph {segments[0]} ({str(sg.get('name') or '?')!r}) — a link cannot cross " + f"the subgraph boundary, so connect cannot reach it. Interior widgets ARE settable: " + f"`comfy workflow set-widget {canonical}. `. To wire a live link, connect to one of " + f"the instance's own slots (see `comfy workflow slots`), or promote the input in the ComfyUI editor first." + ) + + +def _promoted_widget_error(workflow: dict, node: dict, slot: Any) -> ValueError | None: + """Explain a connect target that names a subgraph instance's promoted widget. + + ``slots`` advertises a curated instance's promoted inputs flat + (``57.text``), and set-widget accepts exactly that address — but a promoted + WIDGET is a value routed through ``proxyWidgets``, not a link input on the + instance. The old error ("input 'text' not found on node 57; inputs: []" + plus the node inventory) reads as *wrong id, try another*, when the truth + is *right id, wrong verb*. Returns ``None`` unless the node is a subgraph + instance and ``slot`` is one of its promoted widgets. + """ + from comfy_cli.cql import engine as _engine + + if not isinstance(slot, str): + return None + if _engine._subgraph_defs_by_id(workflow).get(str(node.get("type", ""))) is None: + return None + try: + target = _subgraph_write_target(workflow, node.get("id"), slot) + except ValueError: + return None # not a promoted widget either — the input-not-found error stands + if target is None: + return None + nid = node.get("id") + return ValueError( + f"input {slot!r} on subgraph instance {nid} is a promoted widget (a value), not a link input — set it with " + f"`comfy workflow set-widget {nid}.{slot} `. Wiring a live link into the subgraph requires " + f"promoting a link input in the ComfyUI editor." + ) + + def connect( workflow: dict, graph, @@ -637,10 +726,20 @@ def _connect_impl( actor: str = "cli", base_version: int = 0, ) -> tuple[dict, dict]: + for endpoint in (from_node, to_node): + boundary = _subgraph_boundary_error(workflow, endpoint) + if boundary is not None: + raise boundary src = _require(workflow, from_node) dst = _require(workflow, to_node) out_idx, link_type = _resolve_output_slot(src, graph, from_slot) - in_idx, grow = _resolve_input_target(dst, graph, to_slot, link_type) + try: + in_idx, grow = _resolve_input_target(dst, graph, to_slot, link_type) + except ValueError as e: + promoted = _promoted_widget_error(workflow, dst, to_slot) + if promoted is not None: + raise promoted from e + raise # Type-check concrete slots: an output only connects to an input that accepts # its type (or a wildcard "*"). Autogrow slots are minted with the source # type, so they need no check. Without this, a mis-wire silently clobbers a diff --git a/tests/comfy_cli/command/test_connect_subgraph_boundary.py b/tests/comfy_cli/command/test_connect_subgraph_boundary.py new file mode 100644 index 000000000..90a3d43cf --- /dev/null +++ b/tests/comfy_cli/command/test_connect_subgraph_boundary.py @@ -0,0 +1,121 @@ +"""`workflow connect` must explain a subgraph boundary instead of "not found". + +Measured on prod comfy-agent traces (2026-08-05, session 0cc9d03b): the agent +read `129/93.text` out of `comfy workflow slots` (which advertises interior +subgraph addresses precisely so widgets can be slot-edited), then tried to wire +a link into it: + + connect 1263680240999073.STRING -> 129/93.text + => "node 129/93 not found in workflow. Nodes in this workflow: ... + Use an id from `comfy workflow slots` / `ls-nodes` — never rebuild it." + +The hint told it to consult the exact tool that advertised the address, so it +retried the SAME call seven times over eleven minutes and the turn died. The +truth: a link cannot cross a subgraph boundary — interior addresses are +writable (set-widget) but not wirable, and `connect` must say that instead of +"not found" + an inventory that fuels the retry loop. +""" + +from __future__ import annotations + +import pytest +from test_workflow_edit import ( # type: ignore[import-not-found] + _graph, + _run, + _subgraph_workflow, + _write, + reset_singleton, # noqa: F401 (autouse fixture) +) + +from comfy_cli.command import workflow_edit + +LOOP_FUEL = ( + "not found in workflow", + "Nodes in this workflow", +) + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + + +def _connect(tmp_path, capsys, source: str, target: str) -> dict: + path = _write(tmp_path, _subgraph_workflow()) + return _run(["connect", str(path), source, target], capsys) + + +def _assert_boundary_error(env: dict) -> dict: + assert env["ok"] is False, env + err = env["error"] + assert err["code"] == "workflow_edit_invalid", err + for fuel in LOOP_FUEL: + assert fuel not in err["message"], f"retry-loop fuel {fuel!r} in: {err['message']}" + return err + + +class TestConnectInteriorAddress: + def test_slash_interior_target_names_the_boundary(self, patched_graph, tmp_path, capsys): + """`57/27.text` — the exact shape of the prod failure (129/93.text).""" + env = _connect(tmp_path, capsys, "9.LATENT", "57/27.text") + err = _assert_boundary_error(env) + msg = err["message"] + assert "inside subgraph 57" in msg, msg + # The actionable alternative: the address IS writable, just not wirable. + assert "set-widget" in msg, msg + assert "57/27" in msg, msg + + def test_colon_interior_target_is_the_same_boundary(self, patched_graph, tmp_path, capsys): + """`57:27.text` — the flattened namespace validate/node_errors report.""" + env = _connect(tmp_path, capsys, "9.LATENT", "57:27.text") + err = _assert_boundary_error(env) + assert "inside subgraph 57" in err["message"], err + + def test_interior_source_is_also_guarded(self, patched_graph, tmp_path, capsys): + """Wiring FROM an interior node out is the same boundary violation.""" + env = _connect(tmp_path, capsys, "57/3.LATENT", "9.width") + err = _assert_boundary_error(env) + assert "inside subgraph 57" in err["message"], err + + def test_unknown_interior_lists_what_the_subgraph_contains(self, patched_graph, tmp_path, capsys): + env = _connect(tmp_path, capsys, "9.LATENT", "57/99.text") + err = _assert_boundary_error(env) + msg = err["message"] + assert "no node 99 inside subgraph 57" in msg, msg + # Inventory of the DEFINITION, not the top-level graph. + assert "27 (CLIPTextEncode)" in msg, msg + assert "3 (KSampler)" in msg, msg + + def test_path_under_a_non_subgraph_node_says_so(self, patched_graph, tmp_path, capsys): + """`9/27.text` — node 9 exists but is a plain node, not a subgraph.""" + env = _connect(tmp_path, capsys, "9.LATENT", "9/27.text") + err = _assert_boundary_error(env) + assert "not a subgraph" in err["message"], err + + def test_unknown_head_still_gets_the_standard_not_found(self, patched_graph, tmp_path, capsys): + """`999/27.text` — nothing to explain; the classic enriched error stays.""" + env = _connect(tmp_path, capsys, "9.LATENT", "999/27.text") + assert env["ok"] is False + assert "not found in workflow" in env["error"]["message"], env + + +class TestConnectPromotedWidget: + def test_flat_promoted_widget_target_points_at_set_widget(self, patched_graph, tmp_path, capsys): + """`57.text` is a promoted WIDGET (proxyWidgets), not a link input. + + The old error — "input 'text' not found on node 57; inputs: []" plus the + top-level node inventory — reads as "wrong id, try another", when the + truth is "right id, wrong verb".""" + env = _connect(tmp_path, capsys, "9.LATENT", "57.text") + err = _assert_boundary_error(env) + msg = err["message"] + assert "promoted widget" in msg, msg + assert "set-widget" in msg, msg + assert "57.text" in msg, msg + + def test_unpromoted_missing_input_keeps_the_standard_error(self, patched_graph, tmp_path, capsys): + """A name that is neither a link input nor a promoted widget stays a + plain input-not-found so genuinely wrong names are still called wrong.""" + env = _connect(tmp_path, capsys, "9.LATENT", "57.nonsense") + assert env["ok"] is False + assert "promoted widget" not in env["error"]["message"], env diff --git a/tests/comfy_cli/command/test_nodes_show_subgraph.py b/tests/comfy_cli/command/test_nodes_show_subgraph.py new file mode 100644 index 000000000..4ac3d3b9d --- /dev/null +++ b/tests/comfy_cli/command/test_nodes_show_subgraph.py @@ -0,0 +1,71 @@ +"""`nodes show ` must name the UUID as a subgraph type, not "not found". + +Measured on prod comfy-agent traces (2026-08-05, session 0cc9d03b): the agent +read a subgraph instance's `type` — its definition UUID — out of `ls-nodes` and +asked `show_node` about it, seven times: + + show_node "84e2cf3f-de93-40ef-ab22-b9375296917b" + => "Node class '84e2cf3f-…' not found in the loaded environment." + +`workflow add-node` already explains this shape (see +test_add_node_unknown_class.py::test_uuid_class_type_is_named_as_a_subgraph_instance); +`nodes show` was left behind with the generic catalog miss. +""" + +from __future__ import annotations + +import json + +import pytest +from test_workflow_edit import ( # type: ignore[import-not-found] + _force_json_renderer, + _graph, + reset_singleton, # noqa: F401 (autouse fixture) +) +from typer.testing import CliRunner + +from comfy_cli.command import nodes as nodes_cmd + +_SG_UUID = "84e2cf3f-de93-40ef-ab22-b9375296917b" + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph()) + + +def _show(capsys, name: str) -> dict: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(nodes_cmd.app, ["show", name], standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + for line in reversed([ln for ln in captured.strip().splitlines() if ln.strip()]): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +def test_uuid_is_named_as_a_subgraph_type(patched_graph, capsys): + env = _show(capsys, _SG_UUID) + assert env["ok"] is False + err = env["error"] + assert err["code"] == "node_not_found", err + blob = json.dumps(err).lower() + assert "subgraph" in blob, f"must explain the UUID is a subgraph type id: {err}" + # Point at the surface that CAN inspect it. + assert "slots" in blob or "ls-nodes" in blob, err + assert (err.get("details") or {}).get("subgraph_id") is True, err + # difflib matches against a UUID are noise; don't emit any. + assert not (err.get("details") or {}).get("close_matches"), err + + +def test_plain_unknown_class_keeps_close_matches(patched_graph, capsys): + env = _show(capsys, "KSample") + assert env["ok"] is False + err = env["error"] + assert err["code"] == "node_not_found" + assert "KSampler" in (err.get("details") or {}).get("close_matches", []), err From 4f102f5306ee3ddbefad4ae215b5efcfbbe23c45 Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 6 Aug 2026 15:29:44 -0700 Subject: [PATCH 31/53] docs(workflow_to_api): point stale comments at the renamed helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_get_widget_name_order` was renamed to `_schema_widget_pairs` on main. Two comments (one of them added during the merge) still named the old function, so they pointed at nothing. Found while auditing the reconciliation for lost work — the symbol scan flagged `_get_widget_name_order` as "present on the branch, absent from HEAD", which turned out to be this rename rather than a deletion. Co-Authored-By: Claude Opus 5 (1M context) --- comfy_cli/workflow_to_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 0c7aa7009..45f1ddefc 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1264,7 +1264,7 @@ def is_control(v: Any) -> bool: # A V3 dynamic combo (``COMFY_*COMBO*``) occupies its selector # slot plus a variable number of sub-input slots chosen by the # selected option. Copy the whole span through untouched and - # advance ``vidx`` in lockstep with ``_get_widget_name_order`` + # advance ``vidx`` in lockstep with ``_schema_widget_pairs`` # (which expands the same sub-inputs). Otherwise the walk # treats the combo as a single slot, reaches a later seed input # too early, checks the wrong slot for its control_after_generate @@ -1277,7 +1277,7 @@ def is_control(v: Any) -> bool: # ``_dynamic_combo_selected_subs`` returns connection-only ones too # (it leaves that call to the caller, as its docstring notes), and # counting those would over-report the span and shift every later - # widget. Same ``_is_widget_input`` test ``_get_widget_name_order`` + # widget. Same ``_is_widget_input`` test ``_schema_widget_pairs`` # applies, so the two walks stay in lockstep. subs = [ sub From 1e8b1543873c15996f1c06fea6be938a161b6f64 Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 6 Aug 2026 16:12:23 -0700 Subject: [PATCH 32/53] fix(workflow-ops): mint the first FREE autogrow slot, not the Nth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both autogrow namers derived N from `len(inputs whose name starts with "base.")`. That is only correct when the existing slots are a complete, gapless, schema-conforming run — which legacy workflows routinely are not: * a gap (`images.image0` + `images.image2`) counts 2, so `_plan_autogrow` minted a SECOND `images.image2` and the grow clobbered a wired input; * a non-conforming sibling (`images.foo`) counts 1, so the first grow skipped `images.image0` entirely. `_next_autogrow_name` shared the seed but looped past collisions, so it could skip a slot though never clobber one. Both now go through `_first_free_autogrow_index`, which counts the names we would actually mint rather than inputs that merely share the prefix — immune to gaps and to foreign siblings, and it keeps the server's sequential convention by filling the lowest free slot. Gap-filling is computed through `_autogrow_elem_name`, so a `names`/`prefix` template fills its own vocabulary. Closes the outstanding CodeRabbit review thread on this PR. Reproduced before the fix (`images.image0` + `images.image2` → `images.image2`) and after (→ `images.image1`); four regression tests cover gap-fill, foreign siblings, templated gap-fill, and the unchanged clean-run append. Co-Authored-By: Claude Opus 5 (1M context) --- comfy_cli/workflow_ops.py | 34 +++++++++++++---- tests/comfy_cli/command/test_workflow_edit.py | 37 +++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index f01fc3f10..8a3c0578e 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -327,6 +327,28 @@ def _autogrow_elem_name(base: str, n: int, template: dict | None) -> str: return f"{stem}{n}" +def _first_free_autogrow_index(taken: set, base: str, template: dict | None) -> int: + """The lowest N whose ``{base}.{elem(N)}`` name is not already present. + + Both autogrow namers used to seed N from ``len(inputs starting with base.)``, + which is only correct when the existing slots are a complete, gapless, + schema-conforming run. Legacy workflows routinely aren't: + + * a gap (``images.image0`` + ``images.image2``) counts 2 and mints a SECOND + ``images.image2``, clobbering a wired slot; + * a non-conforming sibling (``images.foo``) counts 1 and skips + ``images.image0`` entirely. + + Counting names we'd actually mint — rather than inputs that merely share the + prefix — is immune to both, and keeps the server's sequential convention by + filling the lowest free slot. + """ + n = 0 + while f"{base}.{_autogrow_elem_name(base, n, template)}" in taken: + n += 1 + return n + + def _next_autogrow_name(ins: list, requested: str, template: dict | None = None) -> str: """A free autogrow slot name. Prefer the op's requested name; if a concurrent connect already took it, grow the next sequential schema-derived slot (see @@ -336,12 +358,8 @@ def _next_autogrow_name(ins: list, requested: str, template: dict | None = None) if requested not in taken: return requested base = requested.split(".", 1)[0] - n = len([i for i in ins if str(i.get("name", "")).startswith(base + ".")]) - name = f"{base}.{_autogrow_elem_name(base, n, template)}" - while name in taken: - n += 1 - name = f"{base}.{_autogrow_elem_name(base, n, template)}" - return name + n = _first_free_autogrow_index(taken, base, template) + return f"{base}.{_autogrow_elem_name(base, n, template)}" def _next_inputcount_name(ins: list, requested: str) -> str: @@ -1694,6 +1712,6 @@ def _plan_autogrow(ins: list, base: str, elem_type: str | None, template: dict | ``{base}.{base[:-1]}{N}`` heuristic only when ``template`` is unavailable (schema unavailable: offline edit, catalog miss). Callers validate any explicitly requested key against this name before growing.""" - existing = [i for i in ins if str(i.get("name", "")).startswith(base + ".")] - elem = _autogrow_elem_name(base, len(existing), template) + taken = {str(i.get("name", "")) for i in ins} + elem = _autogrow_elem_name(base, _first_free_autogrow_index(taken, base, template), template) return {"name": f"{base}.{elem}", "type": elem_type or "*"} diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 6e7212e23..36738e2c2 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -1717,6 +1717,43 @@ def test_autogrow_without_template_keeps_heuristic(self): ] assert grown == ["images.image0"] + def test_autogrow_fills_a_gap_instead_of_colliding(self): + """A gapped legacy run (``image0`` + ``image2``) must not mint a SECOND + ``image2``. Both namers used to seed N from the count of ``images.``- + prefixed inputs, so a gap made the count land on an occupied slot and + the grow clobbered a wired input.""" + from comfy_cli.workflow_ops import _next_autogrow_name, _plan_autogrow + + ins = [{"name": "images.image0"}, {"name": "images.image2"}] + assert _plan_autogrow(ins, "images", "IMAGE")["name"] == "images.image1" + assert _next_autogrow_name(ins, "images.image0") == "images.image1" + + def test_autogrow_ignores_non_conforming_siblings(self): + """A sibling that isn't a mintable slot name (``images.foo``) must not + advance the counter — counting prefix matches skipped ``image0``.""" + from comfy_cli.workflow_ops import _next_autogrow_name, _plan_autogrow + + ins = [{"name": "images.foo"}] + assert _plan_autogrow(ins, "images", "IMAGE")["name"] == "images.image0" + assert _next_autogrow_name(ins, "images.foo") == "images.image0" + + def test_autogrow_gap_fill_respects_a_names_template(self): + """Gap-filling is computed from the names we'd actually mint, so a + ``names`` template fills its own vocabulary rather than a guessed stem.""" + from comfy_cli.workflow_ops import _plan_autogrow + + tpl = {"names": ["first", "second", "third"]} + ins = [{"name": "images.first"}, {"name": "images.third"}] + assert _plan_autogrow(ins, "images", "IMAGE", tpl)["name"] == "images.second" + + def test_autogrow_still_appends_on_a_clean_run(self): + """The common case is unchanged: a gapless run grows at the end.""" + from comfy_cli.workflow_ops import _plan_autogrow + + ins = [{"name": "images.image0"}, {"name": "images.image1"}] + assert _plan_autogrow(ins, "images", "IMAGE")["name"] == "images.image2" + assert _plan_autogrow([], "images", "IMAGE")["name"] == "images.image0" + def test_p9_autogrow_names_template_converges(self): """Two concurrent autogrow connects onto a ``names``-templated base still converge to the schema's two literal element names in either apply From 538702a11ead7f0e259c609d19830b1dcee43b39 Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 6 Aug 2026 22:10:21 -0700 Subject: [PATCH 33/53] fix(cql): never enum-validate an upload-backed input port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadImage.image is a COMBO whose options are the server's *installed input files*, not an install-time enum. validate_catalog checked a value against that snapshot anyway, so a file the user just uploaded — which by construction cannot be in it — came back as a hard error. Live in agenteval's i2v-attach: "the server reports 0 installed options for image" (no_options_available), the run was rejected, the agent retried, and the loop burned two of the turn's paid run slots. The no_options_available reasoning ("an empty option list is STRONGER evidence the value is unavailable") is right for MODEL folders — UNETLoader/CLIPLoader are static and set at install time — and exactly wrong for input files, which are per-user and populated at run time by upload. object_info already distinguishes the two: it marks upload-backed ports with a `_upload` flag, the same flag that makes the frontend render an upload button (image_upload, audio_upload, video_upload, file_upload all ship in the production catalog). Carry that marker through PortOptions the way the autogrow template is carried, and make the port simply unconstrained rather than special-casing the rejection sites: Port.is_upload_backed skips BOTH enum branches, since a fresh upload is absent from a POPULATED list just as surely as from an empty one. canonical_combo is exempt for the same reason — against a stale directory listing, "the option it clearly means" is unanswerable, and a case-only match would silently swap a just-uploaded Beach.JPG for the sample beach.jpg. The real membership check is the server's, at run time. LoadImageMask.channel (an ordinary enum on the same node) and empty model folders keep their existing errors. --- comfy_cli/cql/engine.py | 54 +++++++- tests/comfy_cli/cql/test_engine.py | 209 +++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+), 2 deletions(-) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 89732da27..abd849e57 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -48,6 +48,10 @@ class PortOptions: # autogrow input (e.g. {"input": {...}, "prefix": "image", "min": 1, "max": 50}). # Use ``Port.autogrow_template`` to pull out just the naming fields. template: dict | None = None + # True when object_info marked this input upload-backed — the frontend renders + # an upload button and the declared options are the server's *installed input + # files*, not an install-time enum. See ``Port.is_upload_backed``. + upload: bool = False @dataclass @@ -117,6 +121,25 @@ def autogrow_template(self) -> dict | None: return {"prefix": prefix} return None + @property + def is_upload_backed(self) -> bool: + """This COMBO's options are the server's *installed input files*, so the + catalog snapshot is not authoritative for it. + + ComfyUI marks these inputs in ``object_info`` with a ``_upload`` + flag (``LoadImage.image`` → ``image_upload``, ``LoadAudio.audio`` → + ``audio_upload``, ``LoadVideo.file`` → ``video_upload``, + ``Load3D.model_file`` → ``file_upload``) — the same flag that makes the + frontend render an upload button. Unlike a model folder (static, set at + install time), this list is per-user and grows at RUN time: a file the + user just uploaded can never be in the snapshot we validated against. + Enum-checking it therefore produces guaranteed false rejections, so the + port is left unconstrained and the real membership check is the + server's at run time. The sibling ``LoadImageMask.channel`` carries no + marker and stays a normal, constrained enum. + """ + return self.type == "COMBO" and self.options.upload + def canonical_combo(self, value: Any) -> Any | None: """Map a *mangled* COMBO value to the real option it clearly means, or None if it can't be resolved unambiguously. @@ -128,8 +151,15 @@ def canonical_combo(self, value: Any) -> Any | None: basename (case-insensitive) and, only when EXACTLY ONE option matches, return it. Ambiguous or unmatched values return None so the caller still surfaces ``unknown_enum_value``. Exact values return None (nothing to do). + + Upload-backed ports are exempt for the same reason they are exempt from + the enum check (see :attr:`is_upload_backed`): the option list is a + stale directory listing, so "the real option it clearly means" is not a + question this snapshot can answer. Rewriting there would silently swap a + just-uploaded ``Beach.JPG`` for the sample ``beach.jpg`` and generate + from the wrong file. """ - if self.type != "COMBO" or not self.enum_values: + if self.type != "COMBO" or not self.enum_values or self.is_upload_backed: return None opts = [str(e) for e in self.enum_values] s = str(value) @@ -193,7 +223,14 @@ def validate_catalog(self, value: Any) -> list[dict]: if self.validate_shape(value) is not None: return [] warnings: list[dict] = [] - if self.type == "COMBO" and self.enum_values: + if self.is_upload_backed: + # Upload-backed input file port: unconstrained, by design. Skipping + # BOTH enum branches is deliberate — a freshly uploaded file is + # absent from a POPULATED snapshot just as surely as from an empty + # one, so gating only the empty-list case would still false-reject + # (`LoadImage.image` typically ships a handful of sample images). + pass + elif self.type == "COMBO" and self.enum_values: # Membership compares on the stringified form BOTH ways, so a value # matches its option regardless of int/str (`8` ↔ "8", `8.0` ↔ "8"). # This keeps validate lenient (never false-warns on a real value) @@ -340,6 +377,18 @@ def _derive_pack(python_module: str) -> str: return "core" +def _upload_marked(opts_raw: dict) -> bool: + """True when the input's options dict carries an upload marker. + + ComfyUI has no single flag name — the marker is ``_upload`` and the + kind varies by loader (``image_upload``, ``audio_upload``, ``video_upload``, + ``file_upload`` are all present in the production catalog, and custom packs + add their own). Matching the suffix rather than an allow-list keeps a new + loader kind from silently regressing into false rejections. + """ + return any(isinstance(k, str) and k.endswith("_upload") and bool(v) for k, v in opts_raw.items()) + + def _parse_port_options(opts_raw: dict) -> PortOptions: template_raw = opts_raw.get("template") return PortOptions( @@ -351,6 +400,7 @@ def _parse_port_options(opts_raw: dict) -> PortOptions: control_after_generate=_control_after_generate_set(opts_raw.get("control_after_generate")), force_input=bool(opts_raw.get("forceInput", False)), template=template_raw if isinstance(template_raw, dict) else None, + upload=_upload_marked(opts_raw), ) diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 225e89328..27ae8ffce 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -1399,6 +1399,215 @@ def test_absent_input_is_not_reported_as_unavailable(self): assert ("unet_name", "no_options_available") not in by_field +class TestValidateUploadBackedCombo: + """A COMBO marked ``_upload`` in object_info lists the server's + *installed input files*, not an install-time enum — so the catalog snapshot + can never be authoritative for it and it must not be enum-validated. + + Found live: an agenteval run uploaded an image, wired it into + ``LoadImage.image``, and validate answered ``no_options_available`` ("the + server reports 0 installed options for image"). The workflow was rejected, + the agent retried, and the loop burned two of the turn's paid run slots. The + empty-list reasoning that ``no_options_available`` was built on holds for + MODEL folders (``UNETLoader``/``CLIPLoader`` — static, install-time) and is + exactly wrong for input files, which are per-user and populated at run time + by upload. A freshly uploaded file is missing from a POPULATED snapshot just + as surely as from an empty one, so BOTH enum branches are skipped. + """ + + def _object_info(self, **extra) -> dict[str, Any]: + # Shapes verified against the production catalog + # (services/ingest/data/object_info.json): LoadImage/LoadImageMask use + # the list-form dialect with `image_upload`, while the V3 loaders + # (LoadAudio/LoadVideo/Load3D) use `["COMBO", {"options": [...], ...}]` + # with their own kind of marker. + oi = { + "LoadImage": { + "input": {"required": {"image": [["beach.jpg", "example.png"], {"image_upload": True}]}}, + "input_order": {"required": ["image"]}, + "output": ["IMAGE", "MASK"], + "output_name": ["IMAGE", "MASK"], + "python_module": "nodes", + }, + "LoadImageMask": { + # The counter-case lives on the SAME node: `image` is + # upload-backed, `channel` is an ordinary enum and must stay + # constrained. + "input": { + "required": { + "image": [["beach.jpg", "example.png"], {"image_upload": True}], + "channel": [["alpha", "red", "green", "blue"]], + } + }, + "input_order": {"required": ["image", "channel"]}, + "output": ["MASK"], + "output_name": ["MASK"], + "python_module": "nodes", + }, + "SaveImage": { + "input": {"required": {"images": "IMAGE"}}, + "output": [], + "output_name": [], + "output_node": True, + "python_module": "nodes", + }, + } + oi.update(extra) + return oi + + def _graph(self, **extra) -> Graph: + return Graph.from_object_info(self._object_info(**extra)) + + def _port(self, graph: Graph, class_type: str, name: str): + return next(p for p in graph.node(class_type).inputs if p.name == name) + + def test_freshly_uploaded_filename_passes_against_a_populated_catalog(self): + """The regression, populated-list half: the snapshot lists other files, + the just-uploaded one is not among them, and that is NOT an error.""" + g = self._graph() + result = g.validate_workflow( + { + "1": {"class_type": "LoadImage", "inputs": {"image": "user_upload_9f2c1a.png"}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + ) + assert result["valid"] is True, result["errors"] + assert [e for e in result["errors"] if e["field"] == "image"] == [] + + def test_freshly_uploaded_filename_passes_against_an_empty_catalog(self): + """The regression as it actually fired: a server with no sample images + declares an EMPTY list, which used to emit ``no_options_available``.""" + oi = self._object_info() + oi["LoadImage"]["input"]["required"]["image"] = [[], {"image_upload": True}] + g = Graph.from_object_info(oi) + result = g.validate_workflow( + { + "1": {"class_type": "LoadImage", "inputs": {"image": "user_upload_9f2c1a.png"}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + ) + assert result["valid"] is True, result["errors"] + codes = {e["code"] for e in result["errors"]} + assert "no_options_available" not in codes + assert "unknown_enum_value" not in codes + + def test_no_warning_on_the_edit_surface_either(self): + """``workflow_ops._validate_widget`` (the set-widget/apply warning + surface the agent reads) funnels through the same ``validate_catalog``, + so assert it directly for both list states.""" + populated = self._port(self._graph(), "LoadImage", "image") + oi = self._object_info() + oi["LoadImage"]["input"]["required"]["image"] = [[], {"image_upload": True}] + empty = self._port(Graph.from_object_info(oi), "LoadImage", "image") + assert populated.validate_catalog("user_upload_9f2c1a.png") == [] + assert empty.validate_catalog("user_upload_9f2c1a.png") == [] + + def test_sibling_plain_enum_on_the_same_node_still_rejects(self): + """The anti-blanket check: exempting the upload port must not disarm + enum checking for the node's ordinary enums.""" + g = self._graph() + result = g.validate_workflow( + { + "1": { + "class_type": "LoadImageMask", + "inputs": {"image": "user_upload_9f2c1a.png", "channel": "cyan"}, + }, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + ) + assert result["valid"] is False + errs = [e for e in result["errors"] if e["code"] == "unknown_enum_value"] + assert [e["field"] for e in errs] == ["channel"] + assert "alpha" in errs[0]["hint"] + + def test_empty_model_folder_still_reports_no_options_available(self): + """The behaviour ``no_options_available`` exists for is untouched: an + unmarked (model-folder) combo with zero installed options still errors.""" + g = self._graph( + UNETLoader={ + "input": {"required": {"unet_name": [[]]}}, + "input_order": {"required": ["unet_name"]}, + "output": ["MODEL"], + "output_name": ["MODEL"], + "python_module": "nodes", + } + ) + result = g.validate_workflow( + { + "1": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux1-dev.safetensors"}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + ) + errs = [e for e in result["errors"] if e["code"] == "no_options_available"] + assert [e["field"] for e in errs] == ["unet_name"] + + def test_marker_is_recognized_for_every_upload_kind(self): + """ComfyUI names the flag per loader kind — ``image_upload``, + ``audio_upload``, ``video_upload``, ``file_upload`` all ship in the + production catalog — and the V3 loaders declare their options in the + dict-form dialect. All are exempt; an unmarked combo is not.""" + g = self._graph( + LoadAudio={ + "input": {"required": {"audio": ["COMBO", {"options": ["sample.mp3"], "audio_upload": True}]}}, + "output": ["AUDIO"], + "output_name": ["AUDIO"], + "python_module": "nodes", + }, + LoadVideo={ + "input": {"required": {"file": ["COMBO", {"options": ["bedroom.mp4"], "video_upload": True}]}}, + "output": ["VIDEO"], + "output_name": ["VIDEO"], + "python_module": "nodes", + }, + Load3D={ + "input": {"required": {"model_file": ["COMBO", {"options": ["none"], "file_upload": True}]}}, + "output": ["MESH"], + "output_name": ["MESH"], + "python_module": "nodes", + }, + ) + marked = [("LoadAudio", "audio"), ("LoadVideo", "file"), ("Load3D", "model_file")] + for class_type, name in marked: + port = self._port(g, class_type, name) + assert port.is_upload_backed is True, f"{class_type}.{name}" + assert port.validate_catalog("just-uploaded.bin") == [], f"{class_type}.{name}" + assert self._port(g, "LoadImageMask", "channel").is_upload_backed is False + + def test_a_falsey_marker_does_not_exempt(self): + """``image_upload: false`` is a declaration that the port is NOT + upload-backed — it must stay constrained.""" + oi = self._object_info() + oi["LoadImage"]["input"]["required"]["image"] = [["beach.jpg"], {"image_upload": False}] + port = self._port(Graph.from_object_info(oi), "LoadImage", "image") + assert port.is_upload_backed is False + assert [w["code"] for w in port.validate_catalog("nope.png")] == ["unknown_enum_value"] + + def test_upload_port_stays_a_widget_not_a_link(self): + """The exemption is validation-only: it must not move the port between + widget and link wiring, nor drop the options `show_node` displays.""" + port = self._port(self._graph(), "LoadImage", "image") + assert (port.is_link, port.enum_declared, port.enum_values) == (False, True, ["beach.jpg", "example.png"]) + + def test_uploaded_filename_is_not_rewritten_to_a_sample_file(self): + """``canonical_combo`` (set-widget's silent auto-correct) is exempt too: + against a stale directory listing, "the option it clearly means" is + unanswerable, and a case-only match would swap the user's upload for a + sample and generate from the wrong image.""" + port = self._port(self._graph(), "LoadImage", "image") + assert port.canonical_combo("Beach.JPG") is None + assert port.canonical_combo("images/beach.jpg") is None + # An unmarked combo keeps the auto-correct. + g = self._graph( + VAELoader={ + "input": {"required": {"vae_name": [["ae.safetensors"]]}}, + "output": ["VAE"], + "output_name": ["VAE"], + "python_module": "nodes", + } + ) + assert self._port(g, "VAELoader", "vae_name").canonical_combo("vae/ae.safetensors") == "ae.safetensors" + + class TestValidateDynamicCombo: """Validate expands a ``COMFY_DYNAMICCOMBO_V3`` selector's chosen option and checks the dotted sub-inputs the server will actually require (BE-3777). From 1a8d93cca49a0a5600853bdab8f351cb2dd93ba4 Mon Sep 17 00:00:00 2001 From: kishore Date: Sat, 8 Aug 2026 23:40:33 -0700 Subject: [PATCH 34/53] fix(cql): treat COMFY_MATCHTYPE ports as wildcards, not type mismatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COMFY_MATCHTYPE_V3 is the V3 schema's generic port: its concrete type is resolved at runtime from whatever it is wired to (ComfySwitchNode, ResizeImageMaskNode and friends). The edge type check only knew about the classic "*" wildcard, so EVERY edge into or out of a match-type port was reported as edge_type_mismatch. A single 48h prod window carried ~30 of these warnings, all on correct graphs: input 'images' expects IMAGE but ResizeImageMaskNode[0] produces COMFY_MATCHTYPE_V3 input 'on_false' expects COMFY_MATCHTYPE_V3 but LoraLoaderModelOnly[0] produces MODEL The agent had to write a disclaimer paragraph explaining them away in nearly every reply ("the 14 warnings are all pre-existing match-type flags — normal for this template"). That is the real cost: a validator that cries wolf teaches the model to discount its output generally, including the findings that matter. Matched by PREFIX rather than exact string so a future revision (V4, ...) cannot silently reintroduce the same false warnings. The wildcard deliberately does not blanket-silence: a genuine concrete-to- concrete mismatch (IMAGE -> MASK) still warns, so this trades no false negative for the false positives it removes — covered by its own test. Verified red -> green: reverting the predicate reproduces both prod warning strings verbatim. Full cql suite passes (298 tests). Co-Authored-By: Claude Opus 5 (1M context) --- comfy_cli/cql/engine.py | 29 ++++++++- tests/comfy_cli/cql/test_engine.py | 99 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index abd849e57..d45a27283 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -352,6 +352,33 @@ def can_apply(self, available: set[str]) -> bool: # --------------------------------------------------------------------------- +# Wildcard socket types: a port carrying one of these accepts/produces ANY type, +# so an edge touching it can never be a type mismatch. +# +# "*" is ComfyUI's classic wildcard. COMFY_MATCHTYPE_V3 is the V3 schema's +# match-type: a generic port whose concrete type is resolved at runtime from +# what it is wired to (ComfySwitchNode, ResizeImageMaskNode and friends). It was +# not recognised here, so every edge into or out of a V3 match-type port was +# reported as edge_type_mismatch — ~30 spurious warnings in a single 48h prod +# window, on graphs that were correct. The agent had to write a paragraph +# explaining them away in nearly every reply, which teaches it to discount +# validator output generally. +_WILDCARD_TYPE_PREFIX = "COMFY_MATCHTYPE" +_WILDCARD_TYPES = frozenset({"*"}) + + +def _is_wildcard_type(type_id: str) -> bool: + """True when a socket type accepts/produces any type. + + Matches COMFY_MATCHTYPE_V3 by prefix rather than exact string so a future + match-type revision (V4, ...) does not silently reintroduce the false + warnings this exists to prevent. + """ + if not type_id: + return False + return type_id in _WILDCARD_TYPES or type_id.startswith(_WILDCARD_TYPE_PREFIX) + + def _is_link(type_id: str, is_enum: bool, force_input: bool) -> bool: """Determine if an input participates in typed wiring (link) or is inline (widget).""" if is_enum: @@ -1140,7 +1167,7 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: if port is not None: src_type = src_m.outputs[out_idx].type dst_type = port.type - if src_type != "*" and dst_type != "*" and src_type != dst_type: + if not _is_wildcard_type(src_type) and not _is_wildcard_type(dst_type) and src_type != dst_type: # Find the correct index for the expected type correct = [f"[{i}]" for i, p in enumerate(src_m.outputs) if p.type == dst_type] hint = ( diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 27ae8ffce..2fb875572 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -2402,3 +2402,102 @@ def spy(**kwargs): g = Graph.load(input_path=str(dump)) assert g.node_count() > 0 assert seen == {"allow_network": False} + + +class TestMatchTypeWildcard: + """COMFY_MATCHTYPE_V3 is the V3 schema's generic port: its concrete type is + resolved at runtime from whatever it is wired to. It was not recognised as a + wildcard, so every edge touching one was reported as edge_type_mismatch — + ~30 spurious warnings in a single 48h prod window on graphs that were + correct (ComfySwitchNode, ResizeImageMaskNode). The agent explained them away + in nearly every reply, which teaches it to discount validator output. + """ + + @staticmethod + def _object_info() -> dict[str, Any]: + return { + "LoadImage": { + "input": {"required": {"image": [["a.png", "b.png"]]}}, + "input_order": {"required": ["image"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "name": "LoadImage", + }, + # Consumes anything, produces a match-type: the switch/resize shape. + "ComfySwitchNode": { + "input": {"required": {"on_true": ["COMFY_MATCHTYPE_V3", {}]}}, + "input_order": {"required": ["on_true"]}, + "output": ["COMFY_MATCHTYPE_V3"], + "output_name": ["out"], + "name": "ComfySwitchNode", + }, + "PreviewImage": { + "input": {"required": {"images": ["IMAGE", {}]}}, + "input_order": {"required": ["images"]}, + "output": [], + "output_name": [], + "output_node": True, + "name": "PreviewImage", + }, + } + + @pytest.fixture + def graph(self) -> Graph: + return Graph.from_object_info(self._object_info()) + + def test_concrete_into_matchtype_input_is_not_a_mismatch(self, graph: Graph): + """IMAGE → COMFY_MATCHTYPE_V3 input: the observed + "input 'input' expects COMFY_MATCHTYPE_V3 but LoadImage[0] produces IMAGE". + """ + wf = { + "1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}, + "2": {"class_type": "ComfySwitchNode", "inputs": {"on_true": ["1", 0]}}, + } + result = graph.validate_workflow(wf) + warns = [w for w in result["warnings"] if w["code"] == "edge_type_mismatch"] + assert warns == [], f"match-type input must accept any type, got {warns}" + + def test_matchtype_output_into_concrete_input_is_not_a_mismatch(self, graph: Graph): + """COMFY_MATCHTYPE_V3 → IMAGE input: the observed + "input 'images' expects IMAGE but ResizeImageMaskNode[0] produces COMFY_MATCHTYPE_V3". + """ + wf = { + "1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}, + "2": {"class_type": "ComfySwitchNode", "inputs": {"on_true": ["1", 0]}}, + "3": {"class_type": "PreviewImage", "inputs": {"images": ["2", 0]}}, + } + result = graph.validate_workflow(wf) + warns = [w for w in result["warnings"] if w["code"] == "edge_type_mismatch"] + assert warns == [], f"match-type output must satisfy any input, got {warns}" + + def test_genuine_mismatch_between_concrete_types_still_warns(self, graph: Graph): + """The wildcard must not blanket-silence real mismatches — otherwise the + fix trades false positives for false negatives.""" + oi = self._object_info() + oi["MaskOnly"] = { + "input": {"required": {"mask": ["MASK", {}]}}, + "input_order": {"required": ["mask"]}, + "output": [], + "output_name": [], + "output_node": True, + "name": "MaskOnly", + } + g = Graph.from_object_info(oi) + wf = { + "1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}, + "2": {"class_type": "MaskOnly", "inputs": {"mask": ["1", 0]}}, + } + result = g.validate_workflow(wf) + warns = [w for w in result["warnings"] if w["code"] == "edge_type_mismatch"] + assert len(warns) == 1, "IMAGE → MASK is a real mismatch and must still warn" + + def test_future_matchtype_revisions_are_wildcards_too(self): + """Prefix match, so a V4 match-type cannot silently reintroduce the + false warnings this exists to prevent.""" + from comfy_cli.cql.engine import _is_wildcard_type + + assert _is_wildcard_type("*") + assert _is_wildcard_type("COMFY_MATCHTYPE_V3") + assert _is_wildcard_type("COMFY_MATCHTYPE_V4") + assert not _is_wildcard_type("IMAGE") + assert not _is_wildcard_type("") From 42f56e53c1f539f536c99469282d6f22b6a002ad Mon Sep 17 00:00:00 2001 From: kishore Date: Sun, 9 Aug 2026 00:07:34 -0700 Subject: [PATCH 35/53] fix(cql): make a node that reaches no output visible instead of silently pruned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComfyUI's validate_prompt only walks output nodes and their transitive inputs; anything else is pruned and never validated. This engine reproduces that so a parked node isn't hard-rejected — but the consequence was that a pruned node became completely invisible: every promoted check skips it, so the graph could report "0 errors, 0 warnings" while doing nothing the author intended. Prod repro: a depth-ControlNet was added, configured, and wired IN correctly — but its CONTROL_NET output was never routed to the sampler. validate returned valid=true with zero errors and zero warnings. The graph then ran twice, producing an image with no pose applied, and cost two paid GPU runs and three turns of "it does nothing" / "still no pose" before the dangling link was found. A node that reaches no output is now reported. Deliberately ADVISORY, not an error: a scratch node parked mid-build is legitimate and the server does run the graph, so valid/ok semantics are unchanged and nothing downstream has to move with this. It only has to be visible. Two guards against becoming the next warning people learn to ignore: a fully wired graph warns about nothing, and output-less nodes (MarkdownNote and friends) are never flagged since feeding nothing is their job. A graph with no output node at all already fails prompt_no_outputs and is not piled on. Verified red -> green: disabling the check restores the silent-clean validate on the exact repro. Full cql suite passes (302 tests). Co-Authored-By: Claude Opus 5 (1M context) --- comfy_cli/cql/engine.py | 40 ++++++++++++ tests/comfy_cli/cql/test_engine.py | 99 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index d45a27283..9e74cdb37 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -1246,6 +1246,46 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: } ) + # A node the server will silently PRUNE (not reachable from any output) + # is almost always a wiring mistake: the author added it and forgot to + # route its result onward. Because pruned nodes are skipped by every + # promoted check above, such a graph could validate as + # "0 errors, 0 warnings" while doing nothing the author intended. + # + # Observed in prod: a depth-ControlNet whose output was never wired into + # the sampler validated completely clean; the graph then ran twice, + # producing an image with no pose applied, and cost two paid GPU runs and + # three turns of "it does nothing" before the dangling link was found. + # + # Advisory, not an error: a scratch node parked mid-build is legitimate, + # and the server does run the graph. It only has to be VISIBLE. + if has_output_node: + for node_id, node_data in workflow.items(): + if node_id == "_meta" or node_id in reachable: + continue + if not isinstance(node_data, dict): + continue + class_type = node_data.get("class_type") + m = self._nodes.get(class_type) if class_type else None + # Note-style nodes legitimately feed nothing. + if m is not None and not m.outputs: + continue + warnings.append( + { + "node_id": node_id, + "field": None, + "code": "node_not_reachable_from_output", + "message": ( + f"node {node_id} ({class_type}) feeds no output node — the server prunes it, " + f"so it will not run and has no effect on the result" + ), + "hint": ( + "wire its output into the chain that reaches a save/preview node, or delete it; " + "a node that reaches no output is skipped entirely" + ), + } + ) + return { "valid": len(errors) == 0, "errors": errors, diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 2fb875572..46c6d37ff 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -2501,3 +2501,102 @@ def test_future_matchtype_revisions_are_wildcards_too(self): assert _is_wildcard_type("COMFY_MATCHTYPE_V4") assert not _is_wildcard_type("IMAGE") assert not _is_wildcard_type("") + + +class TestUnreachableNodeIsVisible: + """A node that reaches no output is pruned by the server, and every promoted + check here skips pruned nodes — so such a graph could validate as + "0 errors, 0 warnings" while doing nothing the author intended. + + Prod repro: a depth-ControlNet whose output was never wired into the sampler + validated completely clean. The graph then ran twice, produced an image with + no pose applied, and cost two paid GPU runs and three turns of "it does + nothing" / "still no pose" before the dangling link was found. + """ + + @staticmethod + def _object_info() -> dict[str, Any]: + return { + "LoadImage": { + "input": {"required": {"image": [["a.png"]]}}, + "input_order": {"required": ["image"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "name": "LoadImage", + }, + "DepthControlNet": { + "input": {"required": {"image": ["IMAGE", {}]}}, + "input_order": {"required": ["image"]}, + "output": ["CONTROL_NET"], + "output_name": ["CONTROL_NET"], + "name": "DepthControlNet", + }, + "SaveImage": { + "input": {"required": {"images": ["IMAGE", {}]}}, + "input_order": {"required": ["images"]}, + "output": [], + "output_name": [], + "output_node": True, + "name": "SaveImage", + }, + "MarkdownNote": { + "input": {"required": {}}, + "input_order": {"required": []}, + "output": [], + "output_name": [], + "name": "MarkdownNote", + }, + } + + @pytest.fixture + def graph(self) -> Graph: + return Graph.from_object_info(self._object_info()) + + def test_dangling_node_is_reported(self, graph: Graph): + """The ControlNet is fully configured and internally valid — its OUTPUT + just goes nowhere. That silence is the whole defect.""" + wf = { + "1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}, + # Wired IN, but its CONTROL_NET output feeds nothing. + "2": {"class_type": "DepthControlNet", "inputs": {"image": ["1", 0]}}, + "3": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + result = graph.validate_workflow(wf) + + # Still valid: the server does run this graph, it just drops node 2. + assert result["valid"] is True, result["errors"] + warns = [w for w in result["warnings"] if w["code"] == "node_not_reachable_from_output"] + assert len(warns) == 1, f"the dangling node must be visible, got {result['warnings']}" + assert warns[0]["node_id"] == "2" + assert "DepthControlNet" in warns[0]["message"] + + def test_fully_wired_graph_warns_about_nothing(self, graph: Graph): + """No false positives on a correct graph — otherwise this becomes the + next warning the agent learns to explain away.""" + wf = { + "1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + } + result = graph.validate_workflow(wf) + warns = [w for w in result["warnings"] if w["code"] == "node_not_reachable_from_output"] + assert warns == [], f"a fully wired graph must warn about nothing, got {warns}" + + def test_output_less_notes_are_not_flagged(self, graph: Graph): + """MarkdownNote produces nothing and is supposed to feed nothing.""" + wf = { + "1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}, + "2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}}, + "3": {"class_type": "MarkdownNote", "inputs": {}}, + } + result = graph.validate_workflow(wf) + warns = [w for w in result["warnings"] if w["code"] == "node_not_reachable_from_output"] + assert warns == [], f"note-style nodes legitimately feed nothing, got {warns}" + + def test_no_output_node_at_all_does_not_double_report(self, graph: Graph): + """With no output node the graph already fails prompt_no_outputs; adding + a reachability warning per node would just be noise on top.""" + wf = {"1": {"class_type": "LoadImage", "inputs": {"image": "a.png"}}} + result = graph.validate_workflow(wf) + assert any(e["code"] == "prompt_no_outputs" for e in result["errors"]) + warns = [w for w in result["warnings"] if w["code"] == "node_not_reachable_from_output"] + assert warns == [], "prompt_no_outputs already says it; don't pile on" From 01ea0a249393e38094745d0b8075ed2c5cdcf4ab Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 01:47:40 -0700 Subject: [PATCH 36/53] docs+ops: freeze the op vocabulary (op-vocabulary-v1.md) + $-alias sugar + batch-clear hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freeze the structured-edit op vocabulary as a normative, versioned contract (docs/op-vocabulary-v1.md) and pin it against the code so neither drifts: * docs/op-vocabulary-v1.md — the six frozen kinds (add_node, connect, set_widget, delete_node, clear, reset_doc) with exact arg shapes, batchability, idempotency (op_id, per-workflow scope), the LWW conflict rule (stamp=[base_version, actor], op_id tiebreak), the concurrency semantics table (delete-wins, no move op, leaderless id minting, explicit reject/no-op per kind), the abort-remainder partial-batch ruling, alias rules, stamping/ID rules (with the FE stable-ID OPEN marker), attribution origins, and the amendment rule. * workflow_ops: export FROZEN_OPS / DEFERRED_OPS / BATCHABLE_OPS as the machine-readable projection of the doc. * resolve_ref: $-alias sugar — exactly one leading `$` is stripped before lookup ($-prefixed is the canonical documented form); `${...}` is rejected loudly as an unsubstituted recipe parameter instead of falling through to "node not found". layout.assign_positions strips the same `$` so $-aliased connects keep dataflow layering. * apply_specs: `clear` in a batch is now rejected with its own registered error code (workflow_clear_not_batchable) whose hint names the standalone `comfy workflow clear` command, instead of the generic "unknown op" / workflow_edit_invalid; apply/foreach render the code + hint. * tests/comfy_cli/test_op_vocabulary_contract.py enforces doc == constants == the apply_op / apply_specs dispatch tables, the alias rules, and the registered batch-clear rejection. Implements Linear BE-7142 (V1-001). Co-Authored-By: Claude Fable 5 --- comfy_cli/command/workflow_edit.py | 18 ++ comfy_cli/error_codes.py | 7 + comfy_cli/layout.py | 4 + comfy_cli/workflow_ops.py | 69 ++++- docs/op-vocabulary-v1.md | 282 ++++++++++++++++++ .../comfy_cli/test_op_vocabulary_contract.py | 225 ++++++++++++++ 6 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 docs/op-vocabulary-v1.md create mode 100644 tests/comfy_cli/test_op_vocabulary_contract.py diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index b7ccb8535..f84cc603d 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -478,6 +478,11 @@ def apply_cmd( workflow, ops, aliases = workflow_ops.apply_specs( workflow, graph, specs, actor=actor, base_version=base_version ) + except workflow_ops.NotBatchableError as e: + # A standalone-only op (clear) inside the batch: its own registered code, + # with the hint naming the standalone command to run instead. + renderer.error(code=e.code, message=f"batch failed: {e}", hint=e.hint) + raise typer.Exit(code=1) from e except (ValueError, KeyError) as e: # Atomic batch: nothing is written if any spec fails. renderer.error(code="workflow_edit_invalid", message=f"batch failed: {e}") @@ -589,6 +594,19 @@ def foreach_cmd( target = out / f"{name}_{i:03d}.json" _atomic_write_text(target, json.dumps(wf, indent=2)) written.append(str(target)) + except workflow_ops.NotBatchableError as e: + renderer.error( + code=e.code, + message=f"foreach failed: {e}", + hint=e.hint + + ( + f" ({len(written)} workflow(s) were already written to {out} — delete them or re-run)" + if written + else "" + ), + details={"written": written} if written else None, + ) + raise typer.Exit(code=1) from e except (workflow_ops.RecipeError, ValueError, KeyError) as e: # foreach writes one file per param-set as it goes, so a mid-batch failure # leaves the earlier files on disk. Surface them (in the hint AND machine- diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index b1a116f8b..22f133911 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -513,6 +513,13 @@ class ErrorCode: "unknown class_type, missing node, bad slot/widget name, or malformed address.", "run `comfy workflow slots ` for widget addresses or `comfy nodes types` for class_types", ), + ErrorCode( + "workflow_clear_not_batchable", + "A batch (`workflow apply` / `workflow foreach`) contained a `clear` op. `clear` wipes the whole " + "graph and is standalone-only (docs/op-vocabulary-v1.md: batchable = no), so the batch was " + "rejected atomically — nothing was applied.", + "run the standalone `comfy workflow clear ` first, then apply the remaining ops as a batch", + ), ErrorCode( "normalized_value", "Warning (not fatal): a set-widget value wasn't an exact COMBO option, so " diff --git a/comfy_cli/layout.py b/comfy_cli/layout.py index 86795ac44..21f7239c9 100644 --- a/comfy_cli/layout.py +++ b/comfy_cli/layout.py @@ -101,6 +101,10 @@ def assign_positions(workflow: dict, graph, specs: list) -> list: def endpoint(ref): node_part = str(ref).partition(".")[0].strip() + # `$alias` is sugar for `alias` (see workflow_ops.resolve_ref); `${...}` + # is a recipe-param hole that apply_specs rejects — not an alias. + if node_part.startswith("$") and not node_part.startswith("${"): + node_part = node_part[1:] if node_part in adds: return ("new", node_part) nid = int(node_part) if node_part.lstrip("-").isdigit() else node_part diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 8a3c0578e..c0fa6f416 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -81,6 +81,48 @@ def _new_op(kind: str, actor: str, base_version: int, **fields: Any) -> dict[str } +# --------------------------------------------------------------------------- +# The frozen op vocabulary — the normative contract is docs/op-vocabulary-v1.md; +# these constants are its machine-readable projection, and +# tests/comfy_cli/test_op_vocabulary_contract.py pins doc == constants == the +# dispatch tables in apply_op / apply_specs. Amend the doc (versioned amendment +# section) before touching any of the three. +# --------------------------------------------------------------------------- + +#: Every op kind in the v1 vocabulary, including defined-but-deferred kinds. +FROZEN_OPS: tuple[str, ...] = ("add_node", "connect", "set_widget", "delete_node", "clear", "reset_doc") + +#: Kinds frozen in the contract whose replay is not implemented yet +#: (``reset_doc`` is specified in op-vocabulary-v1.md; implementation is +#: deferred to the bulk-writers ticket). ``apply_op`` must keep rejecting these. +DEFERRED_OPS: tuple[str, ...] = ("reset_doc",) + +#: Kinds a batch (``apply_specs``) dispatches. ``clear`` and ``reset_doc`` are +#: standalone-only: they rewrite the whole document, so they never ride inside +#: an atomic batch. +BATCHABLE_OPS: tuple[str, ...] = ("add_node", "connect", "set_widget", "delete_node") + + +class NotBatchableError(ValueError): + """A frozen op kind that is standalone-only was submitted inside a batch. + + The command layer renders this with the registered ``code``/``hint`` below + (see ``comfy_cli/error_codes.py``) instead of the generic + ``workflow_edit_invalid``, so a caller learns the exact standalone command + to run rather than re-trying the batch. + """ + + code = "workflow_clear_not_batchable" + hint = "run the standalone `comfy workflow clear ` first, then apply the remaining ops as a batch" + + def __init__(self, index: int): + super().__init__( + f"spec #{index}: `clear` wipes the whole graph and is standalone-only (op-vocabulary-v1: " + "batchable = no) — it never rides inside a batch. No changes were applied — the batch was " + "discarded. Run `comfy workflow clear ` as its own command, then apply the remaining ops." + ) + + # Node types that live only in the UI graph and never reach the API — the # frontend's isVirtualNode set. Mirrors workflow_to_api._UI_ONLY_NODE_TYPES; # duplicated rather than imported to keep workflow_ops import-free of the @@ -1022,8 +1064,24 @@ def _slot_name(slots: Any, idx: Any) -> Any: def resolve_ref(ref: Any, aliases: dict[str, Any]) -> Any: - """Map an alias to its minted id; pass ints/unknown strings through.""" + """Map an alias (bare or ``$``-prefixed) to its minted id; pass ints and + unknown strings through. + + ``$up`` and ``up`` address the same alias — exactly one leading ``$`` is + stripped before lookup (``$``-prefixed is the canonical documented form; see + docs/op-vocabulary-v1.md). ``${name}`` is NOT an alias: that shape is + reserved for recipe parameters (filled by :func:`substitute_params`), so an + unsubstituted one is rejected loudly here instead of falling through to a + misleading "node not found".""" if isinstance(ref, str): + if ref.startswith("${"): + raise ValueError( + f"{ref!r} looks like an unsubstituted recipe parameter — `${{name}}` is reserved for recipe " + "params (declare it under `params` and fill it with --param); an alias reference is `$name` " + "(or bare `name`)" + ) + if ref.startswith("$"): + ref = ref[1:] if ref in aliases: return aliases[ref] if ref.lstrip("-").isdigit(): @@ -1086,11 +1144,20 @@ def apply_specs( workflow, op = delete_node( workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version ) + elif kind == "clear": + # In the frozen vocabulary but standalone-only — surfaced with + # its own registered code so the caller learns the standalone + # command instead of a generic "unknown op". + raise NotBatchableError(i) else: raise ValueError(f"spec #{i}: unknown op {kind!r}") except KeyError as e: raise ValueError(f"spec #{i} ({kind}) is missing required field {e}") from e ops.append(op) + except NotBatchableError: + # Already carries the registered code, the standalone command, and the + # nothing-was-applied statement — don't wrap it into a generic hint. + raise except (ValueError, KeyError) as e: raise _rehint_discarded_batch(e, pre_batch_hint) from e return workflow, ops, aliases diff --git a/docs/op-vocabulary-v1.md b/docs/op-vocabulary-v1.md new file mode 100644 index 000000000..dbbc7669c --- /dev/null +++ b/docs/op-vocabulary-v1.md @@ -0,0 +1,282 @@ +# Op vocabulary — v1 (frozen) + +Status: **FROZEN**. This document is the normative contract for the structured-edit +op vocabulary in `comfy_cli/workflow_ops.py`: the op kinds, their argument shapes, +their idempotency and conflict rules, and the batch protocol. Downstream repos +(cloud `services/agent`, `harness`, the merge consumer) cite this document **by +commit SHA**, not by branch. + +Machine-readable projection: `workflow_ops.FROZEN_OPS`, `workflow_ops.DEFERRED_OPS`, +`workflow_ops.BATCHABLE_OPS`. `tests/comfy_cli/test_op_vocabulary_contract.py` +enforces that this document, those constants, and the `apply_op` / `apply_specs` +dispatch tables agree. A change to any of the three without the others fails CI. + +This freeze describes the code on the **unmerged branch +`fix/validate-lowers-ui-to-api`**. Until that branch merges to `master`, a SHA +citation must point at a commit on that branch. + +## 1. Frozen op kinds + +Six kinds. No other kind is valid in v1: `apply_op` rejects an unknown kind with +`ValueError("unknown op ...")` — it never ignores one. + +| Kind | Batchable | Standalone command | Summary | +|------|-----------|--------------------|---------| +| `add_node` | yes | `comfy workflow add-node` | Mint and insert one node | +| `connect` | yes | `comfy workflow connect` | Wire one output slot to one input slot | +| `set_widget` | yes | `comfy workflow set-widget` | Set one widget value by name | +| `delete_node` | yes | `comfy workflow delete` | Remove one node and its incident links | +| `clear` | no | `comfy workflow clear` | Remove every node, link, and group | +| `reset_doc` | no | (deferred) | Reset the whole document to an empty baseline | + +Batchable = the kind is accepted by `apply_specs` (the `workflow apply` / +`workflow foreach` batch surface). `clear` and `reset_doc` rewrite the whole +document, so they are standalone-only: a batch containing `clear` is rejected +atomically with error code `workflow_clear_not_batchable` and a hint naming the +standalone `comfy workflow clear` command. Nothing from such a batch is applied. + +Every op carries the common envelope stamped by `_new_op`: + +```json +{ + "op": "", + "op_id": "", + "actor": "", + "base_version": 0, + "stamp": [, ""] +} +``` + +### 1.1 `add_node` + +Spec form (batch input): + +```json +{"op": "add_node", "class_type": "KSampler", "at": [x, y], "as": "sampler"} +``` + +`at` is optional (layout assigns a collision-free position at mint time; the +position freezes into the op). `as` is optional and declares a batch-local alias +(section 5). Minted op fields beyond the envelope: `node_id` (mint_id int), +`class_type`, `pos`, `node` (the complete node object — replay inserts it verbatim). + +* Idempotency: re-applying the same `op_id` is a no-op; independently, replaying + an `add_node` whose `node_id` already exists in the graph is a no-op. +* Conflict: none — ids are minted leaderlessly (section 6), so two concurrent + `add_node` ops never target the same identity. +* Invalid: an unknown `class_type` is rejected at mint time (`UnknownNodeType`, + rendered as `node_not_found` with close matches). + +### 1.2 `connect` + +Spec form: + +```json +{"op": "connect", "from": "$up.MODEL", "to": "$sampler.model"} +``` + +`from`/`to` are `.` where `` is an int id, a bare alias, or a +`$`-prefixed alias, and `` is a name or an index. Minted op fields: +`link_id` (mint_id int), `from_node`, `from_slot` (resolved output index), +`to_node`, `to_slot` (resolved input index; `null` for autogrow), `link_type`, +and optionally `grow` (autogrow slot descriptor: `{name, type, widget?, inputcount?}`). + +* Idempotency: `op_id` no-op; a link tuple with an already-present `link_id` is + not appended twice. +* Conflict: a concrete input holds at most one link — a connect to an occupied + input replaces it and fully retires the prior link (`_remove_link`). Two + concurrent connects to the same concrete input are an update-vs-update + conflict on that input (section 3). Autogrow connects are non-clobbering: + each grows a fresh slot keyed by `grow_id` (the link id), so both survive. +* Invalid: type-mismatched slots are rejected at mint time; a link cannot cross + a subgraph boundary (rejected with the boundary explanation). + +### 1.3 `set_widget` + +Spec form: + +```json +{"op": "set_widget", "node": "$sampler", "widget": "steps", "value": 30} +``` + +`node` is an int id, alias, `$alias`, or a subgraph-scoped id (section 6). +Minted op fields: `node_id`, `widget` (name, never index), `value`, `old`; for a +subgraph interior write also `path` (resolved node path, list of strings) and +`inner_widget`; optionally `warnings` (e.g. `normalized_value`). + +* Idempotency: `op_id` no-op. +* Conflict: last-writer-wins per `(node, widget)` target (section 3). +* Invalid: an unknown widget name or shape-mismatched value is rejected at mint + time; at replay an unknown widget name on a live node also rejects + (`_widget_index` raises), while a missing node is a no-op (delete wins). + +### 1.4 `delete_node` + +Spec form: + +```json +{"op": "delete_node", "node": "$sampler"} +``` + +Minted op fields: `node_id`, `removed_links` (ids of every link incident to the +node at mint time). Replay removes the node, drops incident links (both the +recorded ones and any link whose endpoint is the node), and scrubs dangling +input/output references. + +* Idempotency: `op_id` no-op; replaying a delete of an already-absent node is a + no-op. +* Conflict: delete wins over concurrent updates (section 3). +* Invalid: deleting a node that does not exist is rejected at mint time + (`node not found` with the live node inventory). + +### 1.5 `clear` — standalone only + +Command: `comfy workflow clear `. Minted op fields: `removed_nodes` (ids +present at mint time). Replay empties `nodes`, `links`, and `groups`. +`last_node_id` / `last_link_id` are preserved so ids minted after a clear stay +monotonic — id reuse would let a merge resurrect a deleted node's identity. + +* Batchable: **no**. `apply_specs` rejects it with the registered code + `workflow_clear_not_batchable`; the batch is discarded atomically and the hint + names the standalone command. +* Idempotency: `op_id` no-op; clearing an empty document changes nothing. + +### 1.6 `reset_doc` — standalone only, deferred + +Defined here; **implementation is deferred to the bulk-writers ticket**. +`apply_op` currently rejects it (`unknown op 'reset_doc'`), and the contract +tests pin that it stays rejected until it is un-deferred by amendment. + +Semantics when implemented: replace the entire document with the empty baseline, +including apply bookkeeping — unlike `clear`, which preserves the id high-water +marks and the applied-op history. Because it erases replay history, it is a +history barrier: ops minted against a pre-reset `base_version` do not replay +across it. Guard semantics: the CLI surface requires an explicit `--confirm` +flag; without it the command fails closed and applies nothing. Not batchable, +for the same reason as `clear`. + +## 2. Idempotency and identity + +* Every op carries `op_id`: uuid4 hex, minted by the **creator, before + dispatch** (`_new_op`). Receivers never regenerate or rewrite an `op_id`. +* `apply_op` records applied `op_id`s in the document's `_applied_ops` list and + drops any op whose `op_id` is already there. **Uniqueness scope is + PER-WORKFLOW**: the same `op_id` can exist in two different workflow documents + without interaction; within one document each op applies exactly once. +* `_applied_ops` (and `_widget_stamps`) are apply-time bookkeeping, stripped + before serialization to disk (`strip_internal`). + +## 3. Conflict rules + +Scalar conflicts resolve by last-writer-wins on the op stamp. The stamp is +`stamp = [base_version, actor]` (stamped by `_new_op`); the exact comparison is +`_stamp_key` in `workflow_ops.py`: + +```python +def _stamp_key(op: dict) -> list: + stamp = op.get("stamp") or [op.get("base_version", 0), op.get("actor", "")] + return [stamp[0], stamp[1], op["op_id"]] +``` + +and the gate is `_lww_gate`: a write applies iff `_stamp_key(op) > list(prior)` +for its target. Higher `base_version` wins; ties break by `actor`, then by the +unique `op_id` — so no two distinct ops ever compare equal, the order is total, +and the surviving value is independent of apply order. + +| Scenario | Ruling | Where in code | +|----------|--------|---------------| +| update vs update (same widget) | LWW on `stamp` with `op_id` tiebreak; loser dropped | `_lww_gate` / `_stamp_key` | +| update vs delete | **delete wins**: `set_widget` to a deleted node is a no-op; `connect` with either endpoint deleted is a no-op; replay never raises on a since-removed target | `_apply_set_widget` (missing node → return), `_apply_connect` (missing endpoint → return) | +| concurrent moves | no `move` op exists in v1 — positions are decided once at `add_node` mint time and frozen into the op; live position editing is frontend view state, out of scope until the FE stable-ID reconciliation (section 6) | `add_node` / `layout.cascade_pos` | +| edges referencing deleted nodes | the connect no-ops (delete wins); a delete removes incident links and scrubs every dangling input/output reference, so no dangling edge survives either order | `_apply_connect`, `_apply_delete_node` | +| duplicate entity creation | impossible by construction across writers (random 53-bit `mint_id`, no shared counter); a replayed `add_node` whose `node_id` already exists is a no-op; a re-sent op is dropped by `op_id` | `mint_id`, `_apply_add_node` | +| concurrent autogrow connects to one base | both survive: each grows a fresh slot keyed by `grow_id`; their display order is the one sequence decision a leaderless writer cannot make and is surfaced by `detect_conflict` for the merge consumer | `_apply_connect` (grow path), `detect_conflict` | +| invalid / inapplicable ops | explicit per kind — unknown kind: **reject** (`apply_op` raises); malformed op (missing required field): **reject**; well-formed op whose target node is gone: **no-op** (delete wins); `set_widget` naming a widget the live schema does not have: **reject**; `clear`/`reset_doc` inside a batch: **reject** with `workflow_clear_not_batchable` / `unknown op`. Rejection is never silent | `apply_op`, `apply_specs`, `_widget_index` | + +## 4. Partial batches: abort-remainder + +Ruling: **abort-remainder**. In a batch of `n` ops, if op `k` fails, ops +`k..n` are **not applied**. The ack reports: + +```json +{"applied_count": , "failed": {"index": , "op": {...}, "code": ""}} +``` + +A retried batch converges by the idempotency rule: every op that did apply is +dropped on re-apply by its `op_id`, so retrying the whole batch is exactly-once +per op. The retrier fixes or removes the failing op; it never re-mints `op_id`s +for ops that may already have landed. + +The local CLI batch surface (`comfy workflow apply`) is stricter than the +minimum: it discards the **entire** batch on any failure (`applied_count` is +always 0 on failure — nothing is written) and restates the surviving node +inventory in the error. That is a conforming implementation of abort-remainder; +a merge consumer MUST NOT apply any op after the failing index and MUST report +`applied_count` truthfully. + +## 5. Aliases + +An `add_node` spec may declare a batch-local alias with `"as": ""`. Later +specs in the same batch reference the minted node by alias. + +* Both reference forms are valid: bare (`"up.MODEL"`, `"node": "up"`) and + `$`-prefixed (`"$up.MODEL"`, `"node": "$up"`). Exactly one leading `$` is + stripped before lookup (`resolve_ref`); the two forms resolve identically. +* **`$`-prefixed is the canonical form** — use it in documentation and + generated batches. It makes an alias visually distinct from a node id or a + class name. +* `${` is **rejected**: `${name}` is reserved for recipe parameters + (`substitute_params` fills them from `--param`; an undeclared `${name}` is a + `RecipeError`). A `${...}` reaching `resolve_ref` means an unsubstituted + recipe parameter and fails with a message saying exactly that — it is never + treated as an alias or a node id. +* A duplicate alias in one batch is rejected (`alias ... is already defined by + an earlier spec`); an unknown alias falls through to id resolution and fails + as `node not found` with the live node inventory. Both behaviors predate this + freeze and are unchanged. +* Aliases are batch-scoped. They do not persist into the document or across + batches; the ack maps each alias to its minted `node_id`. + +## 6. Stamping and IDs + +* `op_id`: uuid4 hex, minted by the creator pre-dispatch. Receivers never + regenerate one (section 2). +* Node and link ids: `mint_id()` — random ints in `[2^40, 2^53)`. Leaderless + and collision-free without coordination; always inside JS + `Number.MAX_SAFE_INTEGER`; always larger than small frontend counter ids. + `last_node_id` / `last_link_id` are advisory high-water marks, never + allocators. +* Subgraph-scoped ids: an interior node is addressed as `57:3` (the flattened + form the UI→API lowering mints and `validate` / server errors print) or + `57/3` (the edit-path form); both resolve to the same interior target. **Ops + must carry fully-scoped ids** — a bare interior id is meaningless at the top + level and is rejected, not guessed. +* OPEN: ID representation is to be reconciled with the FE stable-ID workstream + before this document's v1.1. Until then, the shapes above are the contract. + +## 7. Attribution origins + +The `actor` field carries the origin of the op. Frozen origin grammar: + +| Origin | Format | Example | +|--------|--------|---------| +| agent turn | `agent::` | `agent:th_8f2c:12` | +| human editor | `human::` | `human:u_41ab:tab_2` | +| system-minted | `system:mint` | `system:mint` | + +The actor participates in LWW tie-breaking (section 3), so origin strings must +be stable within a writer session. The CLI's `--actor` flag carries the origin; +its default `cli` is a legacy value accepted for interactive local use — +merge-consumer traffic uses the structured forms above. + +## 8. Amendments + +* Post-freeze changes require a **versioned amendment section** appended to + this document (`## Amendment v1.x — `), stating what changed and why. + Silent edits to frozen sections are not valid; the contract tests pin the + frozen table against the code. +* Downstream repos cite this document by commit SHA and upgrade by moving the + SHA, never by tracking a branch. +* Adding, removing, or re-scoping an op kind requires updating `FROZEN_OPS` / + `DEFERRED_OPS` / `BATCHABLE_OPS`, the dispatch tables, and this document in + one commit — `tests/comfy_cli/test_op_vocabulary_contract.py` fails otherwise. diff --git a/tests/comfy_cli/test_op_vocabulary_contract.py b/tests/comfy_cli/test_op_vocabulary_contract.py new file mode 100644 index 000000000..b845c3697 --- /dev/null +++ b/tests/comfy_cli/test_op_vocabulary_contract.py @@ -0,0 +1,225 @@ +"""Enforce docs/op-vocabulary-v1.md against the code it freezes. + +The doc is the normative contract for the structured-edit op vocabulary; these +tests pin it two ways so neither the doc nor the code can drift silently: + + * the doc's frozen-kinds table == ``workflow_ops.FROZEN_OPS`` == the kinds + ``apply_op`` actually replays (minus the explicitly deferred ones); + * the doc's ``Batchable`` column == the kinds ``apply_specs`` actually + dispatches; + * the ``$``-alias sugar and the batch-``clear`` rejection behave exactly as + the doc rules. +""" + +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any + +import pytest + +from comfy_cli import error_codes, workflow_ops +from comfy_cli.cql.engine import Graph + +DOC = Path(__file__).resolve().parents[2] / "docs" / "op-vocabulary-v1.md" + + +# --------------------------------------------------------------------------- +# doc parsing — the frozen-kinds table is the machine-readable surface +# --------------------------------------------------------------------------- + + +def _parse_frozen_table() -> dict[str, bool]: + """Parse the doc's frozen-kinds markdown table into {kind: batchable}. + + The table is identified by a header row containing both ``Kind`` and + ``Batchable``; the ``Batchable`` cell must be exactly ``yes`` or ``no``. + """ + assert DOC.is_file(), f"contract doc missing: {DOC}" + lines = DOC.read_text(encoding="utf-8").splitlines() + for i, line in enumerate(lines): + stripped = line.strip() + if not (stripped.startswith("|") and "Kind" in stripped and "Batchable" in stripped): + continue + header = [c.strip() for c in stripped.strip("|").split("|")] + kind_col = header.index("Kind") + batch_col = header.index("Batchable") + table: dict[str, bool] = {} + for row in lines[i + 2 :]: # skip the |---| separator + row = row.strip() + if not row.startswith("|"): + break + cells = [c.strip() for c in row.strip("|").split("|")] + kind = cells[kind_col].strip("`") + batchable = cells[batch_col] + assert batchable in ("yes", "no"), f"Batchable cell for {kind!r} must be exactly yes/no, got {batchable!r}" + table[kind] = batchable == "yes" + assert table, "frozen-kinds table has a header but no rows" + return table + raise AssertionError("no markdown table with `Kind` and `Batchable` columns found in the doc") + + +# --------------------------------------------------------------------------- +# probes — what the code actually accepts, discovered behaviorally +# --------------------------------------------------------------------------- + + +def _apply_op_accepts(kind: str) -> bool: + """True iff ``apply_op`` dispatches ``kind`` (vs rejecting it as unknown). + + A malformed-but-dispatched probe op fails with KeyError etc. — that still + counts as accepted; only the explicit ``unknown op`` rejection counts as no. + """ + wf: dict[str, Any] = {"nodes": [], "links": []} + op = {"op": kind, "op_id": uuid.uuid4().hex, "actor": "probe", "base_version": 0, "stamp": [0, "probe"]} + try: + workflow_ops.apply_op(wf, op, None) + except ValueError as e: + if "unknown op" in str(e): + return False + except Exception: + pass # dispatched into a handler, probe op just lacks that kind's fields + return True + + +def _apply_specs_accepts(kind: str) -> bool: + """True iff ``apply_specs`` dispatches ``kind`` inside a batch.""" + try: + workflow_ops.apply_specs({"nodes": [], "links": []}, _graph(), [{"op": kind}]) + except workflow_ops.NotBatchableError: + return False + except (ValueError, KeyError) as e: + return "unknown op" not in str(e) + return True + + +# --------------------------------------------------------------------------- +# a two-node catalog: one MODEL producer, one MODEL consumer +# --------------------------------------------------------------------------- + + +def _object_info() -> dict[str, Any]: + return { + "TinyLoader": { + "input": {"required": {"ckpt_name": [["a.safetensors", "b.safetensors"]]}}, + "input_order": {"required": ["ckpt_name"]}, + "output": ["MODEL"], + "output_name": ["MODEL"], + "category": "loaders", + "display_name": "Tiny Loader", + "python_module": "nodes", + }, + "TinySink": { + "input": {"required": {"model": "MODEL"}}, + "input_order": {"required": ["model"]}, + "output": [], + "output_name": [], + "category": "test", + "display_name": "Tiny Sink", + "python_module": "nodes", + }, + } + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +def _connect_specs(from_ref: str, to_ref: str) -> list[dict]: + return [ + {"op": "add_node", "class_type": "TinyLoader", "as": "up"}, + {"op": "add_node", "class_type": "TinySink", "as": "sink"}, + {"op": "connect", "from": from_ref, "to": to_ref}, + ] + + +# --------------------------------------------------------------------------- +# 1. the doc's frozen kinds == FROZEN_OPS == apply_op's dispatch table +# --------------------------------------------------------------------------- + + +def test_doc_lists_exactly_the_apply_op_kinds(): + table = _parse_frozen_table() + assert set(table) == set(workflow_ops.FROZEN_OPS), ( + f"doc table {sorted(table)} != FROZEN_OPS {sorted(workflow_ops.FROZEN_OPS)}" + ) + # The probe must be able to tell acceptance from rejection at all. + assert not _apply_op_accepts("definitely_not_an_op") + accepted = {k for k in workflow_ops.FROZEN_OPS if _apply_op_accepts(k)} + expected = set(workflow_ops.FROZEN_OPS) - set(workflow_ops.DEFERRED_OPS) + assert accepted == expected, ( + f"apply_op accepts {sorted(accepted)} but the frozen vocabulary (minus deferred " + f"{sorted(workflow_ops.DEFERRED_OPS)}) is {sorted(expected)}" + ) + # Deferred kinds are frozen in the doc but must NOT be replayable yet. + for kind in workflow_ops.DEFERRED_OPS: + assert kind in workflow_ops.FROZEN_OPS + assert not _apply_op_accepts(kind), f"deferred op {kind!r} is implemented; un-defer it in the contract" + + +# --------------------------------------------------------------------------- +# 2. the doc's Batchable column == apply_specs' dispatch table +# --------------------------------------------------------------------------- + + +def test_batchability_matches_apply_specs(): + table = _parse_frozen_table() + doc_batchable = {k for k, batchable in table.items() if batchable} + assert doc_batchable == set(workflow_ops.BATCHABLE_OPS) + probed = {k for k in workflow_ops.FROZEN_OPS if _apply_specs_accepts(k)} + assert probed == doc_batchable, ( + f"apply_specs dispatches {sorted(probed)} but the doc marks {sorted(doc_batchable)} batchable" + ) + + +# --------------------------------------------------------------------------- +# 3./4./5. alias rules: bare and $-prefixed resolve identically; ${ rejects +# --------------------------------------------------------------------------- + + +def test_dollar_prefixed_alias_resolves(): + specs = _connect_specs("$up.MODEL", "$sink.model") + specs.append({"op": "set_widget", "node": "$up", "widget": "ckpt_name", "value": "b.safetensors"}) + wf, ops, aliases = workflow_ops.apply_specs({"nodes": [], "links": []}, _graph(), specs) + links = wf["links"] + assert len(links) == 1 + assert links[0][1] == aliases["up"] and links[0][3] == aliases["sink"] + set_op = next(op for op in ops if op["op"] == "set_widget") + assert set_op["node_id"] == aliases["up"] + assert set_op["value"] == "b.safetensors" + + +def test_bare_alias_still_resolves(): + wf, _ops, aliases = workflow_ops.apply_specs( + {"nodes": [], "links": []}, _graph(), _connect_specs("up.MODEL", "sink.model") + ) + links = wf["links"] + assert len(links) == 1 + assert links[0][1] == aliases["up"] and links[0][3] == aliases["sink"] + + +def test_dollar_brace_rejected(): + with pytest.raises(ValueError, match="recipe param"): + workflow_ops.apply_specs({"nodes": [], "links": []}, _graph(), _connect_specs("${up}.MODEL", "$sink.model")) + + +# --------------------------------------------------------------------------- +# 6. clear in a batch: registered code, hint names the standalone command +# --------------------------------------------------------------------------- + + +def test_clear_rejected_in_batch_with_registered_hint(): + with pytest.raises(workflow_ops.NotBatchableError) as ei: + workflow_ops.apply_specs( + {"nodes": [], "links": []}, + _graph(), + [{"op": "add_node", "class_type": "TinyLoader"}, {"op": "clear"}], + ) + err = ei.value + assert error_codes.is_registered(err.code), f"{err.code!r} is not in error_codes.REGISTRY" + registered = error_codes.get(err.code) + assert registered is not None and "comfy workflow clear" in (registered.hint or "") + assert "comfy workflow clear" in err.hint + # Atomicity is part of the message contract: the caller must learn nothing landed. + assert "no changes were applied" in str(err).lower() From 7e732242d971daf0d2d30f22f997abfacd78986e Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 02:01:23 -0700 Subject: [PATCH 37/53] docs: pin replication/replay semantics from the CRDT replay spike (V1-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append frozen section 8 "Replication and replay semantics" to docs/op-vocabulary-v1.md (part of the initial freeze — the doc has not shipped, so this is not an amendment), covering the LWW/replication load-bearing rules the code implements but did not pin: * 8.1 stamp comparison is code-point lexicographic ([base_version numeric, actor, op_id] — Python str order; ASCII actors required so JS UTF-16 comparison agrees) * 8.2 op_id format is LWW-load-bearing: uuid4().hex — exactly 32 lowercase hex chars, no dashes; lexicographic tiebreak decides conflict outcomes * 8.3 last_node_id/last_link_id are max-registers (merge = max); pins that connect does not bump last_link_id today and the intended symmetric rule * 8.4 inputcount-family autogrow: one op writing two registers — slot growth keyed by grow_id plus a count-widget write stamped with the connect's own op_id/stamp through the same LWW gate, value planned at mint time * 8.5 add_node's op.node payload is authoritative — receivers apply it verbatim, never re-mint from the catalog * 8.6 bootstrap rule: all replicas fork one common initial snapshot (independent re-seeding duplicates content on first merge) * 8.7 subgraph scope: set_widget-only, three address forms normalize to one resolved-path write target, connect/add_node/delete_node refuse interior scope, deterministic sha256 definition forking; OPEN marker for full shared-definition forking semantics before v1.1 Machine-pin the cheap invariant: test_op_id_format_is_frozen asserts every minted op kind emits a 32-char lowercase-hex op_id and the [base_version, actor] stamp envelope. Implements the doc gaps from the V1-007 spike report for Linear BE-7142. Co-Authored-By: Claude Fable 5 --- docs/op-vocabulary-v1.md | 123 +++++++++++++++++- .../comfy_cli/test_op_vocabulary_contract.py | 26 +++- 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/docs/op-vocabulary-v1.md b/docs/op-vocabulary-v1.md index dbbc7669c..5c604599c 100644 --- a/docs/op-vocabulary-v1.md +++ b/docs/op-vocabulary-v1.md @@ -269,7 +269,128 @@ be stable within a writer session. The CLI's `--actor` flag carries the origin; its default `cli` is a legacy value accepted for interactive local use — merge-consumer traffic uses the structured forms above. -## 8. Amendments +## 8. Replication and replay semantics + +Rules a second implementation (JS/TS merge consumer, multi-player) must follow +to converge with the Python applier. Grounded against `workflow_ops.py` and the +V1-007 CRDT replay spike. The unit of replication is the **op**: every replica +applies every op exactly once (any order) through an applier with these +semantics. Exchanging raw document state between concurrently-editing replicas +is not equivalent and does not inherit these guarantees. + +### 8.1 Stamp comparison is code-point lexicographic + +`_stamp_key` builds `[base_version, actor, op_id]` and relies on Python +sequence comparison: element-wise, first difference decides. The frozen rule, +so any implementation compares identically: + +* `base_version`: numeric comparison. +* `actor`, then `op_id`: **Unicode code point order** (Python `str` `<`), + compared character by character; a strict prefix sorts before its extension. +* No locale, no case folding, no normalization. + +`op_id` is lowercase ASCII hex (8.2), so code-point order equals byte order +for it. `actor` strings MUST be ASCII (the section 7 grammar is) — for ASCII, +JS UTF-16 `<` agrees with code-point order; above the Basic Multilingual Plane +it does not, which is why non-ASCII actors are not valid. + +### 8.2 `op_id` format is LWW-load-bearing + +`_new_op` emits `uuid.uuid4().hex`: exactly **32 lowercase hex characters +`[0-9a-f]`, no dashes**. This is a frozen format, not an implementation +detail: `op_id` is the final LWW tiebreaker (8.1), so its generation and its +lexicographic comparison decide conflict outcomes, not just deduplication. An +implementation that emits a different shape (uppercase, dashed UUID, shorter) +changes who wins ties. Receivers never regenerate or normalize an `op_id`. + +### 8.3 `last_node_id` / `last_link_id` are max-registers + +Both are advisory high-water marks, never allocators (ids come from +`mint_id`). Register semantics: **max-register** — a write is +`max(current, new)`, and merging two replicas' values is `max(a, b)`; a plain +overwrite is wrong under concurrency. + +`_apply_add_node` implements this for nodes: +`workflow["last_node_id"] = max(workflow.get("last_node_id") or 0, op["node_id"])`. +`_apply_connect` does **not** bump `last_link_id` today — no apply path writes +it. The intended, frozen rule is symmetric: connect SHOULD set +`last_link_id = max(last_link_id, link_id)`; the omission is a known gap, and +because the field is advisory, an implementation that already bumps it does +not diverge semantically from one that does not. `clear` preserves both +(section 1.5). + +### 8.4 `inputcount`-family autogrow: one op, two registers + +A connect whose `grow.inputcount` is set (the kijai `*Multi` family) performs +two writes under one `op_id`: + +1. **Structural growth**: a new input slot with a bare `{elem}_N` name + (`_next_inputcount_name` — never the dotted `base.elemN` autogrow shape), + keyed by `grow_id = link_id` for idempotent, non-clobbering replay. +2. **A stamped widget write** to the family's count widget + (`_apply_inputcount_bump`), passing through the same `_lww_gate` / + `_lww_commit` as an explicit `set_widget`, **stamped with the connect's own + `op_id` / `stamp` / `base_version`**. It therefore occupies the same LWW + register (`("widget", node_id, widget)`) as a concurrent explicit + `set_widget` on that widget, and the winner is decided by 8.1 regardless of + apply order. + +The written value is the mint-time-planned count — a static property of the +op, never re-derived from a post-collision slot number — so both apply orders +carry the same winning value and the graph converges. Known, accepted +limitation: the register is LWW, not a monotonic counter, so a slot that loses +a bare-key naming race can leave the count low until the next write to that +widget. When the applier has no catalog (`graph is None`), the slot still +grows and the count write is skipped. + +### 8.5 `op.node` on `add_node` is authoritative + +`_apply_add_node` inserts `op["node"]` verbatim (`copy.deepcopy`, one append). +Receivers MUST use the payload as-is and MUST NOT re-mint the node from the +schema catalog at apply time: widget defaults drift between catalog versions, +so a re-derived node diverges from the creator's. No catalog is needed to +apply an `add_node`. + +### 8.6 Bootstrap: one common initial snapshot + +All replicas of a workflow document MUST fork from one seeded initial +snapshot. Independently re-seeding the same base workflow on two replicas +creates content with distinct internal identities that **duplicates on first +merge** — silently, because each replica looks correct alone (verified in the +spike). Creating a document and seeding its base state is a single-writer +event; replication starts from that snapshot. + +### 8.7 Subgraph scope + +Current contract, pinned: + +* **Only `set_widget` is subgraph-scoped.** Three address forms are accepted + and normalize to ONE write target: flat promoted (`57.text`, routed through + the instance's `proxyWidgets`), nested interior (`57/3.steps`), and the + flattened UI→API alias (`57:3.cfg`). The minted op carries the **resolved** + `path` (e.g. `["57", "27"]`) plus `inner_widget`, so replay needs no + proxyWidgets logic, and the LWW target is + `("widget", ("57", "27"), "text")` — a flat-form and a nested-form + concurrent write to the same interior widget converge under 8.1. +* **`connect` refuses subgraph scope** with a structural explanation: an + interior endpoint is rejected with "a link cannot cross the subgraph + boundary"; a promoted-widget target is rejected with "promoted widget (a + value), not a link input". +* **`add_node` and `delete_node` cannot address interior nodes** at all; an + interior id fails as node-not-found against the top-level inventory. +* An interior write to a **shared** definition forks the definition at apply + time (`engine._isolate_shared_subgraph`): the definition is deep-copied + under `"sg-" + sha256(def_id + "\x00" + instance_id)[:32]` — deterministic, + never random — and the instance's `type` is repointed, so two replicas + replaying the same op produce byte-identical graphs and sibling instances + are never aliased. +* OPEN: the shared-definition forking semantics above are apply-time behavior + that rewrites `instance.type` without an explicit op saying so. A full + specification (fork visibility, interaction with concurrent interior writes + to sibling instances, definition garbage collection) is owed before this + document's v1.1, together with the FE stable-ID reconciliation (section 6). + +## 9. Amendments * Post-freeze changes require a **versioned amendment section** appended to this document (`## Amendment v1.x — `), stating what changed and why. diff --git a/tests/comfy_cli/test_op_vocabulary_contract.py b/tests/comfy_cli/test_op_vocabulary_contract.py index b845c3697..12a1692cd 100644 --- a/tests/comfy_cli/test_op_vocabulary_contract.py +++ b/tests/comfy_cli/test_op_vocabulary_contract.py @@ -13,6 +13,7 @@ from __future__ import annotations +import re import uuid from pathlib import Path from typing import Any @@ -205,7 +206,30 @@ def test_dollar_brace_rejected(): # --------------------------------------------------------------------------- -# 6. clear in a batch: registered code, hint names the standalone command +# 6. op_id format (doc section 8.2): LWW-load-bearing, so its shape is contract +# --------------------------------------------------------------------------- + + +def test_op_id_format_is_frozen(): + """op_id is the final LWW tiebreaker (doc sections 8.1/8.2): exactly 32 + lowercase hex chars, no dashes, on every minted op kind — its lexicographic + comparison decides conflict outcomes, not just deduplication.""" + op_id_re = re.compile(r"^[0-9a-f]{32}$") + g = _graph() + wf: dict[str, Any] = {"nodes": [], "links": []} + wf, add_op = workflow_ops.add_node(wf, g, "TinyLoader") + wf, add2_op = workflow_ops.add_node(wf, g, "TinySink") + wf, conn_op = workflow_ops.connect(wf, g, add_op["node_id"], "MODEL", add2_op["node_id"], "model") + wf, set_op = workflow_ops.set_widget(wf, g, add_op["node_id"], "ckpt_name", "a.safetensors") + wf, del_op = workflow_ops.delete_node(wf, g, add2_op["node_id"]) + wf, clear_op = workflow_ops.clear(wf) + for op in (add_op, add2_op, conn_op, set_op, del_op, clear_op): + assert op_id_re.match(op["op_id"]), f"{op['op']} minted op_id {op['op_id']!r}, not 32 lowercase hex chars" + assert op["stamp"] == [op["base_version"], op["actor"]] + + +# --------------------------------------------------------------------------- +# 7. clear in a batch: registered code, hint names the standalone command # --------------------------------------------------------------------------- From c22137c45e09653a98feb1bd9413f1fe38e71e9d Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 02:06:56 -0700 Subject: [PATCH 38/53] feat(cli): --select projection on the heavy four (one selector, fail-open, selected/total_bytes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Linear BE-7148 (V1-011): ONE selector implementation (comfy_cli/selector.py, gjson-style dot paths — path, array index, array wildcard `#`, comma multi-select) wired as `--select ` into the four heaviest read commands: - templates ls - nodes show - workflow slots - generate list (the argv the cloud agent's list_generate_models tool shells: `generate list --json`) With --select the envelope's `data` becomes the selected slice and the envelope gains additive sibling fields `selected_bytes` / `total_bytes`. Without --select output is byte-identical to before (pinned by test_no_select_output_unchanged). A malformed or zero-match expression fails OPEN: ok stays true, exit 0, `data` carries a bounded (~2KB) key inventory of the full payload plus a registered `select_no_match` advisory in data.warnings[] so the caller can self-correct. Pretty mode renders the slice as JSON (bare strings plain) instead of the table. Renderer gains an additive optional `extra` mapping on emit() that merges non-core top-level envelope fields; envelope/1 unchanged (additive-optional, additionalProperties already true). Co-Authored-By: Claude Fable 5 --- comfy_cli/command/generate/app.py | 8 + comfy_cli/command/nodes.py | 13 + comfy_cli/command/templates.py | 13 + comfy_cli/command/workflow.py | 13 + comfy_cli/error_codes.py | 10 + comfy_cli/output/renderer.py | 10 + comfy_cli/selector.py | 245 ++++++++++++ tests/comfy_cli/command/test_select_flag.py | 414 ++++++++++++++++++++ tests/comfy_cli/test_selector.py | 214 ++++++++++ 9 files changed, 940 insertions(+) create mode 100644 comfy_cli/selector.py create mode 100644 tests/comfy_cli/command/test_select_flag.py create mode 100644 tests/comfy_cli/test_selector.py diff --git a/comfy_cli/command/generate/app.py b/comfy_cli/command/generate/app.py index 322b881b0..b43fd024e 100644 --- a/comfy_cli/command/generate/app.py +++ b/comfy_cli/command/generate/app.py @@ -841,12 +841,20 @@ def _list_models(extra_args: list[str]) -> None: partner = _arg_value(clean, "--partner", "-p") category = _arg_value(clean, "--category", "--style", "-c") query = _arg_value(clean, "--query", "-q") + # `list`-only, deliberately NOT in `_separate_meta_flags`' meta_names: that + # set applies to every generate sub-action, and --select belongs to the + # four heavy read commands only (V1-011). + select_expr = _arg_value(clean, "--select") eps = spec.list_endpoints(partner=partner, category=category, query=query) payload = { "models": [_model_record(e) for e in eps], "count": len(eps), "filters": {"partner": partner, "category": category, "query": query}, } + if select_expr is not None: + from comfy_cli.selector import emit_selected + + return emit_selected(renderer, payload, select_expr, command="generate list") if renderer.is_pretty(): if not eps: rprint("[yellow]No models match those filters.[/yellow]") diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index 9ba1a7707..e4a6322bd 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -340,6 +340,14 @@ def show_cmd( show_default=False, help="ComfyUI port (defaults to COMFY_LOCAL_URL, the background server, or 8188)." ), ] = None, + select: Annotated[ + str | None, + typer.Option( + "--select", + show_default=False, + help="Project the payload: dot path (inputs.0.name), wildcard (inputs.#.name), comma multi-select.", + ), + ] = None, ): renderer = get_renderer() _stale: dict = {} @@ -401,6 +409,11 @@ def show_cmd( } ] + if select is not None: + from comfy_cli.selector import emit_selected + + return emit_selected(renderer, payload, select, command="nodes show") + if renderer.is_pretty(): from rich.table import Table diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 7e87ffcdc..60c11cdc9 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -395,6 +395,14 @@ def ls_cmd( bool, typer.Option("--refresh", help="Re-fetch index.json from GitHub before listing."), ] = False, + select: Annotated[ + str | None, + typer.Option( + "--select", + show_default=False, + help="Project the payload: dot path (rows.0.name), wildcard (rows.#.name), comma multi-select.", + ), + ] = None, ): renderer = get_renderer() @@ -458,6 +466,11 @@ def ls_cmd( ], } + if select is not None: + from comfy_cli.selector import emit_selected + + return emit_selected(renderer, payload, select, command="templates ls") + if renderer.is_pretty(): from rich.table import Table diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index 54f9a4076..3716bf6f2 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -194,6 +194,14 @@ def slots_cmd( str, typer.Option("--id", show_default=False, help="Template ID label; cosmetic only — defaults to the filename."), ] = "", + select: Annotated[ + str | None, + typer.Option( + "--select", + show_default=False, + help="Project the payload: dot path (slots.0.address), wildcard (slots.#.address), comma multi-select.", + ), + ] = None, ): renderer = get_renderer() p, workflow = _load_workflow_or_fail(renderer, file) @@ -222,6 +230,11 @@ def slots_cmd( {"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"} ] + if select is not None: + from comfy_cli.selector import emit_selected + + return emit_selected(renderer, payload, select, command="workflow slots") + if renderer.is_pretty(): from rich.table import Table diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index b1a116f8b..b495e854c 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -484,6 +484,16 @@ class ErrorCode: "Requested feature is not available in JSON output mode.", "drop `--json` (or pass `--no-json`) for this command", ), + ErrorCode( + "select_no_match", + "A `--select ` projection was malformed or matched nothing in the payload. The command " + "fails open — `ok` stays true and exit code stays 0 — and `data` carries a bounded key " + "inventory of the full payload (`data.inventory`: top-level keys, value types, sizes, one " + "nested level of keys) plus this advisory in `data.warnings[]`.", + "read `data.inventory` for the payload's real keys, then re-run with a corrected --select; " + "grammar: dot path `a.b.c`, array index `a.0.b`, wildcard `items.#.name`, comma multi-select " + "`name,inputs`", + ), # --- skills -------------------------------------------------------------- ErrorCode( "unknown_skill", diff --git a/comfy_cli/output/renderer.py b/comfy_cli/output/renderer.py index 25becea61..148302b0c 100644 --- a/comfy_cli/output/renderer.py +++ b/comfy_cli/output/renderer.py @@ -251,6 +251,7 @@ def emit( where: str | None = None, changed: bool | None = None, ok: bool = True, + extra: Mapping[str, Any] | None = None, ) -> None: """Emit the final envelope. In pretty mode this is a no-op (data was already shown by ``print``/``success``/etc). @@ -260,6 +261,12 @@ def emit( on an invalid workflow, which still emits its error/warning payload as data) pass ``ok=False`` so the envelope's ``ok`` agrees with the process exit code. + + ``extra`` merges additional top-level fields into the envelope + (additive-optional under envelope/1, e.g. ``--select``'s + ``selected_bytes``/``total_bytes``). Core envelope keys are never + overridden; when ``extra`` is absent the envelope is byte-identical to + an emit without it. """ if self.is_pretty(): return @@ -274,6 +281,9 @@ def emit( changed=changed, error=None, ) + if extra: + for key, value in extra.items(): + envelope.setdefault(key, value) self._write_json_line(envelope) self._envelope_emitted = True diff --git a/comfy_cli/selector.py b/comfy_cli/selector.py new file mode 100644 index 000000000..3d9891b0c --- /dev/null +++ b/comfy_cli/selector.py @@ -0,0 +1,245 @@ +"""The ONE ``--select`` projection grammar over envelope ``data`` payloads. + +This module is the single selector implementation for the CLI (V1-011 / C4): +the four heaviest read commands (``templates ls``, ``nodes show``, +``workflow slots``, ``generate list``) accept ``--select `` and project +their JSON payload through it. No second dialect will ever be added — keep the +grammar exactly this small. + +Grammar (gjson-style dot paths): + + - **dot path** — ``a.b.c`` walks object keys. + - **array index** — ``a.0.b`` indexes into an array (non-negative decimal). + On an object, a digit segment is an ordinary key lookup. + - **array wildcard** — ``items.#.name`` maps the rest of the path over every + array element and returns the array of matches; per-element misses are + dropped. ``items.#`` alone returns the whole array. A wildcard whose + remainder matches zero elements of a non-empty array is a miss; over an + empty array it matches and returns ``[]``. Wildcards compose + (``rows.#.tags.#``). + - **multi-select** — ``name,inputs`` splits on commas and returns an object + keyed by each sub-expression that matched. It is a miss only when every + part misses. + +There is no escaping: keys containing ``.``, ``,`` or ``#`` cannot be +addressed. Malformed expressions (empty, empty segment, empty part) are +reported as a miss, never an error — the CLI fails open (see +``selected_payload``): the command still succeeds and returns a bounded key +inventory of the full payload plus a ``select_no_match`` advisory so the +caller can correct the expression from what it just learned. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any + +# Hard bound on the serialized fail-open inventory (~1-2KB per V1-011). +_INVENTORY_MAX_BYTES = 2048 +# (top-level key cap, nested key cap) attempts, largest first; the first +# rendering that fits under the byte bound wins. +_INVENTORY_CAPS = ((40, 16), (16, 6), (6, 0)) + +WILDCARD = "#" + + +def select(data: Any, expr: str) -> tuple[Any, bool]: + """Evaluate ``expr`` against ``data``. Pure; never raises on bad input. + + Returns ``(result, matched)``. ``matched`` is False for both a malformed + expression and a well-formed one that matched nothing — the caller's + fail-open path treats them identically. + """ + if not isinstance(expr, str) or not expr.strip(): + return None, False + parts = [p.strip() for p in expr.split(",")] + if len(parts) > 1: + out: dict[str, Any] = {} + for part in parts: + result, matched = _select_one(data, part) + if matched: + out[part] = result + if out: + return out, True + return None, False + return _select_one(data, parts[0]) + + +def _select_one(data: Any, path: str) -> tuple[Any, bool]: + if not path: + return None, False + segments = path.split(".") + if any(seg == "" for seg in segments): + return None, False + return _walk(data, segments) + + +def _walk(current: Any, segments: list[str]) -> tuple[Any, bool]: + if not segments: + return current, True + seg, rest = segments[0], segments[1:] + if seg == WILDCARD: + if not isinstance(current, list): + return None, False + if not rest: + return list(current), True + out = [] + for element in current: + result, matched = _walk(element, rest) + if matched: + out.append(result) + if out or not current: + return out, True + return None, False + if isinstance(current, Mapping): + if seg in current: + return _walk(current[seg], rest) + return None, False + if isinstance(current, list): + if seg.isdigit(): + index = int(seg) + if index < len(current): + return _walk(current[index], rest) + return None, False + return None, False + + +# --------------------------------------------------------------------------- +# Fail-open inventory +# --------------------------------------------------------------------------- + + +def _type_name(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "bool" + if isinstance(value, int | float): + return "number" + if isinstance(value, str): + return "str" + if isinstance(value, Mapping): + return "object" + if isinstance(value, list): + return "array" + return type(value).__name__ + + +def _capped_keys(mapping: Mapping, cap: int) -> list[str]: + keys = [str(k) for k in mapping] + if cap and len(keys) > cap: + return keys[:cap] + [f"…+{len(keys) - cap} more"] + return keys if cap else [] + + +def _describe(value: Any, nested_cap: int) -> Any: + """One level of shape for a top-level value: type, size, and (for + objects / arrays-of-objects) one level of keys.""" + if isinstance(value, Mapping): + desc: dict[str, Any] = {"type": "object", "size": len(value)} + if nested_cap: + desc["keys"] = _capped_keys(value, nested_cap) + return desc + if isinstance(value, list): + desc = {"type": "array", "size": len(value)} + if nested_cap and value and isinstance(value[0], Mapping): + desc["item_keys"] = _capped_keys(value[0], nested_cap) + return desc + return {"type": _type_name(value)} + + +def _inventory(data: Any, top_cap: int, nested_cap: int) -> Any: + if isinstance(data, Mapping): + keys = list(data) + inv: dict[str, Any] = {str(k): _describe(data[k], nested_cap) for k in keys[:top_cap]} + if len(keys) > top_cap: + inv["…"] = f"+{len(keys) - top_cap} more keys" + return inv + return _describe(data, nested_cap) + + +def key_inventory(data: Any) -> Any: + """A bounded (~2KB serialized) shape summary of ``data``: top-level keys, + value types, sizes for objects/arrays, one nested level of keys.""" + inv: Any = None + for caps in _INVENTORY_CAPS: + inv = _inventory(data, *caps) + if len(_dumps(inv).encode("utf-8")) <= _INVENTORY_MAX_BYTES: + return inv + return inv + + +# --------------------------------------------------------------------------- +# Shared emit path for the four --select commands +# --------------------------------------------------------------------------- + + +def _dumps(obj: Any) -> str: + # Same serialization convention as the envelope writer (renderer + # _write_json_line): compact-ish, non-ASCII passthrough, best-effort + # coercion for stray non-JSON types. + from comfy_cli.output.renderer import _json_default + + return json.dumps(obj, default=_json_default, ensure_ascii=False) + + +def _num_bytes(obj: Any) -> int: + return len(_dumps(obj).encode("utf-8")) + + +def selected_payload(payload: Any, expr: str) -> tuple[Any, bool, dict[str, int]]: + """Apply ``expr`` to a command's full ``data`` payload. + + Returns ``(data, matched, meta)`` where ``data`` is what the envelope + should carry (the selected slice, or — fail-open — the key inventory plus + a ``select_no_match`` advisory in ``warnings``), and ``meta`` holds the + envelope's sibling byte counts: ``selected_bytes`` (serialized emitted + slice) and ``total_bytes`` (serialized full payload). + """ + result, matched = select(payload, expr) + if matched: + data: Any = result + else: + from comfy_cli import error_codes + + registered = error_codes.get("select_no_match") + data = { + "inventory": key_inventory(payload), + "warnings": [ + { + "code": "select_no_match", + "message": f"--select {expr!r} matched nothing in the payload", + "hint": registered.hint if registered else None, + } + ], + } + meta = {"selected_bytes": _num_bytes(data), "total_bytes": _num_bytes(payload)} + return data, matched, meta + + +def emit_selected(renderer: Any, payload: Any, expr: str, *, command: str) -> None: + """Render/emit a command payload through ``--select``. + + JSON modes: one envelope whose ``data`` is the selected slice and which + carries sibling ``selected_bytes`` / ``total_bytes`` fields. Pretty mode: + the selected slice pretty-printed as JSON (bare strings printed plain), or + — fail-open — a yellow advisory plus the key inventory. Exit code is the + caller's (always 0): a miss is never an error. + """ + data, matched, meta = selected_payload(payload, expr) + if renderer.is_pretty(): + if matched: + if isinstance(data, str): + # A selected bare string is almost always feeding a shell / + # human eyeball; don't wrap it in JSON quotes. markup=False so + # payload text can't be interpreted as Rich tags. + renderer.console().print(data, markup=False) + else: + renderer.console().print_json(_dumps(data)) + else: + warning = data["warnings"][0] + renderer.warn(warning["message"], hint=warning["hint"]) + renderer.console().print_json(_dumps(data["inventory"])) + return + renderer.emit(data, command=command, extra=meta) diff --git a/tests/comfy_cli/command/test_select_flag.py b/tests/comfy_cli/command/test_select_flag.py new file mode 100644 index 000000000..5bc188db2 --- /dev/null +++ b/tests/comfy_cli/command/test_select_flag.py @@ -0,0 +1,414 @@ +"""`--select ` wiring on the four heaviest read commands (V1-011). + +ONE selector implementation (``comfy_cli.selector``) projected onto: + + - ``comfy templates ls --select`` + - ``comfy nodes show --select`` + - ``comfy workflow slots --select`` + - ``comfy generate list --select`` (the invocation the cloud agent's + ``list_generate_models`` tool shells — services/agent/internal/loop/tools.go + execs ``generate list --json``) + +Pinned here per command: the envelope's ``data`` becomes the selected slice, +sibling ``selected_bytes``/``total_bytes`` fields appear, fail-open on a miss +(ok:true, exit 0, key inventory + ``select_no_match`` advisory), pretty-mode +rendering, and — crucially — that WITHOUT ``--select`` the envelope is +byte-identical to the pre-flag output (``test_no_select_output_unchanged``). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli.caller import Caller +from comfy_cli.command import nodes as nodes_cmd +from comfy_cli.command import templates as templates_cmd +from comfy_cli.command import workflow as workflow_cmd +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import ( + OutputMode, + Renderer, + reset_renderer_for_testing, + set_renderer, +) + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _force_pretty_renderer(): + r = Renderer.resolve( + is_stdout_tty=True, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + no_json_flag=True, + ) + r.mode = OutputMode.PRETTY + set_renderer(r) + return r + + +def _envelope(stdout: str) -> dict: + for line in reversed(stdout.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope in stdout:\n{stdout}") + + +def _assert_byte_fields(env: dict) -> None: + # selected_bytes counts the serialized emitted slice; total_bytes the full + # payload. On a fail-open miss the inventory slice can serialize LARGER + # than a small payload, so no ordering is asserted here — match-path tests + # assert selected < total themselves. + assert env["selected_bytes"] == len(json.dumps(env["data"], ensure_ascii=False).encode("utf-8")) + assert isinstance(env["total_bytes"], int) and env["total_bytes"] > 0 + + +def _assert_fail_open(env: dict) -> None: + assert env["ok"] is True + assert env["error"] is None + assert "inventory" in env["data"] + assert env["data"]["warnings"][0]["code"] == "select_no_match" + + +# --------------------------------------------------------------------------- +# templates ls +# --------------------------------------------------------------------------- + +GALLERY_FIXTURE = [ + { + "moduleName": "default", + "category": "GENERATION TYPE", + "title": "Image", + "type": "image", + "templates": [ + { + "name": "image_flux2", + "title": "Flux 2 Image", + "description": "Text-to-image using Flux 2 via the BFL API.", + "mediaType": "image", + "mediaSubtype": "webp", + "tags": ["API", "Text to Image"], + "models": ["Flux 2"], + "logos": [{"provider": ["Black Forest Labs"]}], + }, + { + "name": "image_z_image", + "title": "Z Image", + "description": "Local SDXL-style text-to-image.", + "mediaType": "image", + "mediaSubtype": "webp", + "tags": ["Local", "Text to Image"], + "models": ["Z Image"], + "logos": [{"provider": "Z"}], + }, + ], + }, +] + + +@pytest.fixture +def gallery_file(tmp_path: Path) -> str: + path = tmp_path / "index.json" + path.write_text(json.dumps(GALLERY_FIXTURE)) + return str(path) + + +class TestTemplatesLsSelect: + def test_select_projects_data(self, gallery_file): + _force_json_renderer() + result = runner.invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, "--select", "rows.#.name"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["ok"] is True + assert env["data"] == ["image_flux2", "image_z_image"] + _assert_byte_fields(env) + assert env["selected_bytes"] < env["total_bytes"] + + def test_select_miss_fails_open_with_inventory(self, gallery_file): + _force_json_renderer() + result = runner.invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, "--select", "not.a.key"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + _assert_fail_open(env) + assert set(env["data"]["inventory"]) >= {"rows", "matched", "filters"} + _assert_byte_fields(env) + + def test_select_works_in_pretty_mode(self, gallery_file): + _force_pretty_renderer() + result = runner.invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, "--select", "rows.#.name"]) + assert result.exit_code == 0, result.output + assert "image_flux2" in result.output + assert "image_z_image" in result.output + # The selection replaced the human table. + assert "Flux 2 Image" not in result.output + + def test_pretty_miss_prints_inventory_and_hint(self, gallery_file): + _force_pretty_renderer() + result = runner.invoke(templates_cmd.app, ["ls", "--gallery", gallery_file, "--select", "not.a.key"]) + assert result.exit_code == 0, result.output + assert "matched nothing" in result.output + assert "rows" in result.output # inventory names the real keys + + def test_no_select_output_unchanged(self, gallery_file): + """WITHOUT --select the envelope is byte-identical to the pre-flag + serialization: exact key set, exact order, no byte-count siblings.""" + _force_json_renderer() + result = runner.invoke(templates_cmd.app, ["ls", "--gallery", gallery_file]) + assert result.exit_code == 0, result.output + line = result.output.strip().splitlines()[-1] + rows = [ + { + "name": t["name"], + "title": t["title"], + "output_type": "image", + "category_title": "Image", + "tags": t["tags"], + "models": t["models"], + "providers": ["Black Forest Labs"] if t["name"] == "image_flux2" else ["Z"], + "description": t["description"][:120], + } + for t in GALLERY_FIXTURE[0]["templates"] + ] + expected = { + "schema": "envelope/1", + "type": "envelope", + "ok": True, + "command": "templates ls", + "version": "", + "where": None, + "data": { + "total_in_gallery": 2, + "matched": 2, + "shown": 2, + "filters": { + "type": None, + "category": None, + "tag": None, + "model": None, + "provider": None, + "name": None, + }, + "rows": rows, + }, + "error": None, + } + assert line == json.dumps(expected, ensure_ascii=False) + + +# --------------------------------------------------------------------------- +# nodes show +# --------------------------------------------------------------------------- + + +def _nodes_object_info() -> dict[str, Any]: + return { + "KSampler": { + "input": { + "required": { + "model": ["MODEL"], + "steps": ["INT", {"default": 20, "min": 1, "max": 10000}], + "sampler_name": [["euler", "heun", "dpmpp_2m"]], + }, + }, + "input_order": {"required": ["model", "steps", "sampler_name"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "KSampler", + "description": "Denoise the latent via the provided model.", + "output_node": False, + "python_module": "nodes", + }, + } + + +@pytest.fixture +def patched_nodes_graph(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: Graph.from_object_info(_nodes_object_info())) + + +class TestNodesShowSelect: + def test_select_projects_inputs(self, patched_nodes_graph): + _force_json_renderer() + result = runner.invoke(nodes_cmd.app, ["show", "KSampler", "--select", "inputs.#.name"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["ok"] is True + assert env["data"] == ["model", "steps", "sampler_name"] + _assert_byte_fields(env) + + def test_select_comma_multi(self, patched_nodes_graph): + _force_json_renderer() + result = runner.invoke(nodes_cmd.app, ["show", "KSampler", "--select", "name,category"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"] == {"name": "KSampler", "category": "sampling"} + + def test_select_miss_fails_open(self, patched_nodes_graph): + _force_json_renderer() + result = runner.invoke(nodes_cmd.app, ["show", "KSampler", "--select", "a..b"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + _assert_fail_open(env) + assert "inputs" in env["data"]["inventory"] + + def test_select_scalar_pretty_prints_plain(self, patched_nodes_graph): + _force_pretty_renderer() + result = runner.invoke(nodes_cmd.app, ["show", "KSampler", "--select", "category"]) + assert result.exit_code == 0, result.output + assert "sampling" in result.output + assert '"sampling"' not in result.output # bare string, no JSON quotes + + +# --------------------------------------------------------------------------- +# workflow slots +# --------------------------------------------------------------------------- + + +def _slots_object_info() -> dict[str, Any]: + return { + "CLIPTextEncode": { + "input": { + "required": { + "text": ["STRING", {"multiline": True}], + "clip": ["CLIP"], + }, + }, + "input_order": {"required": ["clip", "text"]}, + "output": ["CONDITIONING"], + "output_name": ["CONDITIONING"], + "category": "conditioning", + "display_name": "CLIP Text Encode", + "python_module": "nodes", + }, + } + + +def _slots_workflow() -> dict: + return { + "nodes": [ + {"id": 6, "type": "CLIPTextEncode", "widgets_values": ["a cat in space"]}, + ], + "links": [], + } + + +@pytest.fixture +def patched_workflow_graph(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(workflow_cmd, "_get_graph", lambda *a, **kw: Graph.from_object_info(_slots_object_info())) + + +class TestWorkflowSlotsSelect: + def _write(self, tmp_path: Path) -> Path: + p = tmp_path / "wf.json" + p.write_text(json.dumps(_slots_workflow()), encoding="utf-8") + return p + + def test_select_projects_addresses(self, patched_workflow_graph, tmp_path): + _force_json_renderer() + path = self._write(tmp_path) + result = runner.invoke(workflow_cmd.app, ["slots", str(path), "--select", "slots.#.address"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["ok"] is True + assert env["data"] == ["6.text"] + _assert_byte_fields(env) + + def test_select_count(self, patched_workflow_graph, tmp_path): + _force_json_renderer() + path = self._write(tmp_path) + result = runner.invoke(workflow_cmd.app, ["slots", str(path), "--select", "count"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"] == 1 + + def test_select_miss_fails_open(self, patched_workflow_graph, tmp_path): + _force_json_renderer() + path = self._write(tmp_path) + result = runner.invoke(workflow_cmd.app, ["slots", str(path), "--select", "widgets"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + _assert_fail_open(env) + assert "slots" in env["data"]["inventory"] + + +# --------------------------------------------------------------------------- +# generate list (what the cloud agent's list_generate_models tool shells) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _disable_tracking(monkeypatch): + monkeypatch.setattr("comfy_cli.tracking.prompt_tracking_consent", lambda *a, **kw: None) + monkeypatch.setattr("comfy_cli.tracking.track_event", lambda *a, **kw: None) + + +@pytest.fixture(autouse=True) +def _isolate_spec_caches(): + from comfy_cli.command.generate import spec as _spec + + _spec.load_raw_spec.cache_clear() + _spec._registry.cache_clear() + yield + _spec.load_raw_spec.cache_clear() + _spec._registry.cache_clear() + + +class TestGenerateListSelect: + def test_select_projects_aliases(self): + from comfy_cli.cmdline import app as cli_app + + _force_json_renderer() + result = runner.invoke(cli_app, ["generate", "list", "--json", "--select", "models.#.alias"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["ok"] is True + assert env["command"] == "generate list" + assert isinstance(env["data"], list) and env["data"] + assert "flux-pro" in env["data"] + _assert_byte_fields(env) + + def test_select_eq_form(self): + from comfy_cli.cmdline import app as cli_app + + _force_json_renderer() + result = runner.invoke(cli_app, ["generate", "list", "--json", "--select=count"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert isinstance(env["data"], int) and env["data"] >= 1 + + def test_select_miss_fails_open(self): + from comfy_cli.cmdline import app as cli_app + + _force_json_renderer() + result = runner.invoke(cli_app, ["generate", "list", "--json", "--select", "bogus.path"]) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + _assert_fail_open(env) + assert set(env["data"]["inventory"]) >= {"models", "count"} diff --git a/tests/comfy_cli/test_selector.py b/tests/comfy_cli/test_selector.py new file mode 100644 index 000000000..b15e45cd8 --- /dev/null +++ b/tests/comfy_cli/test_selector.py @@ -0,0 +1,214 @@ +"""Unit tests for ``comfy_cli.selector`` — the ONE `--select` projection grammar. + +Grammar under test (deliberately small, see the module docstring; no second +dialect will ever be added): + + - dot path: ``a.b.c`` + - array index: ``a.0.b`` + - array wildcard: ``items.#.name`` -> array of per-element matches + - multi-select: ``name,inputs`` -> object keyed by each sub-expression + +Fail-open semantics (malformed expression / zero matches) and the byte-count +envelope fields are covered here at the pure-function level; the per-command +wiring is covered in each command's test file. +""" + +from __future__ import annotations + +import json + +import pytest + +from comfy_cli import error_codes +from comfy_cli.selector import key_inventory, select, selected_payload + +PAYLOAD = { + "name": "KSampler", + "category": "sampling", + "inputs": [ + {"name": "seed", "type": "INT", "options": {"default": 0}}, + {"name": "steps", "type": "INT", "options": {"default": 20}}, + {"name": "sampler_name", "type": "COMBO"}, + ], + "output_types": ["LATENT"], + "nested": {"a": {"b": {"c": 42}}}, +} + + +class TestPath: + def test_top_level_key(self): + result, matched = select(PAYLOAD, "name") + assert matched is True + assert result == "KSampler" + + def test_nested_dot_path(self): + result, matched = select(PAYLOAD, "nested.a.b.c") + assert matched is True + assert result == 42 + + def test_path_returns_subtree(self): + result, matched = select(PAYLOAD, "nested.a") + assert matched is True + assert result == {"b": {"c": 42}} + + def test_missing_key_is_a_miss(self): + result, matched = select(PAYLOAD, "nope") + assert matched is False + assert result is None + + def test_missing_nested_key_is_a_miss(self): + _, matched = select(PAYLOAD, "nested.a.zzz") + assert matched is False + + def test_traversal_into_scalar_is_a_miss(self): + _, matched = select(PAYLOAD, "name.deeper") + assert matched is False + + +class TestIndex: + def test_array_index(self): + result, matched = select(PAYLOAD, "inputs.1.name") + assert matched is True + assert result == "steps" + + def test_index_out_of_range_is_a_miss(self): + _, matched = select(PAYLOAD, "inputs.99.name") + assert matched is False + + def test_index_returns_element(self): + result, matched = select(PAYLOAD, "output_types.0") + assert matched is True + assert result == "LATENT" + + def test_digit_segment_on_dict_is_key_lookup(self): + result, matched = select({"0": "zero"}, "0") + assert matched is True + assert result == "zero" + + +class TestWildcard: + def test_wildcard_projects_each_element(self): + result, matched = select(PAYLOAD, "inputs.#.name") + assert matched is True + assert result == ["seed", "steps", "sampler_name"] + + def test_bare_wildcard_returns_whole_array(self): + result, matched = select(PAYLOAD, "inputs.#") + assert matched is True + assert result == PAYLOAD["inputs"] + + def test_wildcard_drops_per_element_misses(self): + result, matched = select(PAYLOAD, "inputs.#.options.default") + assert matched is True + assert result == [0, 20] + + def test_wildcard_on_non_array_is_a_miss(self): + _, matched = select(PAYLOAD, "nested.#") + assert matched is False + + def test_wildcard_zero_element_matches_is_a_miss(self): + _, matched = select(PAYLOAD, "inputs.#.bogus") + assert matched is False + + def test_wildcard_over_empty_array_matches_empty(self): + result, matched = select({"items": []}, "items.#.name") + assert matched is True + assert result == [] + + def test_nested_wildcards_compose(self): + data = {"rows": [{"tags": ["a", "b"]}, {"tags": ["c"]}]} + result, matched = select(data, "rows.#.tags.#") + assert matched is True + assert result == [["a", "b"], ["c"]] + + +class TestComma: + def test_multi_select_returns_object_of_matches(self): + result, matched = select(PAYLOAD, "name,category") + assert matched is True + assert result == {"name": "KSampler", "category": "sampling"} + + def test_multi_select_mixed_expressions(self): + result, matched = select(PAYLOAD, "name,inputs.#.name") + assert matched is True + assert result == {"name": "KSampler", "inputs.#.name": ["seed", "steps", "sampler_name"]} + + def test_multi_select_drops_missing_parts(self): + result, matched = select(PAYLOAD, "name,nope") + assert matched is True + assert result == {"name": "KSampler"} + + def test_multi_select_all_missing_is_a_miss(self): + _, matched = select(PAYLOAD, "nope,alsonope") + assert matched is False + + +class TestMalformed: + @pytest.mark.parametrize("expr", ["", " ", ".", "a..b", ".a", "a.", ",", ",,"]) + def test_malformed_expressions_are_misses(self, expr): + result, matched = select(PAYLOAD, expr) + assert matched is False + assert result is None + + +# --------------------------------------------------------------------------- +# Fail-open inventory + byte accounting (the shared emit path) +# --------------------------------------------------------------------------- + + +def test_malformed_selector_fails_open_with_key_inventory(): + data, matched, meta = selected_payload(PAYLOAD, "a..b") + assert matched is False + inv = data["inventory"] + # Top-level keys with value types; dicts/lists carry sizes; nested one + # level of keys. + assert set(inv) >= {"name", "inputs", "nested"} + assert inv["inputs"]["type"] == "array" + assert inv["inputs"]["size"] == 3 + assert inv["nested"]["type"] == "object" + assert "a" in inv["nested"]["keys"] + # Advisory hint under a registered code; the command still succeeds. + warning = data["warnings"][0] + assert warning["code"] == "select_no_match" + registered = error_codes.get("select_no_match") + assert registered is not None, "select_no_match must be registered in error_codes.py" + assert warning["hint"] == registered.hint + # Inventory stays bounded. + assert len(json.dumps(inv)) <= 2048 + + +def test_zero_match_selector_fails_open_like_malformed(): + data, matched, _ = selected_payload(PAYLOAD, "definitely.not.here") + assert matched is False + assert "inventory" in data + assert data["warnings"][0]["code"] == "select_no_match" + + +def test_inventory_is_bounded_on_huge_payloads(): + huge = {f"key_{i}": {f"sub_{j}": "x" * 50 for j in range(50)} for i in range(200)} + inv = key_inventory(huge) + assert len(json.dumps(inv)) <= 2048 + + +def test_envelope_reports_selected_and_total_bytes(): + data, matched, meta = selected_payload(PAYLOAD, "inputs.#.name") + assert matched is True + assert data == ["seed", "steps", "sampler_name"] + assert meta["selected_bytes"] == len(json.dumps(data, ensure_ascii=False).encode("utf-8")) + assert meta["total_bytes"] == len(json.dumps(PAYLOAD, ensure_ascii=False).encode("utf-8")) + assert meta["selected_bytes"] < meta["total_bytes"] + + +def test_selected_bytes_counts_the_inventory_slice_on_miss(): + data, matched, meta = selected_payload(PAYLOAD, "nope") + assert matched is False + assert meta["selected_bytes"] == len(json.dumps(data, ensure_ascii=False).encode("utf-8")) + assert meta["total_bytes"] == len(json.dumps(PAYLOAD, ensure_ascii=False).encode("utf-8")) + + +def test_select_is_pure_and_does_not_mutate(): + snapshot = json.dumps(PAYLOAD, sort_keys=True) + select(PAYLOAD, "inputs.#.name") + select(PAYLOAD, "nope") + selected_payload(PAYLOAD, "a..b") + assert json.dumps(PAYLOAD, sort_keys=True) == snapshot From 66632748ec762373fa10204eca4fe96878d68a50 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 02:33:16 -0700 Subject: [PATCH 39/53] feat(cli): --ack summary|full on workflow apply (default full; pinned summary shape) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `comfy workflow apply` echoes the FULL ops array back in the success envelope — for a big batch the sender gets its own batch mirrored back and learns nothing new. Add `--ack summary|full`: * default stays `full` and is byte-identical to today's payload (proved by a determinism-pinned byte-compare test); `--ack full` is an explicit synonym for the default. * `--ack summary` returns a compact receipt with a PINNED shape (field names feed the cloud field-contract manifest): {count, ops_by_kind, nodes_added, nodes_deleted, aliases, base_version, version, changed} — no ops echo. * on batch failure, summary mode keeps the exact full-mode outcome (code=workflow_edit_invalid, exit 1, atomic no-write) and ADDS error.details = {failed: {index, op, code}, applied_count}; apply_specs now stamps the wrapped batch error with the failing spec's position. * pretty mode renders the summary as per-kind counts + alias lines. `foreach` takes no --ack: its payload never echoed ops. Implements Linear BE-7149 (ticket V1-012). Co-Authored-By: Claude Fable 5 --- comfy_cli/command/workflow_edit.py | 71 +++++- comfy_cli/workflow_ops.py | 11 +- tests/comfy_cli/command/test_ack_flag.py | 267 +++++++++++++++++++++++ 3 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 tests/comfy_cli/command/test_ack_flag.py diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index b7ccb8535..8b3e05a37 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -423,6 +423,15 @@ def apply_cmd( list[str] | None, typer.Option("--param", show_default=False, help="Recipe param as key=value; repeatable."), ] = None, + ack: Annotated[ + str, + typer.Option( + "--ack", + help="Envelope acknowledgment detail: 'full' (default) echoes every applied op in " + "`data.ops`; 'summary' returns a compact receipt (counts, minted/deleted node ids, " + "aliases) with no ops echo.", + ), + ] = "full", actor: ActorOpt = "cli", base_version: BaseVersionOpt = 0, stdout: StdoutOpt = False, @@ -436,6 +445,9 @@ def apply_cmd( minted node by alias instead of a captured id.""" renderer = get_renderer() renderer.command = "workflow apply" + if ack not in ("full", "summary"): + renderer.error(code="workflow_edit_invalid", message=f"--ack must be 'summary' or 'full', got {ack!r}") + raise typer.Exit(code=1) p, workflow = _load_workflow_or_fail(renderer, file) graph = _graph_or_exit(input_path, host, port, renderer, where) @@ -479,8 +491,22 @@ def apply_cmd( workflow, graph, specs, actor=actor, base_version=base_version ) except (ValueError, KeyError) as e: - # Atomic batch: nothing is written if any spec fails. - renderer.error(code="workflow_edit_invalid", message=f"batch failed: {e}") + # Atomic batch: nothing is written if any spec fails. Same error code + # and exit in both ack modes — `--ack summary` only ADDS a structured + # receipt (`failed` position + `applied_count`) to `error.details`; + # the default envelope stays exactly what it is today. + details = None + if ack == "summary": + details = { + "failed": { + "index": getattr(e, "spec_index", None), + "op": getattr(e, "spec_op", None), + "code": "workflow_edit_invalid", + }, + # Specs applied before the abort — all discarded (atomic batch). + "applied_count": getattr(e, "applied_count", 0), + } + renderer.error(code="workflow_edit_invalid", message=f"batch failed: {e}", details=details) raise typer.Exit(code=1) from e workflow_ops.strip_internal(workflow) @@ -493,17 +519,40 @@ def apply_cmd( else: _atomic_write_text(p, serialized) wrote = str(p) - payload = { - "workflow": str(p), - "count": len(ops), - "ops": ops, - "aliases": aliases, - "base_version": base_version, - "version": base_version + len(ops), - "wrote": wrote, - } + if ack == "summary": + # PINNED shape — these field names feed the cloud field-contract + # manifest; add/rename only with a contract bump on that side. + ops_by_kind: dict[str, int] = {} + for op in ops: + ops_by_kind[op["op"]] = ops_by_kind.get(op["op"], 0) + 1 + payload = { + "count": len(ops), + "ops_by_kind": ops_by_kind, + "nodes_added": [op["node_id"] for op in ops if op["op"] == "add_node"], + "nodes_deleted": [op["node_id"] for op in ops if op["op"] == "delete_node"], + "aliases": aliases, + "base_version": base_version, + "version": base_version + len(ops), + "changed": True, + } + else: + payload = { + "workflow": str(p), + "count": len(ops), + "ops": ops, + "aliases": aliases, + "base_version": base_version, + "version": base_version + len(ops), + "wrote": wrote, + } if renderer.is_pretty(): rprint(f"[bold green]✓[/bold green] applied {len(ops)} edit(s) → [dim]{p}[/dim]") + if ack == "summary": + kinds = ", ".join(f"{k} ×{n}" for k, n in payload["ops_by_kind"].items()) + if kinds: + rprint(f" [dim]{kinds}[/dim]") + for alias, node_id in aliases.items(): + rprint(f" [dim]alias {alias} → {node_id}[/dim]") renderer.emit(payload, command="workflow apply", changed=True) diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 8a3c0578e..ab971fc4d 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -1092,7 +1092,16 @@ def apply_specs( raise ValueError(f"spec #{i} ({kind}) is missing required field {e}") from e ops.append(op) except (ValueError, KeyError) as e: - raise _rehint_discarded_batch(e, pre_batch_hint) from e + err = _rehint_discarded_batch(e, pre_batch_hint) + # Structured failure position for callers that report a summary + # receipt (`apply --ack summary`): which spec aborted the batch, its + # op kind, and how many specs had applied before the abort (all of + # them then discarded — the batch is atomic). `i`/`spec` are the loop + # variables at raise time; guard for a non-dict spec. + err.spec_index = i # type: ignore[attr-defined] + err.spec_op = spec.get("op") if isinstance(spec, dict) else None # type: ignore[attr-defined] + err.applied_count = len(ops) # type: ignore[attr-defined] + raise err from e return workflow, ops, aliases diff --git a/tests/comfy_cli/command/test_ack_flag.py b/tests/comfy_cli/command/test_ack_flag.py new file mode 100644 index 000000000..a6acf4582 --- /dev/null +++ b/tests/comfy_cli/command/test_ack_flag.py @@ -0,0 +1,267 @@ +"""`comfy workflow apply --ack summary|full` — envelope acknowledgment detail. + +The default `full` ack echoes every applied op back in `data.ops` — for a big +batch that is the whole batch again, and an agent that just SENT the ops +learns nothing new from the echo. `--ack summary` returns a compact receipt +instead. The summary payload shape is PINNED (its field names feed the cloud +field-contract manifest): + + {count, ops_by_kind: {: n}, nodes_added: [ids], nodes_deleted: [ids], + aliases: {alias: id}, base_version, version, changed} + +with NO `ops` echo. `--ack` only changes the SHAPE of the payload — never the +outcome: file writes, version bookkeeping, error codes, and exit semantics are +identical in both modes, and the default (no flag) payload stays byte-identical +to today's. + +`foreach` deliberately has no `--ack`: its payload never echoed ops (it reports +`{recipe, count, out_dir, written}`), so there is nothing to summarize. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from test_workflow_edit import ( # type: ignore[import-not-found] + _base_workflow, + _force_json_renderer, + _graph, + _run, + _write, + reset_singleton, # noqa: F401 (autouse fixture) +) +from typer.testing import CliRunner + +from comfy_cli import workflow_ops +from comfy_cli.command import workflow as workflow_cmd +from comfy_cli.command import workflow_edit + +# The pinned summary payload contract — exactly these keys, nothing else. +SUMMARY_KEYS = { + "count", + "ops_by_kind", + "nodes_added", + "nodes_deleted", + "aliases", + "base_version", + "version", + "changed", +} + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + + +def _empty(tmp_path): + return _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}) + + +def _ops_file(tmp_path, specs: list[dict], name: str = "ops.json"): + p = tmp_path / name + p.write_text(json.dumps(specs), encoding="utf-8") + return p + + +def _three_op_specs() -> list[dict]: + """A 3-op batch: two aliased add_nodes + a connect through the aliases.""" + return [ + {"op": "add_node", "class_type": "CheckpointLoaderSimple", "as": "ckpt"}, + {"op": "add_node", "class_type": "CLIPTextEncode", "as": "pos"}, + {"op": "connect", "from": "ckpt.CLIP", "to": "pos.clip"}, + ] + + +class TestAckSummary: + def test_apply_ops_ack_summary_returns_counts_and_aliases(self, patched_graph, tmp_path, capsys): + path = _empty(tmp_path) + ops_path = _ops_file(tmp_path, _three_op_specs()) + env = _run(["apply", str(path), "--ops", str(ops_path), "--ack", "summary"], capsys) + assert env["ok"] is True, env + data = env["data"] + # Exactly the pinned shape — a stray extra field would silently enter + # the cloud field-contract manifest, a missing one would break it. + assert set(data) == SUMMARY_KEYS, data + assert "ops" not in data + assert data["count"] == 3 + assert data["ops_by_kind"] == {"add_node": 2, "connect": 1} + assert set(data["aliases"]) == {"ckpt", "pos"} + assert sorted(data["nodes_added"]) == sorted(data["aliases"].values()) + assert data["nodes_deleted"] == [] + assert data["base_version"] == 0 + assert data["version"] == 3 # base_version + len(ops), same math as full mode + assert data["changed"] is True + assert env["changed"] is True # envelope-level flag identical to full mode + # The file really was written — summary changes the receipt, not the write. + assert len(json.loads(path.read_text())["nodes"]) == 2 + + def test_ack_summary_reports_deleted_nodes(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + ops_path = _ops_file(tmp_path, [{"op": "delete_node", "node": 7}]) + env = _run(["apply", str(path), "--ops", str(ops_path), "--ack", "summary"], capsys) + assert env["ok"] is True, env + data = env["data"] + assert set(data) == SUMMARY_KEYS + assert data["ops_by_kind"] == {"delete_node": 1} + assert data["nodes_added"] == [] + assert data["nodes_deleted"] == [7] + + def test_ack_rejects_unknown_value(self, patched_graph, tmp_path, capsys): + path = _empty(tmp_path) + ops_path = _ops_file(tmp_path, _three_op_specs()) + env = _run(["apply", str(path), "--ops", str(ops_path), "--ack", "bogus"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + + +class TestAckDefaultUnchanged: + def test_apply_ack_default_byte_identical(self, patched_graph, tmp_path, capsys, monkeypatch): + """`--ack full` must be byte-identical to today's default payload. + + Soundness of the assertion: node ids (`mint_id`, `random.getrandbits`) + and op ids (`uuid.uuid4`) are the ONLY nondeterminism in the apply + path (layout is documented "no randomness, no clock"; the CRDT stamp + is [base_version, actor]). We pin both to counters, run the same batch + on the same file path twice — resetting the file content and the + counters in between — so the two invocations are observationally + identical except for the explicit `--ack full` flag. Byte-equal + envelope lines then prove `--ack full` == default. The shape + assertions below additionally pin that default to TODAY'S payload + (full `ops` echo + aliases), so a change to either mode fails here. + """ + state = {"node": 0, "op": 0} + + def fake_mint() -> int: + state["node"] += 1 + return (1 << 40) + state["node"] + + class _FakeUUID: + def __init__(self, n: int): + self.hex = f"{n:032x}" + + class _FakeUuidModule: + @staticmethod + def uuid4() -> Any: + state["op"] += 1 + return _FakeUUID(state["op"]) + + monkeypatch.setattr(workflow_ops, "mint_id", fake_mint) + monkeypatch.setattr(workflow_ops, "uuid", _FakeUuidModule) + + initial = json.dumps({"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}, indent=2) + path = tmp_path / "wf.json" + ops_path = _ops_file(tmp_path, _three_op_specs()) + + def run_once(extra: list[str]) -> str: + path.write_text(initial, encoding="utf-8") + state["node"] = state["op"] = 0 + _force_json_renderer() + runner = CliRunner() + result = runner.invoke( + workflow_cmd.app, ["apply", str(path), "--ops", str(ops_path), *extra], standalone_mode=False + ) + out = capsys.readouterr().out or result.stdout or "" + lines = [ln for ln in out.strip().splitlines() if ln.strip()] + for line in reversed(lines): + try: + json.loads(line) + return line + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception})") + + default_line = run_once([]) + full_line = run_once(["--ack", "full"]) + assert default_line == full_line + + data = json.loads(default_line)["data"] + # Today's payload, pinned: full ops echo with per-op identity. + assert set(data) == {"workflow", "count", "ops", "aliases", "base_version", "version", "wrote"} + assert data["count"] == 3 and len(data["ops"]) == 3 + assert all("op_id" in op and "op" in op for op in data["ops"]) + assert data["version"] == 3 + + +class TestAckSummaryPartialFailure: + def test_ack_summary_partial_failure_reports_index_and_code(self, patched_graph, tmp_path, capsys): + """Op 2 of 3 (0-based index 1) fails → same code/atomicity as full + mode, plus a structured receipt: failed.{index,op,code} + applied_count. + + `applied_count` counts specs applied before the abort; the batch is + atomic, so all of them were then discarded (nothing was written). + """ + path = _empty(tmp_path) + before = path.read_text() + specs = [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "NoSuchNode"}, # fails + {"op": "set_widget", "node": "ks", "widget": "steps", "value": 30}, + ] + ops_path = _ops_file(tmp_path, specs) + env = _run(["apply", str(path), "--ops", str(ops_path), "--ack", "summary"], capsys) + assert env["ok"] is False + # Outcome identical to full mode: same code, nothing written. + assert env["error"]["code"] == "workflow_edit_invalid" + assert path.read_text() == before + details = env["error"]["details"] + assert details["failed"] == {"index": 1, "op": "add_node", "code": "workflow_edit_invalid"} + assert details["applied_count"] == 1 + + def test_full_mode_failure_envelope_unchanged(self, patched_graph, tmp_path, capsys): + """Default mode keeps today's failure envelope: no details block.""" + path = _empty(tmp_path) + specs = [ + {"op": "add_node", "class_type": "KSampler", "as": "ks"}, + {"op": "add_node", "class_type": "NoSuchNode"}, + ] + ops_path = _ops_file(tmp_path, specs) + env = _run(["apply", str(path), "--ops", str(ops_path)], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + assert env["error"]["details"] is None + + +class TestSingleEditVerbsUnaffected: + def test_single_edit_verbs_unaffected(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + env = _run(["add-node", str(path), "VAEDecode"], capsys) + assert env["ok"] is True, env + # Today's single-edit payload shape, untouched by the ack work. + assert set(env["data"]) == {"workflow", "op", "base_version", "version", "wrote"} + assert "ops_by_kind" not in env["data"] + + def test_single_edit_verbs_do_not_take_ack(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + _force_json_renderer() + runner = CliRunner() + result = runner.invoke( + workflow_cmd.app, ["add-node", str(path), "VAEDecode", "--ack", "summary"], standalone_mode=False + ) + assert result.exit_code != 0 or result.exception is not None + + +class TestAckSummaryPretty: + def test_pretty_summary_renders_counts_and_aliases(self, patched_graph, tmp_path, capsys): + from comfy_cli.caller import Caller + from comfy_cli.output.renderer import OutputMode, Renderer, set_renderer + + r = Renderer.resolve( + is_stdout_tty=True, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + ) + r.mode = OutputMode.PRETTY + set_renderer(r) + path = _empty(tmp_path) + ops_path = _ops_file(tmp_path, _three_op_specs()) + runner = CliRunner() + result = runner.invoke( + workflow_cmd.app, ["apply", str(path), "--ops", str(ops_path), "--ack", "summary"], standalone_mode=False + ) + out = capsys.readouterr().out + (result.stdout or "") + assert "applied 3 edit(s)" in out + assert "add_node" in out and "2" in out # per-kind count line + assert "ckpt" in out and "pos" in out # aliases rendered From 0672d99d299b3c0085a1aa5d7c3c09499931076e Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 03:10:36 -0700 Subject: [PATCH 40/53] feat(cli): nodes search --expand-top N Kill the measured search -> show xN agent loop (x76, ~92% of show args are a verbatim copy of the search hit): --expand-top N re-resolves the top-N returned hits through the exact catalog path nodes show uses (graph.node -> morphism_to_dict) and attaches each show payload under data.expanded[] with a class_type join key. - --expand-top 0 / omitted: payload byte-identical to today. - per-hit catalog miss degrades to a per-hit expand_miss error entry inside expanded[]; the search itself never fails. - expand_miss registered in the error-code registry (advisory, in-data). Linear: BE-7150 Co-Authored-By: Claude Fable 5 --- comfy_cli/command/nodes.py | 37 ++++ comfy_cli/error_codes.py | 7 + .../command/test_nodes_search_expand.py | 204 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 tests/comfy_cli/command/test_nodes_search_expand.py diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index 9ba1a7707..dc1f4b2e0 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -459,6 +459,18 @@ def search_cmd( ), ], limit: Annotated[int, typer.Option(help="Cap output to N rows.")] = 20, + expand_top: Annotated[ + int, + typer.Option( + "--expand-top", + metavar="N", + help=( + "Also attach the full `nodes show` schema (inputs, defaults, enum choices, outputs) " + "for the top-N hits under `expanded`, so no follow-up `show` calls are needed. " + "0 (the default) leaves the output unchanged." + ), + ), + ] = 0, input_path: Annotated[ str | None, typer.Option("--input", show_default=False, help="Path to a local object_info JSON (offline mode)."), @@ -569,6 +581,31 @@ def search_cmd( ], } + # --expand-top N: kill the search → show × N loop (measured on prod agent + # traces: the follow-up `show` args are overwhelmingly a verbatim copy of the + # hit name). The top-N returned rows are re-resolved through the SAME catalog + # path `nodes show` uses (graph.node → morphism_to_dict), so `expanded[i]` is + # exactly the show payload plus a `class_type` key to join back on the row. + # A per-hit miss degrades to a per-hit error entry — it never fails the + # search, since the rows themselves are still perfectly good results. + if expand_top > 0: + expanded: list[dict[str, Any]] = [] + for m in matched[: max(0, expand_top)]: + resolved = graph.node(m.id) + if resolved is None: + expanded.append( + { + "class_type": m.id, + "error": { + "code": "expand_miss", + "message": f"search matched {m.id!r} but the catalog could not resolve its schema", + }, + } + ) + continue + expanded.append({"class_type": m.id, **graph.morphism_to_dict(resolved)}) + payload["expanded"] = expanded + if _stale: payload["stale"] = True payload["warnings"] = [ diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index b1a116f8b..ea8e4ae24 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -620,6 +620,13 @@ class ErrorCode: "Requested node class isn't in the loaded environment.", "see `details.close_matches` or run `comfy nodes search`", ), + ErrorCode( + "expand_miss", + "`comfy nodes search --expand-top N` matched a node class but could not resolve its full schema " + "from the catalog. Surfaced as a per-hit error entry inside `data.expanded[]` (not as an error " + "envelope) — the search itself still succeeds and the other hits still expand.", + "the hit itself is still valid; inspect it directly with `comfy nodes show `", + ), # --- file transfer (upload / download) ----------------------------------- ErrorCode( "upload_failed", diff --git a/tests/comfy_cli/command/test_nodes_search_expand.py b/tests/comfy_cli/command/test_nodes_search_expand.py new file mode 100644 index 000000000..dc40b3669 --- /dev/null +++ b/tests/comfy_cli/command/test_nodes_search_expand.py @@ -0,0 +1,204 @@ +"""Tests for ``comfy nodes search --expand-top N`` (V1-017 / BE-7150). + +The measured agent loop is search → show × N (the show args are ~92% a copy of +the search hit). ``--expand-top N`` folds the show payload for the top-N hits +into the search envelope so the follow-up ``show`` calls disappear. + +Contract under test: + * ``--expand-top N`` attaches ``data.expanded`` — one entry per expanded hit, + carrying ``class_type`` plus the exact ``nodes show`` field vocabulary + (``inputs`` with options/defaults/choices, ``outputs``, …). + * ``--expand-top 0`` / flag omitted → payload byte-identical to today. + * a per-hit catalog miss degrades to a per-hit error entry (code + ``expand_miss``) and never fails the search. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli.caller import Caller +from comfy_cli.command import nodes as nodes_cmd +from comfy_cli.output.renderer import OutputMode, Renderer, reset_renderer_for_testing, set_renderer + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _object_info() -> dict[str, Any]: + return { + "KSampler": { + "input": { + "required": { + "model": ["MODEL"], + "steps": ["INT", {"default": 20, "min": 1, "max": 10000}], + "sampler_name": [["euler", "heun", "dpmpp_2m"]], + }, + }, + "input_order": {"required": ["model", "steps", "sampler_name"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "KSampler", + "description": "Denoise the latent via the provided model.", + "output_node": False, + "python_module": "nodes", + }, + "KSamplerAdvanced": { + "input": {"required": {"model": ["MODEL"]}}, + "input_order": {"required": ["model"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "KSampler (Advanced)", + "description": "Denoise the latent with extra knobs.", + "output_node": False, + "python_module": "nodes", + }, + "VAEDecode": { + "input": {"required": {"samples": ["LATENT"], "vae": ["VAE"]}}, + "input_order": {"required": ["samples", "vae"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "latent", + "display_name": "VAE Decode", + "description": "Turn a latent back into pixels.", + "output_node": False, + "python_module": "nodes", + }, + } + + +def _graph(): + from comfy_cli.cql.engine import Graph + + return Graph.from_object_info(_object_info()) + + +@pytest.fixture +def patched_loader(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph()) + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(nodes_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + for line in reversed(captured.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +class TestExpandTop: + def test_expand_returns_top_hit_schema(self, patched_loader, capsys): + """--expand-top 1 folds the full `nodes show` payload for the top hit + into the search envelope: inputs with defaults + enum choices, outputs.""" + env = _run(["search", "KSampler", "--expand-top", "1"], capsys) + assert env["ok"] is True + data = env["data"] + # Ranking unchanged: exact name is the top hit. + assert data["rows"][0]["name"] == "KSampler" + expanded = data["expanded"] + assert len(expanded) == 1 + entry = expanded[0] + assert entry["class_type"] == "KSampler" + # `nodes show` field vocabulary, verbatim (morphism_to_dict). + inputs = {i["name"]: i for i in entry["inputs"]} + assert inputs["steps"]["options"]["default"] == 20 + assert inputs["steps"]["options"]["min"] == 1 + assert inputs["sampler_name"]["choices"] == ["euler", "heun", "dpmpp_2m"] + assert inputs["model"]["is_link"] is True + assert entry["outputs"] == [{"name": "LATENT", "type": "LATENT"}] + assert entry["output_types"] == ["LATENT"] + + def test_expand_covers_top_n_in_rank_order(self, patched_loader, capsys): + env = _run(["search", "sampler", "--expand-top", "2"], capsys) + assert env["ok"] is True + expanded = env["data"]["expanded"] + assert [e["class_type"] for e in expanded] == [r["name"] for r in env["data"]["rows"][:2]] + assert len(expanded) == 2 + + def test_omitted_and_zero_are_byte_identical_to_baseline(self, patched_loader, capsys): + base = _run(["search", "KSampler"], capsys) + zero = _run(["search", "KSampler", "--expand-top", "0"], capsys) + assert zero["data"] == base["data"] + assert "expanded" not in base["data"] + assert "expanded" not in zero["data"] + + def test_zero_matches_baseline_yields_empty_expanded(self, patched_loader, capsys): + """A query with no hits (and no close-name fallback) still succeeds and + carries an empty `expanded` — never an error.""" + env = _run(["search", "xyzzy_zzq_nothing", "--expand-top", "3"], capsys) + assert env["ok"] is True + assert env["data"]["total"] == 0 + assert env["data"]["rows"] == [] + assert env["data"]["expanded"] == [] + + def test_per_hit_catalog_miss_degrades_to_error_entry(self, monkeypatch, capsys): + """A hit that can't be re-resolved through the show path yields a per-hit + `expand_miss` error entry; the search itself still succeeds and the other + hits still expand.""" + graph = _graph() + real_node = graph.node + + def flaky_node(name: str): + if name == "KSampler": + return None # simulate a catalog miss for this one hit + return real_node(name) + + monkeypatch.setattr(graph, "node", flaky_node) + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: graph) + + env = _run(["search", "sampler", "--expand-top", "2"], capsys) + assert env["ok"] is True + expanded = env["data"]["expanded"] + assert len(expanded) == 2 + by_class = {e["class_type"]: e for e in expanded} + miss = by_class["KSampler"] + assert miss["error"]["code"] == "expand_miss" + assert "inputs" not in miss + hit = by_class["KSamplerAdvanced"] + assert "error" not in hit + assert {i["name"] for i in hit["inputs"]} == {"model"} + + def test_expand_applies_to_close_match_fallback_rows(self, patched_loader, capsys): + """Typo queries fall back to close-name matches; those rows are real + catalog nodes and expand the same way.""" + env = _run(["search", "KSampeler", "--expand-top", "1"], capsys) + assert env["ok"] is True + assert env["data"]["close_match"] is True + expanded = env["data"]["expanded"] + assert len(expanded) == 1 + assert expanded[0]["class_type"] == env["data"]["rows"][0]["name"] + assert "inputs" in expanded[0] + + def test_expand_miss_is_registered(self): + from comfy_cli import error_codes + + assert error_codes.is_registered("expand_miss") From a03c38c0543c8451cdac960663e49c1649d136b6 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 03:12:52 -0700 Subject: [PATCH 41/53] feat(cli): templates get --where MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kill the measured templates ls -> fetch agent loop (x42, the fetched name is a 100% verbatim copy of an ls row): `templates get --where key=value` (repeatable) resolves through the exact ls filter predicates (_matches, reused verbatim — no new query language; keys: type/category/tag/model/provider/name) and, when exactly ONE template matches, fetches its workflow and returns it in the same envelope fetch uses. - zero matches -> template_not_found with leave-one-out near-miss suggestions (also computed via _matches). - >1 matches -> template_ambiguous with <=10 candidates (name + one-line meta). - malformed/unknown/missing --where -> template_filter_invalid. - ls/show/fetch untouched; their suites pin byte-identical behavior. - template_ambiguous + template_filter_invalid registered. Linear: BE-7151 Co-Authored-By: Claude Fable 5 --- comfy_cli/command/templates.py | 203 ++++++++++++ comfy_cli/discovery.py | 1 + comfy_cli/error_codes.py | 14 + tests/comfy_cli/command/test_templates_get.py | 295 ++++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 tests/comfy_cli/command/test_templates_get.py diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 7e87ffcdc..ffd0544ec 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -732,6 +732,209 @@ def fetch_cmd( renderer.emit(payload, command="templates fetch") +# --------------------------------------------------------------------------- +# templates get — resolve by ls filters + fetch in ONE call +# --------------------------------------------------------------------------- + +# The ONLY filter vocabulary `get --where` accepts: exactly the `templates ls` +# flags, mapped onto the same `_matches` kwargs — no new query language. Keep +# this in lockstep with `_matches`' signature. +_GET_FILTER_KEYS = { + "type": "type_", + "category": "category", + "tag": "tag", + "model": "model", + "provider": "provider", + "name": "name_sub", +} + + +def _parse_get_filters(renderer, where: list[str]) -> dict[str, str | None]: + """Parse repeatable ``--where key=value`` pairs into `_matches` kwargs. + + Strict: a pair without ``=``, an empty/unknown key, or no filters at all is + an error — a filterless `get` is just `ls`, and would always be ambiguous. + """ + filters: dict[str, str | None] = {v: None for v in _GET_FILTER_KEYS.values()} + valid = ", ".join(sorted(_GET_FILTER_KEYS)) + if not where: + renderer.error( + code="template_filter_invalid", + message="`templates get` needs at least one --where filter to resolve a single template", + hint=f"pass --where key=value (repeatable); keys: {valid} — same semantics as `templates ls`", + ) + raise typer.Exit(code=1) + for raw in where: + key, sep, value = raw.partition("=") + key = key.strip() + if not sep or not key: + renderer.error( + code="template_filter_invalid", + message=f"--where must be key=value, got {raw!r}", + hint=f"keys: {valid} — e.g. --where type=video --where tag=API", + ) + raise typer.Exit(code=1) + if key not in _GET_FILTER_KEYS: + renderer.error( + code="template_filter_invalid", + message=f"unknown --where key {key!r}", + hint=f"keys: {valid} — same semantics as the `templates ls` flags", + details={"key": key, "valid_keys": sorted(_GET_FILTER_KEYS)}, + ) + raise typer.Exit(code=1) + filters[_GET_FILTER_KEYS[key]] = value + return filters + + +def _get_near_misses(rows: list[dict[str, Any]], filters: dict[str, str | None]) -> list[dict[str, Any]]: + """Leave-one-out suggestions for a zero-match filter set: for each active + filter, what would have matched with that one filter dropped. Reuses + `_matches` verbatim, so the suggestions obey exactly the ls semantics.""" + active = {k: v for k, v in filters.items() if v is not None} + key_by_kwarg = {v: k for k, v in _GET_FILTER_KEYS.items()} + near: list[dict[str, Any]] = [] + if len(active) < 2 and "name_sub" not in active: + # With a single non-name filter there is nothing useful to relax against. + return near + for dropped in active: + relaxed = dict(filters) + relaxed[dropped] = None + names = [r["name"] for r in rows if _matches(r, **relaxed)][:5] + if names: + near.append({"without": key_by_kwarg[dropped], "names": names}) + return near + + +@app.command( + "get", + help=( + "Resolve ONE template by `templates ls` filters and fetch its workflow in the same call. " + "Filters are repeatable `--where key=value` pairs (keys: type, category, tag, model, " + "provider, name — identical semantics to the `templates ls` flags). Errors when zero " + "or more than one template matches." + ), +) +@tracking.track_command("templates") +def get_cmd( + where: Annotated[ + list[str] | None, + typer.Option( + "--where", + "-w", + metavar="KEY=VALUE", + show_default=False, + help="Filter (repeatable): type=…, category=…, tag=…, model=…, provider=…, name=…", + ), + ] = None, + gallery_path: Annotated[ + str | None, + typer.Option("--gallery", show_default=False, help="Path to a local index.json (skips the cache + fetch)."), + ] = None, + refresh: Annotated[ + bool, + typer.Option("--refresh", help="Re-fetch the gallery index from GitHub before resolving."), + ] = False, +): + """Fuse `templates ls` (find) + `templates fetch` (get) into one hop. + + Measured agent loops run ls → fetch with the name copied verbatim; `get` + resolves the same filter predicates and, when exactly ONE template matches, + returns its workflow in the same envelope `fetch` uses. + """ + renderer = get_renderer() + filters = _parse_get_filters(renderer, list(where or [])) + + try: + cats = _load_gallery(gallery_path, refresh=refresh) + except _GALLERY_LOAD_ERRORS as e: + renderer.error(code="gallery_load_failed", message=str(e)) + raise typer.Exit(code=1) from e + + rows = _flatten_templates(cats) + matched = [r for r in rows if _matches(r, **filters)] + shown_filters = {k: filters[v] for k, v in _GET_FILTER_KEYS.items()} + + if not matched: + near = _get_near_misses(rows, filters) + near_hint = "; ".join(f"drop {n['without']}= to match {', '.join(n['names'])}" for n in near[:2]) + renderer.error( + code="template_not_found", + message=f"no template matches {shown_filters}", + hint=near_hint or "relax a filter, or browse with `comfy templates ls`", + details={"filters": shown_filters, "near_misses": near}, + ) + raise typer.Exit(code=1) + + if len(matched) > 1: + candidates = [ + { + "name": r["name"], + "title": r["title"], + "output_type": r["output_type"], + "tags": r["tags"], + "models": r["models"], + } + for r in matched[:10] + ] + renderer.error( + code="template_ambiguous", + message=f"{len(matched)} templates match {shown_filters}; `get` needs exactly one", + hint="add another --where filter (e.g. name=) to narrow to a single template", + details={"filters": shown_filters, "matched": len(matched), "candidates": candidates}, + ) + raise typer.Exit(code=1) + + match = matched[0] + name = match["name"] + + # From here down this is `fetch` with no --out: same fetch helper, same + # error envelopes, workflow riding in the envelope (or pretty stdout). + try: + body = _fetch_template_workflow(name) + except (urllib.error.HTTPError, urllib.error.URLError, OSError, RuntimeError, ResponseTooLarge) as e: + status = getattr(e, "code", None) + renderer.error( + code="template_fetch_failed", + message=f"failed to fetch workflow for {name!r}: {e}", + hint=( + "the gallery index references a template whose workflow JSON " + "is missing upstream — report at " + "https://github.com/Comfy-Org/workflow_templates/issues" + if status == 404 + else "check network connectivity" + ), + details={"status": status} if status else None, + ) + raise typer.Exit(code=1) from e + + try: + wf = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + renderer.error( + code="template_workflow_invalid_json", + message=f"upstream returned non-JSON for {name!r}: {e}", + hint="report at https://github.com/Comfy-Org/workflow_templates/issues", + ) + raise typer.Exit(code=1) from e + + payload = { + "name": name, + "title": match["title"], + "output_type": match["output_type"], + "filters": shown_filters, + "bytes": len(body), + "node_count": _workflow_node_count(wf), + "workflow": wf, + } + if renderer.is_pretty(): + # Pipeable, exactly like `fetch` with no --out. + import sys + + sys.stdout.write(body.decode("utf-8")) + sys.stdout.write("\n") + renderer.emit(payload, command="templates get") + + # --------------------------------------------------------------------------- # templates check — per-template runnable/missing/api-required/unknown verdict # --------------------------------------------------------------------------- diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index e9586935d..50ea588b1 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -108,6 +108,7 @@ "comfy templates ls": "templates", "comfy templates show": "templates", "comfy templates fetch": "templates", + "comfy templates get": "templates", "comfy templates refresh": "templates", "comfy templates check": "templates", # lifecycle diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index ea8e4ae24..4e02a9f69 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -353,6 +353,20 @@ class ErrorCode: "Upstream `templates/.json` was not parseable JSON.", "report at https://github.com/Comfy-Org/workflow_templates/issues", ), + ErrorCode( + "template_ambiguous", + "`comfy templates get --where …` matched more than one template; `get` resolves exactly one. " + "`details.candidates` carries up to 10 matches (name + title/type/tags/models) and " + "`details.matched` the full count.", + "add another --where filter (e.g. name=) to narrow to a single template", + ), + ErrorCode( + "template_filter_invalid", + "A `comfy templates get --where` filter was malformed (not key=value), used an unknown key, " + "or no filter was given at all. Valid keys: type, category, tag, model, provider, name — " + "identical semantics to the `templates ls` flags.", + "pass repeatable `--where key=value` pairs, e.g. `--where type=video --where tag=API`", + ), ErrorCode( "cancel_failed", "`comfy jobs cancel` could not reach the local server to cancel the prompt.", diff --git a/tests/comfy_cli/command/test_templates_get.py b/tests/comfy_cli/command/test_templates_get.py new file mode 100644 index 000000000..da67fb1a0 --- /dev/null +++ b/tests/comfy_cli/command/test_templates_get.py @@ -0,0 +1,295 @@ +"""Tests for ``comfy templates get --where k=v`` (V1-018 / BE-7151). + +The measured agent loop is ``templates ls`` (to find the name) → ``templates +fetch`` (copying that name back, 100% verbatim). ``get`` fuses the two: the +same ls filter predicates (``_matches``, reused exactly — no new query +language) resolve the template, and when exactly one matches its workflow is +fetched and returned in one envelope. + +Contract under test: + * exactly one match → fetch + return the workflow in one envelope. + * zero matches → ``template_not_found`` + nearest-candidate suggestions. + * >1 matches → ``template_ambiguous`` with ≤10 candidates (name + meta). + * malformed / unknown ``--where`` key → ``template_filter_invalid``. + * existing ls/show/fetch surfaces untouched (their own tests pin that). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from comfy_cli.caller import Caller +from comfy_cli.command import templates as templates_cmd +from comfy_cli.output.renderer import ( + OutputMode, + Renderer, + reset_renderer_for_testing, + set_renderer, +) + +# Same schema as the real gallery index (mirrors test_templates.py's fixture, +# with names distinct enough to pin single/ambiguous/none per filter). +GET_FIXTURE = [ + { + "moduleName": "default", + "category": "GENERATION TYPE", + "title": "Image", + "type": "image", + "templates": [ + { + "name": "image_flux_dev", + "title": "Flux Dev Image", + "description": "Text-to-image with Flux Dev.", + "mediaType": "image", + "mediaSubtype": "webp", + "tags": ["Local", "Text to Image"], + "models": ["Flux Dev"], + "logos": [{"provider": ["Black Forest Labs"]}], + "openSource": True, + "usage": 90, + }, + { + "name": "image_flux_pro_api", + "title": "Flux Pro (API)", + "description": "Text-to-image via the BFL API.", + "mediaType": "image", + "mediaSubtype": "webp", + "tags": ["API", "Text to Image"], + "models": ["Flux Pro"], + "logos": [{"provider": ["Black Forest Labs"]}], + "openSource": False, + "usage": 100, + }, + ], + }, + { + "moduleName": "default", + "category": "GENERATION TYPE", + "title": "Video", + "type": "video", + "templates": [ + { + "name": "video_kling_i2v", + "title": "Kling Image to Video", + "description": "Image-to-video via Kling.", + "mediaType": "video", + "mediaSubtype": "mp4", + "tags": ["API", "Image to Video"], + "models": ["Kling 2.5"], + "logos": [{"provider": ["Kling"]}], + "openSource": False, + "usage": 75, + } + ], + }, +] + + +@pytest.fixture +def gallery_file(tmp_path: Path) -> str: + path = tmp_path / "get_index.json" + path.write_text(json.dumps(GET_FIXTURE)) + return str(path) + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _envelope(stdout: str) -> dict: + for line in reversed(stdout.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope in stdout:\n{stdout}") + + +def _stub_workflow_fetch(monkeypatch, body_or_exc): + def _impl(name, timeout=15.0): + if isinstance(body_or_exc, Exception): + raise body_or_exc + return body_or_exc + + monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", _impl) + + +WORKFLOW_BODY = json.dumps({"9": {"class_type": "KSampler", "inputs": {}}}).encode() + + +class TestGetSingleMatch: + def test_unique_filter_fetches_and_returns_workflow(self, gallery_file, monkeypatch): + _force_json_renderer() + _stub_workflow_fetch(monkeypatch, WORKFLOW_BODY) + runner = CliRunner() + result = runner.invoke( + templates_cmd.app, + ["get", "--gallery", gallery_file, "--where", "type=video"], + ) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["ok"] is True + data = env["data"] + assert data["name"] == "video_kling_i2v" + assert data["title"] == "Kling Image to Video" + assert data["output_type"] == "video" + assert data["node_count"] == 1 + # The whole point: the workflow rides in the same envelope. + assert data["workflow"] == json.loads(WORKFLOW_BODY) + + def test_filters_and_together_narrow_to_one(self, gallery_file, monkeypatch): + _force_json_renderer() + _stub_workflow_fetch(monkeypatch, WORKFLOW_BODY) + runner = CliRunner() + result = runner.invoke( + templates_cmd.app, + ["get", "--gallery", gallery_file, "--where", "type=image", "--where", "tag=API"], + ) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["name"] == "image_flux_pro_api" + + def test_name_filter_is_the_ls_substring_predicate(self, gallery_file, monkeypatch): + _force_json_renderer() + _stub_workflow_fetch(monkeypatch, WORKFLOW_BODY) + runner = CliRunner() + result = runner.invoke( + templates_cmd.app, + ["get", "--gallery", gallery_file, "--where", "name=kling"], + ) + assert result.exit_code == 0, result.output + env = _envelope(result.output) + assert env["data"]["name"] == "video_kling_i2v" + + +class TestGetAmbiguous: + def test_multi_match_errors_with_candidates(self, gallery_file, monkeypatch): + _force_json_renderer() + + def _should_not_fire(name, timeout=15.0): + raise AssertionError("workflow fetch must not fire on an ambiguous filter") + + monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", _should_not_fire) + runner = CliRunner() + result = runner.invoke( + templates_cmd.app, + ["get", "--gallery", gallery_file, "--where", "type=image"], + ) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["ok"] is False + assert env["error"]["code"] == "template_ambiguous" + candidates = env["error"]["details"]["candidates"] + assert len(candidates) == 2 + names = {c["name"] for c in candidates} + assert names == {"image_flux_dev", "image_flux_pro_api"} + # One-line meta per candidate so the agent can pick without another ls. + for c in candidates: + assert c["title"] + assert c["output_type"] == "image" + + def test_candidates_are_capped_at_ten(self, tmp_path, monkeypatch): + _force_json_renderer() + many = [ + { + "moduleName": "default", + "category": "GENERATION TYPE", + "title": "Image", + "type": "image", + "templates": [ + { + "name": f"image_bulk_{i:02d}", + "title": f"Bulk {i}", + "description": "", + "mediaType": "image", + "mediaSubtype": "webp", + "tags": ["Local"], + "models": [], + "logos": [], + } + for i in range(14) + ], + } + ] + path = tmp_path / "get_index_many.json" + path.write_text(json.dumps(many)) + runner = CliRunner() + result = runner.invoke(templates_cmd.app, ["get", "--gallery", str(path), "--where", "type=image"]) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["error"]["code"] == "template_ambiguous" + assert env["error"]["details"]["matched"] == 14 + assert len(env["error"]["details"]["candidates"]) == 10 + + +class TestGetNoMatch: + def test_zero_matches_errors_with_suggestions(self, gallery_file, monkeypatch): + _force_json_renderer() + + def _should_not_fire(name, timeout=15.0): + raise AssertionError("workflow fetch must not fire on zero matches") + + monkeypatch.setattr(templates_cmd, "_fetch_template_workflow", _should_not_fire) + runner = CliRunner() + # type=video AND tag=Local matches nothing; dropping either filter finds rows. + result = runner.invoke( + templates_cmd.app, + ["get", "--gallery", gallery_file, "--where", "type=video", "--where", "tag=Local"], + ) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["ok"] is False + assert env["error"]["code"] == "template_not_found" + near = env["error"]["details"]["near_misses"] + # Leave-one-out: dropping `tag` finds the video template, dropping `type` + # finds the Local image template. + by_dropped = {n["without"]: n["names"] for n in near} + assert "video_kling_i2v" in by_dropped["tag"] + assert "image_flux_dev" in by_dropped["type"] + + +class TestGetFilterValidation: + @pytest.mark.parametrize("bad", ["type", "flavor=spicy", "=video"]) + def test_malformed_or_unknown_where_is_rejected(self, gallery_file, bad): + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(templates_cmd.app, ["get", "--gallery", gallery_file, "--where", bad]) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["ok"] is False + assert env["error"]["code"] == "template_filter_invalid" + + def test_no_filters_at_all_is_rejected(self, gallery_file): + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(templates_cmd.app, ["get", "--gallery", gallery_file]) + assert result.exit_code != 0 + env = _envelope(result.output) + assert env["error"]["code"] == "template_filter_invalid" + + +class TestGetErrorCodesRegistered: + def test_new_codes_are_registered(self): + from comfy_cli import error_codes + + assert error_codes.is_registered("template_ambiguous") + assert error_codes.is_registered("template_filter_invalid") From 03398ad314f525365f16be0932ce124956b88818 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 03:15:20 -0700 Subject: [PATCH 42/53] feat(cli): nodes path --emit-ops (round-trips through apply_specs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodes path reports a plan; agents then hand-build the apply batch from it. --emit-ops closes that gap: each returned path gains paths[].ops — a ready-to-apply spec batch in the frozen edit vocabulary (add_node with as: aliases + connect referencing them as BARE alias names), feedable straight into `comfy workflow apply --ops`. - THE CONTRACT IS THE ROUND-TRIP: tests feed the emitted specs to workflow_ops.apply_specs on an empty workflow and assert nodes+links exist. - deterministic dedup-suffixed aliases (same slugging as capture_recipe). - each step wires from the NEAREST prior producer of its consumed type, resolved against the schema; the seed FROM type stays unbound by design. - without the flag the payload is byte-identical. - bare alias refs are the freeze vocabulary's valid form; the $-canonical sugar lands with the vocabulary-freeze PR (#704) and is a follow-up here. Linear: BE-7152 Co-Authored-By: Claude Fable 5 --- comfy_cli/command/nodes.py | 92 ++++++++++ .../command/test_nodes_path_emit_ops.py | 169 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 tests/comfy_cli/command/test_nodes_path_emit_ops.py diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index dc1f4b2e0..2557e0c22 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -782,6 +782,83 @@ def downstream_cmd( renderer.emit(payload, command="nodes downstream") +def _alias_slug(class_type: str) -> str: + """A spec-batch alias for a class name — the same slugging + ``workflow_ops.capture_recipe`` uses, so the two surfaces mint identical + alias vocabulary.""" + import re + + return re.sub(r"[^a-z0-9]+", "_", str(class_type or "node").lower()).strip("_") or "node" + + +def _emit_path_ops(graph, steps: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Project a routed path (``steps`` from ``find_paths``/``exact_paths``) + into a ready-to-apply spec batch in the frozen edit vocabulary. + + THE CONTRACT IS THE ROUND-TRIP: the returned specs must pass + ``workflow_ops.apply_specs`` unchanged. Shape: + + * one ``add_node`` per step, with a deterministic dedup-suffixed ``as:`` + alias (``tinysampler``, ``tinysampler_2``, …); + * one ``connect`` per step whose consumed type is produced by an earlier + step — from the NEAREST prior producer's matching output to this step's + first link input accepting that type. Alias references are emitted as + bare names (the freeze vocabulary's valid form; the ``$``-canonical + sugar lands with the vocabulary-freeze PR). + + The path's seed FROM type is produced by nothing in the path, so the first + step's input deliberately stays unbound — never a phantom connect. + """ + from comfy_cli.workflow_ops import _types_compatible + + # Deterministic dedup-suffixed aliases, one add_node per step. + counts: dict[str, int] = {} + aliases: list[str] = [] + specs: list[dict[str, Any]] = [] + for step in steps: + class_type = str(step.get("node") or "") + slug = _alias_slug(class_type) + counts[slug] = counts.get(slug, 0) + 1 + alias = slug if counts[slug] == 1 else f"{slug}_{counts[slug]}" + aliases.append(alias) + specs.append({"op": "add_node", "class_type": class_type, "as": alias}) + + def _producer_output(class_type: str, want: str) -> str | None: + m = graph.node(class_type) + for p in m.outputs if m is not None else (): + types = {t.strip() for t in str(p.type).split(",") if t.strip()} + if want in types or "*" in types: + return p.name + return None + + def _consumer_input(class_type: str, want: str) -> str | None: + m = graph.node(class_type) + ports = [p for p in (m.inputs if m is not None else ()) if p.is_link] + # Required inputs first — that's the slot the plan is routing through. + for p in sorted(ports, key=lambda p: not p.required): + if _types_compatible(want, p.type): + return p.name + return None + + for j, step in enumerate(steps): + want = str(step.get("input_type") or "") + if not want: + continue # step consumes nothing from the path (e.g. a loader) + # Nearest prior step whose node actually produces `want` — in exact + # mode the recorded per-step output_type is one of possibly several + # outputs, so resolve against the schema, not just the step record. + for k in range(j - 1, -1, -1): + src_class = str(steps[k].get("node") or "") + out_name = _producer_output(src_class, want) + if out_name is None: + continue + in_name = _consumer_input(str(step.get("node") or ""), want) + if in_name is not None: + specs.append({"op": "connect", "from": f"{aliases[k]}.{out_name}", "to": f"{aliases[j]}.{in_name}"}) + break + return specs + + @app.command("path", help="Routed paths from one type to another (e.g. MODEL -> IMAGE).") @tracking.track_command("nodes") def path_cmd( @@ -796,6 +873,17 @@ def path_cmd( help="Exact: every step's required link inputs must be satisfiable from the path so far. Loose: any routed sequence.", ), ] = True, + emit_ops: Annotated[ + bool, + typer.Option( + "--emit-ops", + help=( + "Attach each path's plan as a ready-to-apply spec batch under `paths[].ops` " + "(frozen add_node/connect vocabulary with `as:` aliases) — feed it straight " + "to `comfy workflow apply --ops`." + ), + ), + ] = False, input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None, host: Annotated[str | None, typer.Option(show_default=False)] = None, port: Annotated[int | None, typer.Option(show_default=False)] = None, @@ -836,6 +924,10 @@ def path_cmd( } for s in (p.get("steps") or []) ], + # --emit-ops: the plan as a ready-to-apply spec batch (round-trips + # through workflow_ops.apply_specs unchanged). Absent without the + # flag so the default output stays byte-identical. + **({"ops": _emit_path_ops(graph, list(p.get("steps") or []))} if emit_ops else {}), } for p in paths ], diff --git a/tests/comfy_cli/command/test_nodes_path_emit_ops.py b/tests/comfy_cli/command/test_nodes_path_emit_ops.py new file mode 100644 index 000000000..a8ffec557 --- /dev/null +++ b/tests/comfy_cli/command/test_nodes_path_emit_ops.py @@ -0,0 +1,169 @@ +"""Tests for ``comfy nodes path --emit-ops`` (V1-019 / BE-7152). + +``nodes path`` reports a plan (a routed node sequence); agents then hand-build +the apply batch from it. ``--emit-ops`` closes that gap: each returned path +gains ``ops`` — a ready-to-apply spec batch in the frozen edit vocabulary +(``add_node`` with ``as:`` aliases + ``connect`` referencing those aliases as +bare names). + +THE CONTRACT IS THE ROUND-TRIP: the emitted specs must pass +``workflow_ops.apply_specs`` unchanged — the tests feed them to ``apply_specs`` +on an empty workflow and assert the nodes and links exist. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli import workflow_ops +from comfy_cli.caller import Caller +from comfy_cli.command import nodes as nodes_cmd +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import OutputMode, Renderer, reset_renderer_for_testing, set_renderer + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _object_info() -> dict[str, Any]: + """A minimal MODEL → LATENT → IMAGE chain so `path MODEL IMAGE --exact` + finds exactly one two-step path.""" + return { + "TinySampler": { + "input": {"required": {"model": ["MODEL"]}}, + "input_order": {"required": ["model"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "Tiny Sampler", + "python_module": "nodes", + }, + "TinyDecode": { + "input": {"required": {"samples": ["LATENT"]}}, + "input_order": {"required": ["samples"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "latent", + "display_name": "Tiny Decode", + "python_module": "nodes", + }, + } + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +@pytest.fixture +def patched_loader(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph()) + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(nodes_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + for line in reversed(captured.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +class TestEmitOps: + def test_round_trip_through_apply_specs(self, patched_loader, capsys): + """The emitted specs ARE the contract: applied unchanged onto an empty + workflow they materialize the path's nodes and intra-path links.""" + env = _run(["path", "MODEL", "IMAGE", "--emit-ops"], capsys) + assert env["ok"] is True + paths = env["data"]["paths"] + assert paths, env["data"] + specs = paths[0]["ops"] + + # Shape: frozen vocabulary only, bare alias references. + kinds = [s["op"] for s in specs] + assert kinds == ["add_node", "add_node", "connect"] + adds = [s for s in specs if s["op"] == "add_node"] + assert [a["class_type"] for a in adds] == ["TinySampler", "TinyDecode"] + assert all(a.get("as") for a in adds) + connect = next(s for s in specs if s["op"] == "connect") + assert connect["from"] == f"{adds[0]['as']}.LATENT" + assert connect["to"] == f"{adds[1]['as']}.samples" + + # Round-trip: apply_specs on an empty workflow, unchanged. + wf: dict = {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0} + wf, ops, aliases = workflow_ops.apply_specs(wf, _graph(), specs) + assert {n["type"] for n in wf["nodes"]} == {"TinySampler", "TinyDecode"} + assert len(wf["links"]) == 1 + decode = next(n for n in wf["nodes"] if n["type"] == "TinyDecode") + samples = next(i for i in decode["inputs"] if i["name"] == "samples") + assert samples["link"] == wf["links"][0][0] + # The link's source really is the sampler minted by the batch. + sampler_id = aliases[adds[0]["as"]] + assert wf["links"][0][1] == sampler_id + + def test_loose_mode_also_emits_ops(self, patched_loader, capsys): + env = _run(["path", "MODEL", "IMAGE", "--loose", "--emit-ops"], capsys) + assert env["ok"] is True + for p in env["data"]["paths"]: + assert isinstance(p["ops"], list) and p["ops"] + + def test_without_flag_byte_identical(self, patched_loader, capsys): + base = _run(["path", "MODEL", "IMAGE"], capsys) + again = _run(["path", "MODEL", "IMAGE"], capsys) + assert again["data"] == base["data"] + for p in base["data"]["paths"]: + assert "ops" not in p + assert "emit_ops" not in base["data"] + + def test_alias_uniqueness_is_deterministic(self): + """Two steps with the same class dedup deterministically (slug, slug_2) + and later connects reference the deduped alias, not the clobbered one.""" + steps = [ + {"node": "TinySampler", "input_type": "MODEL", "output_type": "LATENT"}, + {"node": "TinyDecode", "input_type": "LATENT", "output_type": "IMAGE"}, + {"node": "TinyDecode", "input_type": "LATENT", "output_type": "IMAGE"}, + ] + specs = nodes_cmd._emit_path_ops(_graph(), steps) + aliases = [s["as"] for s in specs if s["op"] == "add_node"] + assert aliases == ["tinysampler", "tinydecode", "tinydecode_2"] + assert len(set(aliases)) == len(aliases) + connects = [s for s in specs if s["op"] == "connect"] + # Each decode is fed by the nearest prior LATENT producer (the sampler). + assert {c["to"] for c in connects} == {"tinydecode.samples", "tinydecode_2.samples"} + # Deterministic: same input → same output. + assert specs == nodes_cmd._emit_path_ops(_graph(), steps) + + def test_seed_type_has_no_phantom_producer(self, patched_loader, capsys): + """The path's FROM type is an unbound input by design (nothing in the + path produces it) — no connect spec may reference a nonexistent source.""" + env = _run(["path", "MODEL", "IMAGE", "--emit-ops"], capsys) + specs = env["data"]["paths"][0]["ops"] + aliases = {s["as"] for s in specs if s["op"] == "add_node"} + for c in (s for s in specs if s["op"] == "connect"): + assert c["from"].partition(".")[0] in aliases + assert c["to"].partition(".")[0] in aliases From 666ec954ae0ad8c729927be747d5fd5b4956130c Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 03:32:54 -0700 Subject: [PATCH 43/53] feat(cli): workflow delete-nodes batch verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kill the delete-node x22 loop: `workflow delete-nodes ` deletes N nodes with one file load, one catalog load, and ONE atomic write (_atomic_write_text), emitting one frozen delete_node op per id via workflow_ops.delete_node — no new op kind. - any invalid id fails the WHOLE batch atomically (file byte-identical), with the node-inventory hint re-rendered from the PRE-batch graph (_rehint_discarded_batch), so the error never advertises discarded state. - dangling links cleaned exactly as the single-delete verb (same apply path). - carries --actor/--base-version/--stdout/--input/--host/--port/--where like every edit verb; registered in discovery COMMAND_SCHEMAS. Linear: BE-7153 Co-Authored-By: Claude Fable 5 --- comfy_cli/command/workflow.py | 4 + comfy_cli/command/workflow_edit.py | 70 ++++++ comfy_cli/discovery.py | 1 + .../command/test_workflow_delete_nodes.py | 215 ++++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 tests/comfy_cli/command/test_workflow_delete_nodes.py diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index 54f9a4076..a666ca39e 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -1482,6 +1482,10 @@ def delete_cmd( app.command("connect", help="Wire an output slot to an input slot; emits a connect op.")(_wedit.connect_cmd) app.command("set-widget", help="Set a widget by name (`.`); emits a set_widget op.")(_wedit.set_widget_cmd) app.command("delete-node", help="Delete a node and its links; emits a delete_node op.")(_wedit.delete_cmd) +app.command( + "delete-nodes", + help="Delete N nodes in one atomic write; emits one delete_node op per id (all-or-nothing).", +)(_wedit.delete_nodes_cmd) app.command("clear", help="Remove every node, link, and group; emits one clear op.")(_wedit.clear_cmd) app.command("ls-nodes", help="List nodes (id/type/title) in a workflow file.")(_wedit.ls_nodes_cmd) app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")( diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index b7ccb8535..6b49dc784 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -262,6 +262,76 @@ def delete_cmd( _finish(renderer, p, workflow, op, base_version, stdout, "workflow delete") +# --------------------------------------------------------------------------- +# delete-nodes — batch delete: N ids, ONE atomic write +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def delete_nodes_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + nodes: Annotated[list[str], typer.Argument(help="Node ids to delete (one or more).")], + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + input_path: InputOpt = None, + host: HostOpt = None, + port: PortOpt = None, + where: WhereOpt = None, +): + """Delete N nodes in one pass: one file load, one catalog load, ONE atomic + write, and one frozen ``delete_node`` op per id (via + ``workflow_ops.delete_node`` — no new op kind). Any invalid id fails the + whole batch atomically: the file is left byte-identical. + """ + renderer = get_renderer() + renderer.command = "workflow delete-nodes" + p, workflow = _load_workflow_or_fail(renderer, file) + graph = _graph_or_exit(input_path, host, port, renderer, where) + # Snapshot the inventory BEFORE any delete mutates the in-memory graph: on + # failure nothing is written, so a mid-batch "nodes in this workflow" hint + # must describe the graph as it still stands on disk (the same pre-batch + # re-hinting `apply` does — advertising already-discarded state is exactly + # the phantom-id failure mode the batch surfaces were bitten by). + pre_batch_hint = workflow_ops._available_nodes_hint(workflow) + ops: list[dict] = [] + try: + for raw in nodes: + raw = raw.strip() + node_id: Any = int(raw) if raw.lstrip("-").isdigit() else raw + workflow, op = workflow_ops.delete_node(workflow, graph, node_id, actor=actor, base_version=base_version) + ops.append(op) + except ValueError as e: + renderer.error( + code="workflow_edit_invalid", + message=f"batch failed: {workflow_ops._rehint_discarded_batch(e, pre_batch_hint)}", + hint="run `comfy workflow ls-nodes ` for the live node ids; the file was not modified", + ) + raise typer.Exit(code=1) from e + + workflow_ops.strip_internal(workflow) + serialized = json.dumps(workflow, indent=2) + wrote: str | None = None + if stdout: + import sys + + sys.stdout.write(serialized + "\n") + else: + _atomic_write_text(p, serialized) + wrote = str(p) + payload = { + "workflow": str(p), + "count": len(ops), + "ops": ops, + "base_version": base_version, + "version": base_version + len(ops), + "wrote": wrote, + } + if renderer.is_pretty(): + rprint(f"[bold green]✓[/bold green] deleted {len(ops)} node(s) → [dim]{p}[/dim]") + renderer.emit(payload, command="workflow delete-nodes", changed=True) + + # --------------------------------------------------------------------------- # clear # --------------------------------------------------------------------------- diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index 50ea588b1..a6b214928 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -61,6 +61,7 @@ "comfy workflow connect": "workflow", "comfy workflow set-widget": "workflow", "comfy workflow delete-node": "workflow", + "comfy workflow delete-nodes": "workflow", "comfy workflow ls-nodes": "workflow", "comfy workflow apply": "workflow", "comfy workflow capture": "workflow", diff --git a/tests/comfy_cli/command/test_workflow_delete_nodes.py b/tests/comfy_cli/command/test_workflow_delete_nodes.py new file mode 100644 index 000000000..20c6b492c --- /dev/null +++ b/tests/comfy_cli/command/test_workflow_delete_nodes.py @@ -0,0 +1,215 @@ +"""Tests for ``comfy workflow delete-nodes `` (V1-020 / BE-7153). + +The measured agent loop runs ``delete-node`` once per doomed node (×22 runs) — +N file loads, N writes, N catalog loads. ``delete-nodes`` is the batch verb: +N ids, ONE atomic write, one frozen ``delete_node`` op per id (via +``workflow_ops.delete_node`` — no new op kind). + +Contract under test: + * batch removes every named node in one write; ``data.ops`` carries one + stamped ``delete_node`` op per id (``--actor``/``--base-version`` honored). + * any invalid id fails the WHOLE batch atomically — file unchanged — with + the node-inventory hint rendered from the PRE-batch graph. + * dangling links are cleaned exactly as the single-delete verb cleans them. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli.caller import Caller +from comfy_cli.command import workflow as workflow_cmd +from comfy_cli.command import workflow_edit +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import ( + OutputMode, + Renderer, + reset_renderer_for_testing, + set_renderer, +) + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _object_info() -> dict[str, Any]: + return { + "TinyLatent": { + "input": {"required": {}}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "latent", + "display_name": "Tiny Latent", + "python_module": "nodes", + }, + "TinyKS": { + "input": {"required": {"latent_image": ["LATENT"]}}, + "input_order": {"required": ["latent_image"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "Tiny KSampler", + "python_module": "nodes", + }, + "TinyNote": { + "input": {"required": {}}, + "output": [], + "category": "util", + "display_name": "Tiny Note", + "python_module": "nodes", + }, + } + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +@pytest.fixture +def patched_graph(monkeypatch): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + + +def _workflow() -> dict: + """TinyLatent(7) --link 1--> TinyKS(3); TinyNote(5) free-standing.""" + return { + "last_node_id": 7, + "last_link_id": 1, + "nodes": [ + { + "id": 3, + "type": "TinyKS", + "pos": [200, 100], + "inputs": [{"name": "latent_image", "type": "LATENT", "link": 1}], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": []}], + "widgets_values": [], + }, + { + "id": 5, + "type": "TinyNote", + "pos": [0, 300], + "inputs": [], + "outputs": [], + "widgets_values": [], + }, + { + "id": 7, + "type": "TinyLatent", + "pos": [0, 100], + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": [1]}], + "widgets_values": [], + }, + ], + "links": [[1, 7, 0, 3, 0, "LATENT"]], + } + + +def _write(tmp_path: Path, data: dict) -> Path: + p = tmp_path / "delete_nodes_wf.json" + p.write_text(json.dumps(data, indent=2), encoding="utf-8") + return p + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(workflow_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + for line in reversed(captured.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +class TestDeleteNodesBatch: + def test_batch_removes_all_and_emits_stamped_ops(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _workflow()) + env = _run( + ["delete-nodes", str(path), "7", "5", "--actor", "agent-x", "--base-version", "4"], + capsys, + ) + assert env["ok"] is True, env + data = env["data"] + assert data["count"] == 2 + ops = data["ops"] + # One frozen delete_node op per id — NOT a new op kind. + assert [op["op"] for op in ops] == ["delete_node", "delete_node"] + assert [op["node_id"] for op in ops] == [7, 5] + for op in ops: + assert op["actor"] == "agent-x" + assert op["base_version"] == 4 + assert op["stamp"] == [4, "agent-x"] + assert isinstance(op["op_id"], str) and op["op_id"] + assert data["base_version"] == 4 + assert data["version"] == 4 + len(ops) + # ONE write, both nodes gone. + on_disk = json.loads(path.read_text()) + assert {n["id"] for n in on_disk["nodes"]} == {3} + # Bookkeeping never serialized. + assert "_applied_ops" not in on_disk + + def test_dangling_links_cleaned_exactly_like_single_delete(self, patched_graph, tmp_path, capsys): + batch_path = _write(tmp_path, _workflow()) + env = _run(["delete-nodes", str(batch_path), "7"], capsys) + assert env["ok"] is True + batch_disk = json.loads(batch_path.read_text()) + + single_path = tmp_path / "delete_nodes_single_wf.json" + single_path.write_text(json.dumps(_workflow(), indent=2), encoding="utf-8") + env2 = _run(["delete-node", str(single_path), "7"], capsys) + assert env2["ok"] is True + single_disk = json.loads(single_path.read_text()) + + assert batch_disk["links"] == single_disk["links"] == [] + for disk in (batch_disk, single_disk): + ks = next(n for n in disk["nodes"] if n["id"] == 3) + assert ks["inputs"][0]["link"] is None + + def test_invalid_id_fails_whole_batch_atomically(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _workflow()) + before = path.read_text() + env = _run(["delete-nodes", str(path), "7", "999"], capsys) + assert env["ok"] is False + assert env["error"]["code"] == "workflow_edit_invalid" + # File untouched even though id 7 was valid and listed first. + assert path.read_text() == before + # The node-inventory hint reflects the PRE-batch graph: node 7 still + # exists on disk, so it must still be advertised. + msg = env["error"]["message"] + assert "999" in msg + assert "7 (TinyLatent)" in msg + assert "No changes were applied" in msg + + def test_stdout_mode_writes_nothing(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _workflow()) + before = path.read_text() + env = _run(["delete-nodes", str(path), "7", "--stdout"], capsys) + assert env["ok"] is True + assert env["data"]["wrote"] is None + assert path.read_text() == before From cf4cd3bb635afd417fd77f314a7b378c04e26cc0 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 15:48:17 -0700 Subject: [PATCH 44/53] feat(cli): emit the widget catalog (widget_order + autogrow templates + catalog_version) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `comfy nodes widget-catalog` emits the derived projection of object_info that a name<->index widget converter needs: {"catalog_version": "sha256:", "class_count": N, "types": {"": {"widget_order": [...], "autogrow_templates": {...}, "inputcount": {...}}}} A ComfyUI workflow stores widget values POSITIONALLY in widgets_values; the CRDT document the cloud agent and the frontend co-edit stores them BY NAME. Converting between the two needs one fact per class — the widget order — and nothing produced it. The only instance anywhere was a hand-vendored 10-class test fixture. The CLI owns the computation because it already does it: Graph.widget_order is what every `workflow set-widget` here resolves against, including the two shapes a naive "list the non-link inputs" projection gets wrong — the synthetic control_after_generate slot, and COMFY_DYNAMICCOMBO_V3 sub-widget expansion. The command imports that, it does not restate it. A second implementation would be a second answer, and the divergence would not surface as an error: it would surface as a widget value written into the wrong index of someone's canvas. catalog_version is sha256 over the canonical JSON encoding of the `types` map alone (sorted keys, no whitespace, UTF-8), prefixed "sha256:". It excludes itself and class_count, so a consumer holding only the catalog can recompute and verify the pin it was handed. Identical object_info in => identical version out regardless of key order; any change to any class's widget order moves it. Grow families, so a consumer holding only the catalog can name a grown slot: - autogrow_templates: every COMFY_AUTOGROW_V3 input, using the schema's template when object_info ships one and otherwise the same singularization _autogrow_elem_name falls back to (new Port.autogrow_element_template). - inputcount: the kijai *Multi family (fixed {elem}_N inputs + an INT widget the node reads at runtime). Detection is factored out of _inputcount_family_match into inputcount_family_elements, so the slot-level and class-level answers cannot drift. Offline by construction: routed through the existing loader, so COMFY_OBJECT_INFO_FILE (or --input) resolves the schema from a baked dump with no network and no credential — the path a server-side host runs it on. --select is supported. Verified against the real prod object_info (3625 classes, 344 KB): the output reproduces the hand-vendored cloud fixture (services/agent/internal/dochost/ testdata/catalog.json) exactly, all 10 classes, widget_order and autogrow_templates alike. Tests grade the emitted order against the engine class by class rather than against hand-written expectations, so the two cannot drift. Co-Authored-By: Claude Fable 5 --- comfy_cli/command/nodes.py | 96 +++++ comfy_cli/cql/engine.py | 20 + comfy_cli/cql/widget_catalog.py | 119 ++++++ comfy_cli/discovery.py | 4 + comfy_cli/schemas/widget_catalog.json | 73 ++++ comfy_cli/workflow_ops.py | 36 +- .../command/test_nodes_widget_catalog.py | 357 ++++++++++++++++++ 7 files changed, 697 insertions(+), 8 deletions(-) create mode 100644 comfy_cli/cql/widget_catalog.py create mode 100644 comfy_cli/schemas/widget_catalog.json create mode 100644 tests/comfy_cli/command/test_nodes_widget_catalog.py diff --git a/comfy_cli/command/nodes.py b/comfy_cli/command/nodes.py index e4a6322bd..fa2fa21e8 100644 --- a/comfy_cli/command/nodes.py +++ b/comfy_cli/command/nodes.py @@ -988,6 +988,102 @@ def categories_cmd( renderer.emit(payload, command="nodes categories") +# --------------------------------------------------------------------------- +# widget-catalog — the derived name↔index projection the CRDT applier needs +# --------------------------------------------------------------------------- + + +@app.command( + "widget-catalog", + help=( + "Emit the widget catalog: per-class widget order (plus autogrow/inputcount families) " + "with a content-hash catalog_version. The projection of object_info a name<->index " + "widget converter needs." + ), +) +@tracking.track_command("nodes") +def widget_catalog_cmd( + where: Annotated[ + str | None, + typer.Option("--where", show_default=False, help="'cloud' to query Comfy Cloud's catalog; default is local."), + ] = None, + input_path: Annotated[ + str | None, + typer.Option("--input", show_default=False, help="Path to a local object_info JSON (offline mode)."), + ] = None, + host: Annotated[str | None, typer.Option(show_default=False)] = None, + port: Annotated[int | None, typer.Option(show_default=False)] = None, + select: Annotated[ + str | None, + typer.Option( + "--select", + show_default=False, + help="Project the payload: dot path (types.KSampler.widget_order), comma multi-select.", + ), + ] = None, +): + """Export ``{types: {: {widget_order, ...}}}`` + ``catalog_version``. + + A ComfyUI workflow stores widget values POSITIONALLY (``widgets_values``); + the CRDT document the cloud agent and the frontend co-edit stores them BY + NAME. Converting between the two needs the widget order — which + ``cql.engine.Graph`` already computes for every ``set-widget`` in this CLI, + including the two shapes a naive projection gets wrong (the synthetic + ``control_after_generate`` slot, and dynamic-combo sub-widget expansion). + Exporting it from here means there is one implementation of widget order, + not one per consumer; see ``comfy_cli.cql.widget_catalog``. + + Offline is the normal case for a server-side host: with + ``COMFY_OBJECT_INFO_FILE`` set (or ``--input``) this reads a baked dump and + never touches the network or a credential. + """ + from comfy_cli.cql.widget_catalog import build_catalog + + renderer = get_renderer() + _stale: dict = {} + graph = _get_graph( + input_path, + host, + port, + where=where, + on_stale=lambda key, err: _stale.update(stale=True, source=key, reason=err), + ) + + payload = build_catalog(graph) + + if _stale: + # A stale catalog is still a usable catalog — the version pins WHICH one + # it is, so a consumer that cached a different version re-fetches. Warn, + # don't fail: the alternative is no catalog at all. + payload["stale"] = True + payload["warnings"] = [ + { + "code": "object_info_stale", + "message": f"served from cache ({_stale['source']}): {_stale['reason']}", + } + ] + + if select is not None: + from comfy_cli.selector import emit_selected + + return emit_selected(renderer, payload, select, command="nodes widget-catalog") + + if renderer.is_pretty(): + from rich.table import Table + + rprint(f"[bold]{payload['class_count']}[/bold] class(es) [dim]{payload['catalog_version']}[/dim]") + table = Table(show_header=True, header_style="bold") + table.add_column("class_type") + table.add_column("widgets") + table.add_column("widget_order") + for class_type, entry in sorted(payload["types"].items()): + order = entry["widget_order"] + table.add_row(sanitize_markup(class_type), str(len(order)), sanitize_markup(", ".join(order))) + renderer.console().print(table) + + renderer.emit(payload, command="nodes widget-catalog") + + # --------------------------------------------------------------------------- # refresh — object_info is fetched live; the annotation data is what's cached # --------------------------------------------------------------------------- diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 9e74cdb37..0f33d5094 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -121,6 +121,26 @@ def autogrow_template(self) -> dict | None: return {"prefix": prefix} return None + @property + def autogrow_element_template(self) -> dict | None: + """The element-naming template a caller should USE for this autogrow + input — never None for an autogrow port. + + Identical to :attr:`autogrow_template` when object_info ships one; + otherwise the historical pluralization fallback (``images`` → prefix + ``image``) that ``workflow_ops._autogrow_elem_name`` applies when the + schema is silent. Exporters need the *effective* answer: a consumer + holding only the exported catalog has no object_info to fall back to, + and omitting the entry would tell it the input does not autogrow at all. + """ + if not self.is_autogrow: + return None + declared = self.autogrow_template + if declared is not None: + return declared + stem = self.name[:-1] if self.name.endswith("s") else self.name + return {"prefix": stem} + @property def is_upload_backed(self) -> bool: """This COMBO's options are the server's *installed input files*, so the diff --git a/comfy_cli/cql/widget_catalog.py b/comfy_cli/cql/widget_catalog.py new file mode 100644 index 000000000..7965f2095 --- /dev/null +++ b/comfy_cli/cql/widget_catalog.py @@ -0,0 +1,119 @@ +"""The widget catalog: a derived projection of ``object_info`` that says, per +node class, **which widgets exist and in what positional order**. + +WHY IT EXISTS. A ComfyUI workflow stores widget values POSITIONALLY, in each +node's ``widgets_values`` array. The CRDT document the cloud agent and the +frontend co-edit stores them BY NAME, because an index is not a stable identity +across a schema change (add one widget to a class and every later index moves). +Something has to convert between the two, in both directions, and that +something needs exactly one fact per class: the widget order. + +That fact is already computed here — :meth:`cql.engine.Graph.widget_order` is +what every edit primitive in this CLI resolves ``set-widget .`` +against, including the two shapes a naive projection gets wrong: + +* ``control_after_generate`` — a synthetic widget the frontend injects after a + seed. It occupies a real ``widgets_values`` slot and is in no ``input_order``. +* ``COMFY_DYNAMICCOMBO_V3`` — one declared selector that expands into + key-dependent sub-widgets (``model`` → ``model``, ``model.resolution``). + +So the catalog is exported from here rather than recomputed by each consumer: +a second implementation of widget order is a second answer, and the two would +diverge silently — as a wrong index, i.e. a widget value written into the wrong +field of the user's canvas. + +WHAT IT IS NOT. It is not ``object_info``. It carries no types, no defaults, no +enum choices, no descriptions — only what a name↔index converter needs. That +keeps it small enough to hand to a sidecar on every call. + +SHAPE (``envelope/1`` ``data`` of ``comfy nodes widget-catalog``):: + + { + "catalog_version": "sha256:<64 hex>", + "class_count": 1234, + "types": { + "KSampler": {"widget_order": ["seed", "control_after_generate", ...]}, + "BatchImagesNode": { + "widget_order": [], + "autogrow_templates": {"images": {"prefix": "image"}} + }, + "ImageBatchMulti": { + "widget_order": ["inputcount"], + "inputcount": {"widget": "inputcount", "elements": ["image"]} + } + } + } + +``catalog_version`` is the SHA-256 of the canonical JSON encoding of ``types`` +alone (sorted keys, no whitespace, UTF-8), prefixed ``sha256:``. It excludes +itself and ``class_count`` so a consumer that stored only the catalog can +recompute and verify the pin it was given. Identical ``object_info`` in ⇒ +identical version out, regardless of key iteration order; any change to any +class's widget order or grow family moves it. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +CATALOG_VERSION_PREFIX = "sha256:" + + +def build_types(graph) -> dict[str, dict[str, Any]]: + """The ``types`` map: one entry per class the graph knows, in class order. + + Every class gets an entry, including widget-less ones (``VAEDecode`` → + ``{"widget_order": []}``). A missing entry and an empty order are different + statements — "I have never heard of this class" vs. "this class has no + widgets" — and a converter must be able to tell them apart. + """ + # Imported inside the function: workflow_ops sits above cql in the import + # graph (it consumes cql.engine), so a module-level import here would make + # the dependency circular. + from comfy_cli.workflow_ops import INPUTCOUNT_WIDGET, inputcount_family_elements + + types: dict[str, dict[str, Any]] = {} + for m in graph.all_nodes(): + entry: dict[str, Any] = {"widget_order": list(graph.widget_order(m.id))} + + # V3 autogrow (COMFY_AUTOGROW_V3): one declared input, one wire slot per + # connection (`images` → `images.image0`, `images.image1`, …). The + # effective template is used, so a schema that ships none still tells the + # consumer the input grows (see Port.autogrow_element_template). + autogrow = {p.name: p.autogrow_element_template for p in m.inputs if p.is_autogrow} + if autogrow: + entry["autogrow_templates"] = autogrow + + # kijai `inputcount` family (ImageBatchMulti, JoinStringMulti, …): NOT + # autogrow-typed — fixed `{elem}_N` inputs plus an INT `inputcount` + # widget the node reads at runtime. Growing a slot means bumping that + # widget, which is a widget write, so the converter has to know. + elements = inputcount_family_elements(graph, m.id) + if elements: + entry["inputcount"] = {"widget": INPUTCOUNT_WIDGET, "elements": elements} + + types[m.id] = entry + return types + + +def catalog_version(types: dict[str, Any]) -> str: + """``sha256:`` over the canonical JSON encoding of ``types``. + + Canonical = sorted keys, no insignificant whitespace, non-ASCII kept + verbatim. Deterministic for identical input on any platform and any Python + build, which is what makes it usable as a consumer-side cache key. + """ + canonical = json.dumps(types, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return CATALOG_VERSION_PREFIX + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def build_catalog(graph) -> dict[str, Any]: + """The full emitted payload — ``types`` plus its pin and cardinality.""" + types = build_types(graph) + return { + "catalog_version": catalog_version(types), + "class_count": len(types), + "types": types, + } diff --git a/comfy_cli/discovery.py b/comfy_cli/discovery.py index e9586935d..8f38c4d80 100644 --- a/comfy_cli/discovery.py +++ b/comfy_cli/discovery.py @@ -51,6 +51,10 @@ "comfy nodes types": "nodes", "comfy nodes categories": "nodes", "comfy nodes refresh": "nodes", + # The widget catalog gets its OWN schema, not `nodes`: `nodes.json` declares + # `types` as an array of connection-type names (`nodes types`), and the + # catalog's `types` is a class_type→entry map. Same key, different contract. + "comfy nodes widget-catalog": "widget_catalog", # workflow editing "comfy workflow slots": "workflow", "comfy workflow set-slot": "workflow", diff --git a/comfy_cli/schemas/widget_catalog.json b/comfy_cli/schemas/widget_catalog.json new file mode 100644 index 000000000..a93fa001f --- /dev/null +++ b/comfy_cli/schemas/widget_catalog.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "comfy nodes widget-catalog", + "description": "The widget catalog: a derived projection of object_info giving, per node class, the positional order of its widgets (plus the grow families that mint slots). It is what a name<->index widget converter needs — notably the CRDT applier, which stores widget values by NAME while the workflow JSON stores them POSITIONALLY in widgets_values. It is NOT object_info: no types, defaults, enum choices, or descriptions.", + "type": "object", + "properties": { + "catalog_version": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "SHA-256 of the canonical JSON encoding of `types` alone (sorted keys, no whitespace, UTF-8), prefixed 'sha256:'. Excludes itself and class_count, so a consumer holding only the catalog can recompute it. Deterministic for identical object_info regardless of key order; any change to any class's widget order or grow family moves it. Consumers pin and cache on this." + }, + "class_count": { + "type": "integer", + "description": "Number of entries in `types`. Redundant with the map's size; present so a consumer can sanity-check a truncated transfer without walking the map." + }, + "types": { + "type": "object", + "description": "One entry per node class the loaded environment knows. EVERY class is present, including widget-less ones: a missing entry ('unknown class') and an empty widget_order ('no widgets') are different statements and a converter must distinguish them.", + "additionalProperties": { + "type": "object", + "properties": { + "widget_order": { + "type": "array", + "items": { "type": "string" }, + "description": "Widget names in the exact positional order of the node's widgets_values array — index i in this list IS index i in widgets_values. Includes the synthetic 'control_after_generate' slot the frontend injects after a seed, and dynamic-combo sub-widgets flattened as '.'. Computed by cql.engine.Graph.widget_order, the same call every set-widget in this CLI resolves against." + }, + "autogrow_templates": { + "type": "object", + "description": "Present only for classes with a COMFY_AUTOGROW_V3 input: base input name -> element-naming template. The schema's own template when object_info ships one, else the pluralization fallback the edit path applies ('images' -> prefix 'image'), so a consumer holding only the catalog can name grown slots without object_info.", + "additionalProperties": { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "description": "Grown slots are '.', N from 0. Mutually exclusive with 'names'." + }, + "names": { + "type": "array", + "items": { "type": "string" }, + "description": "Verbatim element names by index; past the end, growth continues as ''. Mutually exclusive with 'prefix'." + } + } + } + }, + "inputcount": { + "type": "object", + "description": "Present only for the kijai 'inputcount' family (ImageBatchMulti, JoinStringMulti, ...). NOT autogrow: the schema declares fixed '_N' inputs plus an INT widget the node reads at runtime, so bare 1-based '_N' keys are the wire address and growing one also means WRITING that widget.", + "properties": { + "widget": { "type": "string", "description": "The widget to bump when a slot is grown (always 'inputcount')." }, + "elements": { + "type": "array", + "items": { "type": "string" }, + "description": "Element bases with a '_1' input, sorted (e.g. ['image'])." + } + }, + "required": ["widget", "elements"] + } + }, + "required": ["widget_order"] + } + }, + "stale": { + "type": "boolean", + "description": "True when object_info came from the on-disk cache because the server/session was unreachable. The catalog is still usable — catalog_version identifies WHICH snapshot it is — so this is a warning, not an error." + }, + "warnings": { + "type": "array", + "items": { "type": "object" }, + "description": "Non-fatal advisories; carries the 'object_info_stale' entry when stale is true." + } + }, + "required": ["catalog_version", "class_count", "types"] +} diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 8a3c0578e..6a0a57f77 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -1543,6 +1543,32 @@ def _resolve_input_slot(node: dict, graph, slot: Any) -> int: _INPUTCOUNT_KEY_RE = re.compile(r"^(.+)_(\d+)$") +INPUTCOUNT_WIDGET = "inputcount" + + +def inputcount_family_elements(graph, node_type: str) -> list[str]: + """The element bases of ``node_type``'s kijai ``inputcount`` family, sorted + — ``["image"]`` for ImageBatchMulti, ``[]`` for anything that isn't one. + + Both detection signals must be present (see + :func:`_inputcount_family_match`, which is defined in terms of this): a + required INT widget named exactly ``inputcount``, PLUS at least one + ``{elem}_1`` sibling input. Exposed (not private) because the family is a + *class-level* property of the schema, and exporters — ``nodes + widget-catalog``, which ships this to the CRDT applier — need to ask about + a class without first inventing a slot name to probe with. + + Returns ``[]`` when ``graph`` is unavailable (offline edit) or the class + isn't in the catalog.""" + if graph is None: + return [] + schema = graph.node(node_type) + if schema is None: + return [] + if not any(p.name == INPUTCOUNT_WIDGET and p.type == "INT" and not p.is_link for p in schema.inputs): + return [] + return sorted({p.name[: -len("_1")] for p in schema.inputs if p.name.endswith("_1")}) + def _inputcount_family_match(graph, node_type: str, slot: str) -> tuple[str, int] | None: """Detect a kijai ``inputcount``-family numbered key (e.g. ``image_3`` on @@ -1566,19 +1592,13 @@ def _inputcount_family_match(graph, node_type: str, slot: str) -> tuple[str, int isn't shaped ``{elem}_``, or the node's schema doesn't carry both signals.""" m = _INPUTCOUNT_KEY_RE.fullmatch(slot) - if not m or graph is None: + if not m: return None elem, n_str = m.group(1), m.group(2) n = int(n_str) if n < 1: return None - schema = graph.node(node_type) - if schema is None: - return None - has_inputcount = any(p.name == "inputcount" and p.type == "INT" and not p.is_link for p in schema.inputs) - if not has_inputcount: - return None - if not any(p.name == f"{elem}_1" for p in schema.inputs): + if elem not in inputcount_family_elements(graph, node_type): return None return elem, n diff --git a/tests/comfy_cli/command/test_nodes_widget_catalog.py b/tests/comfy_cli/command/test_nodes_widget_catalog.py new file mode 100644 index 000000000..6c6146502 --- /dev/null +++ b/tests/comfy_cli/command/test_nodes_widget_catalog.py @@ -0,0 +1,357 @@ +"""Tests for ``comfy nodes widget-catalog`` (the widget-catalog producer). + +WHY THIS COMMAND EXISTS: the CRDT doc host (cloud ``services/agent/dochost``) +and the applier (``@comfyorg/comfy-multi-player``) convert between the CRDT +doc's NAME-keyed widget maps and the workflow JSON's POSITIONAL +``widgets_values`` array. That conversion needs one derived projection of +``object_info`` — ``{types: {: {widget_order, autogrow_templates}}}`` +— and the widget order it needs is exactly what ``cql.engine.Graph`` already +computes for every edit primitive in this CLI. Emitting it here (rather than +recomputing it in Go) keeps a single source of ComfyUI widget semantics. + +THE CONTRACT IS THE ENGINE: for every class, ``types[c].widget_order`` must be +byte-identical to ``Graph.widget_order(c)``. If those two ever diverge, the +applier writes a widget value into the wrong index and the user's canvas +silently corrupts — so the tests below assert equality against the engine +itself, never against a hand-written expectation. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli.caller import Caller +from comfy_cli.command import nodes as nodes_cmd +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import OutputMode, Renderer, reset_renderer_for_testing, set_renderer + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +# --------------------------------------------------------------------------- +# Fixture object_info — one class per interesting widget-order shape. +# --------------------------------------------------------------------------- + + +def _object_info() -> dict[str, Any]: + return { + # control_after_generate: the engine injects a synthetic widget right + # after the seed, so a naive "list the non-link inputs" projection + # mis-indexes every widget after it. + "KSampler": { + "input": { + "required": { + "model": ["MODEL"], + "seed": ["INT", {"default": 0, "control_after_generate": True}], + "steps": ["INT", {"default": 20}], + "cfg": ["FLOAT", {"default": 8.0}], + "sampler_name": [["euler", "dpmpp_2m"]], + "denoise": ["FLOAT", {"default": 1.0}], + } + }, + "input_order": {"required": ["model", "seed", "steps", "cfg", "sampler_name", "denoise"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "KSampler", + "python_module": "nodes", + }, + "CLIPTextEncode": { + "input": {"required": {"text": ["STRING", {"multiline": True}], "clip": ["CLIP"]}}, + "input_order": {"required": ["text", "clip"]}, + "output": ["CONDITIONING"], + "output_name": ["CONDITIONING"], + "category": "conditioning", + "display_name": "CLIP Text Encode", + "python_module": "nodes", + }, + # Zero widgets — a real, load-bearing state. The applier must be able to + # tell "this class has no widgets" from "this class is unknown". + "VAEDecode": { + "input": {"required": {"samples": ["LATENT"], "vae": ["VAE"]}}, + "input_order": {"required": ["samples", "vae"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "latent", + "display_name": "VAE Decode", + "python_module": "nodes", + }, + # V3 autogrow WITH a schema-declared naming template. + "BatchImagesNode": { + "input": { + "required": { + "images": ["COMFY_AUTOGROW_V3", {"template": {"prefix": "image", "min": 1, "max": 50}}], + } + }, + "input_order": {"required": ["images"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "image", + "display_name": "Batch Images", + "python_module": "nodes", + }, + # V3 autogrow with NO template — the catalog must still say the input is + # autogrow, falling back to the same pluralization the edit path uses. + "UntemplatedGrowNode": { + "input": {"required": {"masks": ["COMFY_AUTOGROW_V3"]}}, + "input_order": {"required": ["masks"]}, + "output": ["MASK"], + "output_name": ["MASK"], + "category": "mask", + "display_name": "Untemplated Grow", + "python_module": "nodes", + }, + # kijai `inputcount` family: NOT autogrow-typed; fixed `{elem}_N` inputs + # plus an INT `inputcount` widget the node reads at runtime. + "ImageBatchMulti": { + "input": { + "required": { + "inputcount": ["INT", {"default": 2, "min": 2, "max": 1000}], + "image_1": ["IMAGE"], + "image_2": ["IMAGE"], + } + }, + "input_order": {"required": ["inputcount", "image_1", "image_2"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "image", + "display_name": "Image Batch Multi", + "python_module": "custom_nodes.KJNodes", + }, + # Dynamic combo: the selector expands key-dependent sub-widgets, and the + # catalog must carry the expanded order (model, model.resolution, seed). + "DynNode": { + "input": { + "required": { + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + {"key": "a", "inputs": {"required": {"resolution": ["INT", {"default": 512}]}}}, + {"key": "b", "inputs": {"required": {}}}, + ] + }, + ], + "seed": ["INT", {"default": 0}], + } + }, + "input_order": {"required": ["model", "seed"]}, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "api node", + "display_name": "Dyn Node", + "python_module": "nodes", + }, + } + + +def _graph(data: dict[str, Any] | None = None) -> Graph: + return Graph.from_object_info(data if data is not None else _object_info()) + + +@pytest.fixture +def patched_loader(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph()) + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(nodes_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + for line in reversed(captured.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +# --------------------------------------------------------------------------- +# widget_order — graded against the engine, class by class +# --------------------------------------------------------------------------- + + +class TestWidgetOrder: + def test_every_class_matches_the_engine(self, patched_loader, capsys): + env = _run(["widget-catalog"], capsys) + assert env["ok"] is True + types = env["data"]["types"] + graph = _graph() + assert set(types) == {m.id for m in graph.all_nodes()} + for class_type, entry in types.items(): + assert entry["widget_order"] == graph.widget_order(class_type), class_type + + def test_control_after_generate_is_in_the_order(self, patched_loader, capsys): + """The synthetic widget the frontend injects after a seed occupies a + real `widgets_values` slot — omitting it shifts every later index.""" + types = _run(["widget-catalog"], capsys)["data"]["types"] + assert types["KSampler"]["widget_order"] == [ + "seed", + "control_after_generate", + "steps", + "cfg", + "sampler_name", + "denoise", + ] + + def test_link_only_class_keeps_an_empty_order(self, patched_loader, capsys): + types = _run(["widget-catalog"], capsys)["data"]["types"] + assert types["VAEDecode"]["widget_order"] == [] + assert "VAEDecode" in types, "a widget-less class must still be present, not dropped" + + def test_dynamic_combo_sub_widgets_expand(self, patched_loader, capsys): + types = _run(["widget-catalog"], capsys)["data"]["types"] + assert types["DynNode"]["widget_order"] == ["model", "model.resolution", "seed"] + + +# --------------------------------------------------------------------------- +# autogrow / inputcount families +# --------------------------------------------------------------------------- + + +class TestGrowFamilies: + def test_schema_declared_autogrow_template(self, patched_loader, capsys): + types = _run(["widget-catalog"], capsys)["data"]["types"] + assert types["BatchImagesNode"]["autogrow_templates"] == {"images": {"prefix": "image"}} + + def test_untemplated_autogrow_falls_back_to_the_edit_paths_naming(self, patched_loader, capsys): + """No template in object_info still means "this input autogrows" — the + catalog says so, using the same singularization `_autogrow_elem_name` + applies when the schema is silent.""" + types = _run(["widget-catalog"], capsys)["data"]["types"] + assert types["UntemplatedGrowNode"]["autogrow_templates"] == {"masks": {"prefix": "mask"}} + + def test_non_growing_class_carries_no_template_key(self, patched_loader, capsys): + types = _run(["widget-catalog"], capsys)["data"]["types"] + assert "autogrow_templates" not in types["KSampler"] + + def test_inputcount_family_is_reported(self, patched_loader, capsys): + types = _run(["widget-catalog"], capsys)["data"]["types"] + assert types["ImageBatchMulti"]["inputcount"] == {"widget": "inputcount", "elements": ["image"]} + assert "inputcount" not in types["BatchImagesNode"], "autogrow is a different family" + + +# --------------------------------------------------------------------------- +# catalog_version +# --------------------------------------------------------------------------- + + +class TestCatalogVersion: + def test_stable_across_runs_for_identical_input(self, patched_loader, capsys): + first = _run(["widget-catalog"], capsys)["data"] + second = _run(["widget-catalog"], capsys)["data"] + assert first == second + assert first["catalog_version"] == second["catalog_version"] + assert first["catalog_version"].startswith("sha256:") + assert len(first["catalog_version"]) == len("sha256:") + 64 + + def test_changes_when_the_input_changes(self, monkeypatch, capsys): + base = _object_info() + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph(base)) + before = _run(["widget-catalog"], capsys)["data"]["catalog_version"] + + drifted = _object_info() + # One extra widget on one class — the smallest change that must move the + # version, because it moves every later widget's index. + drifted["KSampler"]["input"]["required"]["scheduler"] = [["normal", "karras"]] + drifted["KSampler"]["input_order"]["required"].insert(4, "scheduler") + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph(drifted)) + after = _run(["widget-catalog"], capsys)["data"]["catalog_version"] + + assert after != before + + def test_version_is_independent_of_class_iteration_order(self, monkeypatch, capsys): + """Reordering object_info's keys is not a catalog change — a pin that + flapped on dict order would be useless as a cache key.""" + base = _object_info() + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph(base)) + before = _run(["widget-catalog"], capsys)["data"]["catalog_version"] + + shuffled = dict(reversed(list(_object_info().items()))) + monkeypatch.setattr(nodes_cmd, "_get_graph", lambda *a, **kw: _graph(shuffled)) + after = _run(["widget-catalog"], capsys)["data"]["catalog_version"] + + assert after == before + + def test_version_excludes_itself_and_the_class_count(self, patched_loader, capsys): + """The hash covers the `types` map only, so a consumer can recompute it + from the catalog it stored without carrying the envelope metadata.""" + import hashlib + + data = _run(["widget-catalog"], capsys)["data"] + canonical = json.dumps(data["types"], sort_keys=True, separators=(",", ":"), ensure_ascii=False) + assert data["catalog_version"] == "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + assert data["class_count"] == len(data["types"]) + + +# --------------------------------------------------------------------------- +# offline + projection +# --------------------------------------------------------------------------- + + +class TestOfflineAndSelect: + def test_offline_via_input_dump(self, tmp_path, capsys): + dump = tmp_path / "object_info.json" + dump.write_text(json.dumps(_object_info()), encoding="utf-8") + env = _run(["widget-catalog", "--input", str(dump)], capsys) + assert env["ok"] is True + assert env["data"]["types"]["KSampler"]["widget_order"][0] == "seed" + + def test_offline_via_comfy_object_info_file_env(self, tmp_path, monkeypatch, capsys): + """The hermetic path the agent's sandbox uses: no --input, no server, no + credential — just the baked dump every other object_info consumer reads.""" + dump = tmp_path / "object_info.json" + dump.write_text(json.dumps(_object_info()), encoding="utf-8") + monkeypatch.setenv("COMFY_OBJECT_INFO_FILE", str(dump)) + monkeypatch.setattr( + "comfy_cli.cql.engine._load_from_target", + lambda **_: (_ for _ in ()).throw(AssertionError("must not touch the network")), + ) + env = _run(["widget-catalog"], capsys) + assert env["ok"] is True + assert env["data"]["types"]["BatchImagesNode"]["autogrow_templates"] == {"images": {"prefix": "image"}} + + def test_select_projects_the_payload(self, patched_loader, capsys): + env = _run(["widget-catalog", "--select", "catalog_version"], capsys) + assert env["ok"] is True + assert isinstance(env["data"], str) and env["data"].startswith("sha256:") + + +class TestSchemaContract: + def test_payload_validates_against_the_registered_schema(self, patched_loader, capsys): + """`comfy discover` hands agents this schema; the payload has to match it.""" + import jsonschema + + from comfy_cli.discovery import COMMAND_SCHEMAS + + assert COMMAND_SCHEMAS["comfy nodes widget-catalog"] == "widget_catalog" + schema = json.loads( + (Path(nodes_cmd.__file__).resolve().parents[1] / "schemas" / "widget_catalog.json").read_text() + ) + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.Draft202012Validator(schema).validate(_run(["widget-catalog"], capsys)["data"]) From a297c6d5c7dddecd02c0f6cedc28f3b5338eedab Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 15:49:49 -0700 Subject: [PATCH 45/53] feat(cli): bulk writers emit op batches + guarded reset_doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `templates fetch -o ` is a BULK WRITER: it replaces the working file wholesale. Downstream, that replacement could only be expressed as a new document — the consumer re-minted a snapshot from the new file, throwing away the attributed op history and doing precisely what op-vocabulary-v1 §8.6 says a replica must never do (independently re-seeding a base duplicates identities, silently, on the first merge). `--emit-ops` closes that. The fetch also emits `data.ops`: the stamped op batch that turns the file being replaced INTO the template — delete_node for what was there, then add_node + connect for the template — in the frozen vocabulary. Two contracts, both tested: * the OP contract: replaying the batch with apply_op reproduces the template exactly, widget values included (they ride inside the add_node payload, which §8.5 makes authoritative); * the SPEC contract: the same array is accepted by apply_specs verbatim, so it is a legal `comfy workflow apply --ops` batch — the same bar `nodes path --emit-ops` already meets. Each entry is dual-shape to satisfy both: a fully minted op AND that kind's spec keys. Identity is re-minted, never inherited — a template's small counter ids would resurrect identities a concurrent replica may still hold. A graph the vocabulary cannot express (subgraph definition, canvas group, reroute) emits NO ops and says why in `ops_skipped`. A partial batch is the dangerous answer: it applies cleanly and leaves a document that is not the graph the caller asked for. Also un-defers `reset_doc` (§1.6), which was frozen-but-rejected: * `comfy workflow reset-doc --confirm` — fails closed without --confirm, before the file is read, so an unconfirmed call cannot fail halfway. The only guarded edit command, because it is the only one no later op can undo; * a history barrier, not a clear: it drops the id high-water marks, `_applied_ops` and `_widget_stamps`. The reset's own op_id is written into the freshly-emptied list, so a re-delivery is a no-op, not a second wipe; * standalone-only, with its own registered code; * never emitted by --emit-ops or by a bulk writer. DEFERRED_OPS is now empty. docs/op-vocabulary-v1.md carries amendment v1.1 (§10) plus a new normative §8.8 for bulk writers, per the §9 amendment rule. Tests: tests/comfy_cli/test_reset_doc_op.py, tests/comfy_cli/command/test_templates_fetch_emit_ops.py. The doc↔code contract test (test_op_vocabulary_contract.py) stays green unchanged. Linear: BE-7171 (V1-038) Co-Authored-By: Claude Fable 5 --- comfy_cli/command/templates.py | 41 ++- comfy_cli/command/workflow.py | 4 + comfy_cli/command/workflow_edit.py | 48 +++ comfy_cli/error_codes.py | 14 + comfy_cli/workflow_ops.py | 279 ++++++++++++++++- docs/op-vocabulary-v1.md | 107 +++++-- .../command/test_templates_fetch_emit_ops.py | 285 ++++++++++++++++++ tests/comfy_cli/test_reset_doc_op.py | 194 ++++++++++++ 8 files changed, 944 insertions(+), 28 deletions(-) create mode 100644 tests/comfy_cli/command/test_templates_fetch_emit_ops.py create mode 100644 tests/comfy_cli/test_reset_doc_op.py diff --git a/comfy_cli/command/templates.py b/comfy_cli/command/templates.py index 7e87ffcdc..15db37dbd 100644 --- a/comfy_cli/command/templates.py +++ b/comfy_cli/command/templates.py @@ -28,7 +28,7 @@ import typer -from comfy_cli import tracking +from comfy_cli import tracking, workflow_ops from comfy_cli.file_utils import atomic_write_bytes from comfy_cli.http import ResponseTooLarge, plain_urlopen, read_capped from comfy_cli.output import get_renderer, rprint @@ -633,6 +633,22 @@ def fetch_cmd( bool, typer.Option("--refresh", help="Re-fetch the gallery index from GitHub before resolving."), ] = False, + emit_ops: Annotated[ + bool, + typer.Option( + "--emit-ops", + help=( + "Also emit `ops`: the stamped op batch that turns the file being replaced INTO this " + "template (delete_node + add_node + connect, frozen vocabulary). Replays through a merge " + "consumer AND is a legal `comfy workflow apply --ops` batch. Omitted, with `ops_skipped` " + "saying why, for templates the vocabulary cannot express (subgraphs, groups)." + ), + ), + ] = False, + actor: Annotated[str, typer.Option("--actor", help="Op author id for --emit-ops (CRDT stamping).")] = "cli", + base_version: Annotated[ + int, typer.Option("--base-version", help="Draft version the emitted ops are stamped against.") + ] = 0, ): renderer = get_renderer() @@ -698,6 +714,18 @@ def fetch_cmd( ) raise typer.Exit(code=1) from e + # The graph this fetch is REPLACING, read before the write clobbers it — + # `--emit-ops` needs it to emit the delete_node half of the batch. Only read + # when asked: an unparseable file at the target is not an error for a plain + # fetch (it is about to be overwritten), so it must not become one here. + previous: dict[str, Any] = {} + if emit_ops and out: + try: + loaded = json.loads(Path(out).expanduser().read_text(encoding="utf-8")) + previous = loaded if isinstance(loaded, dict) else {} + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + previous = {} + if out: out_path = Path(out).expanduser() out_path.parent.mkdir(parents=True, exist_ok=True) @@ -727,6 +755,17 @@ def fetch_cmd( # can get the workflow — emit() owns stdout in JSON mode, so without this # the fetch would produce nothing but metadata. payload["workflow"] = wf + if emit_ops: + # A bulk writer that emits ops stops being a whole-document replacement: + # the consumer folds the batch into the document it already has, so the + # replaced canvas keeps ONE identity and an attributed history instead of + # being re-seeded (op-vocabulary-v1 §8.6). Failure is NOT fatal — the + # fetch itself succeeded and the file is written; the consumer falls back + # to whatever it did before ops existed, and `ops_skipped` says why. + try: + payload["ops"] = workflow_ops.replace_ops(previous, wf, actor=actor, base_version=base_version) + except workflow_ops.NotExpressibleError as e: + payload["ops_skipped"] = str(e) if renderer.is_pretty() and out: rprint(f"[green]✓[/green] wrote {len(body):,} bytes ({payload['node_count']} nodes) to {target_repr}") renderer.emit(payload, command="templates fetch") diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index 54f9a4076..499ae2dcc 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -1483,6 +1483,10 @@ def delete_cmd( app.command("set-widget", help="Set a widget by name (`.`); emits a set_widget op.")(_wedit.set_widget_cmd) app.command("delete-node", help="Delete a node and its links; emits a delete_node op.")(_wedit.delete_cmd) app.command("clear", help="Remove every node, link, and group; emits one clear op.")(_wedit.clear_cmd) +app.command( + "reset-doc", + help="Reset the document to the empty baseline — nodes, ids AND replay history. Requires --confirm.", +)(_wedit.reset_doc_cmd) app.command("ls-nodes", help="List nodes (id/type/title) in a workflow file.")(_wedit.ls_nodes_cmd) app.command("apply", help="Apply a recipe / batch of edits in one pass; supports node aliases + --param.")( _wedit.apply_cmd diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index f84cc603d..b2657b62d 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -282,6 +282,54 @@ def clear_cmd( _finish(renderer, p, workflow, op, base_version, stdout, "workflow clear") +# --------------------------------------------------------------------------- +# reset-doc — the guarded document reset (op-vocabulary-v1 §1.6) +# --------------------------------------------------------------------------- + + +@tracking.track_command("workflow") +def reset_doc_cmd( + file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")], + confirm: Annotated[ + bool, + typer.Option( + "--confirm", + help="REQUIRED. Without it the command fails closed and writes nothing.", + ), + ] = False, + actor: ActorOpt = "cli", + base_version: BaseVersionOpt = 0, + stdout: StdoutOpt = False, + where: WhereOpt = None, # accepted for caller uniformity; reset needs no catalog +): + """Reset the document to the empty baseline — nodes, links, groups, ids AND + the applied-op history. + + Guarded, unlike every other edit command, because it is the only one whose + effect no later op can undo: it is a history barrier, so ops minted against + a pre-reset base_version do not replay across it. The check runs BEFORE the + file is read, so an unconfirmed call cannot even fail halfway. + """ + renderer = get_renderer() + renderer.command = "workflow reset-doc" + if not confirm: + renderer.error( + code="workflow_reset_doc_unconfirmed", + message=( + "`workflow reset-doc` erases every node AND the document's replay history; " + "it requires an explicit --confirm. Nothing was written." + ), + hint=( + "re-run with --confirm if that is really what you want — otherwise " + "`comfy workflow clear ` empties the graph while keeping the document's history" + ), + ) + raise typer.Exit(code=1) + p, workflow = _load_workflow_or_fail(renderer, file) + workflow, op = workflow_ops.reset_doc(workflow, actor=actor, base_version=base_version) + _finish(renderer, p, workflow, op, base_version, stdout, "workflow reset-doc") + + # --------------------------------------------------------------------------- # Litegraph node modes worth surfacing on ls-nodes. 0 (always) and 1 (on-event) # are normal execution and are deliberately unlabeled. Mirrors workflow_to_api's diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index 22f133911..53f85e3f7 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -520,6 +520,20 @@ class ErrorCode: "rejected atomically — nothing was applied.", "run the standalone `comfy workflow clear ` first, then apply the remaining ops as a batch", ), + ErrorCode( + "workflow_reset_doc_not_batchable", + "A batch (`workflow apply` / `workflow foreach`) contained a `reset_doc` op. `reset_doc` resets the " + "whole document to the empty baseline and erases its replay history, so it is standalone-only " + "(docs/op-vocabulary-v1.md: batchable = no) and the batch was rejected atomically — nothing was applied.", + "run the standalone `comfy workflow reset-doc --confirm` first, then apply the remaining ops as a batch", + ), + ErrorCode( + "workflow_reset_doc_unconfirmed", + "`comfy workflow reset-doc` was called without `--confirm`. The command fails closed: it erases every " + "node AND the document's replay history, which no later op can undo.", + "re-run with `--confirm` if that is really what you want — otherwise `comfy workflow clear ` " + "empties the graph while keeping the document's history", + ), ErrorCode( "normalized_value", "Warning (not fatal): a set-widget value wasn't an exact COMBO option, so " diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index c0fa6f416..d7ed9485b 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -92,16 +92,32 @@ def _new_op(kind: str, actor: str, base_version: int, **fields: Any) -> dict[str #: Every op kind in the v1 vocabulary, including defined-but-deferred kinds. FROZEN_OPS: tuple[str, ...] = ("add_node", "connect", "set_widget", "delete_node", "clear", "reset_doc") -#: Kinds frozen in the contract whose replay is not implemented yet -#: (``reset_doc`` is specified in op-vocabulary-v1.md; implementation is -#: deferred to the bulk-writers ticket). ``apply_op`` must keep rejecting these. -DEFERRED_OPS: tuple[str, ...] = ("reset_doc",) +#: Kinds frozen in the contract whose replay is not implemented yet. +#: ``apply_op`` must keep rejecting these. Empty since amendment v1.1: +#: ``reset_doc`` was un-deferred by the bulk-writers ticket (V1-038). +DEFERRED_OPS: tuple[str, ...] = () #: Kinds a batch (``apply_specs``) dispatches. ``clear`` and ``reset_doc`` are #: standalone-only: they rewrite the whole document, so they never ride inside #: an atomic batch. BATCHABLE_OPS: tuple[str, ...] = ("add_node", "connect", "set_widget", "delete_node") +#: Per-kind rendering for :class:`NotBatchableError` — the registered error code +#: and the standalone command that DOES do the job. One entry per frozen kind +#: outside ``BATCHABLE_OPS``; the contract test pins that correspondence. +_NOT_BATCHABLE: dict[str, dict[str, str]] = { + "clear": { + "code": "workflow_clear_not_batchable", + "command": "comfy workflow clear ", + "does": "wipes the whole graph", + }, + "reset_doc": { + "code": "workflow_reset_doc_not_batchable", + "command": "comfy workflow reset-doc --confirm", + "does": "resets the whole document to the empty baseline and erases its replay history", + }, +} + class NotBatchableError(ValueError): """A frozen op kind that is standalone-only was submitted inside a batch. @@ -110,16 +126,25 @@ class NotBatchableError(ValueError): (see ``comfy_cli/error_codes.py``) instead of the generic ``workflow_edit_invalid``, so a caller learns the exact standalone command to run rather than re-trying the batch. + + ``code``/``hint`` are per-kind INSTANCE attributes; the class attributes are + the ``clear`` values, kept so existing callers that read + ``NotBatchableError.code`` off the class still resolve. """ code = "workflow_clear_not_batchable" hint = "run the standalone `comfy workflow clear ` first, then apply the remaining ops as a batch" - def __init__(self, index: int): + def __init__(self, index: int, kind: str = "clear"): + entry = _NOT_BATCHABLE.get(kind, _NOT_BATCHABLE["clear"]) + command = entry["command"] + self.code = entry["code"] + self.kind = kind + self.hint = f"run the standalone `{command}` first, then apply the remaining ops as a batch" super().__init__( - f"spec #{index}: `clear` wipes the whole graph and is standalone-only (op-vocabulary-v1: " + f"spec #{index}: `{kind}` {entry['does']} and is standalone-only (op-vocabulary-v1: " "batchable = no) — it never rides inside a batch. No changes were applied — the batch was " - "discarded. Run `comfy workflow clear ` as its own command, then apply the remaining ops." + f"discarded. Run `{command}` as its own command, then apply the remaining ops." ) @@ -843,6 +868,207 @@ def clear(workflow: dict, *, actor: str = "cli", base_version: int = 0) -> tuple return apply_op(workflow, op, None), op +def reset_doc(workflow: dict, *, actor: str = "cli", base_version: int = 0) -> tuple[dict, dict]: + """Reset the whole document to the empty baseline (op-vocabulary-v1 §1.6). + + Not ``clear``. ``clear`` empties the graph but PRESERVES the id high-water + marks and the applied-op bookkeeping, so it is an ordinary edit that merges + with concurrent ops. ``reset_doc`` drops those too: it is a **history + barrier**, and ops minted against a pre-reset ``base_version`` do not replay + across it. + + That is why the CLI surface guards it behind an explicit ``--confirm`` and + why it is standalone-only — there is no safe way to fold "forget everything + that ever applied" into the middle of a batch. + """ + removed = [n.get("id") for n in workflow.get("nodes") or [] if isinstance(n, dict)] + op = _new_op("reset_doc", actor, base_version, removed_nodes=removed) + return apply_op(workflow, op, None), op + + +# --------------------------------------------------------------------------- +# Bulk writers — expressing a whole-file replacement as ops (V1-038) +# --------------------------------------------------------------------------- + + +class NotExpressibleError(ValueError): + """A graph uses structure the frozen v1 vocabulary cannot express. + + Raised by :func:`replace_ops` INSTEAD of returning a partial batch. A + partial batch is the dangerous answer: it applies cleanly and leaves a + document that is not the graph the caller asked for. The caller is expected + to fall back to whatever whole-document path it had before (the cloud + agent re-mints), and to say why. + """ + + +def _inexpressible_reason(workflow: dict) -> str | None: + """Why ``workflow`` cannot be rebuilt from add_node/connect ops, or None. + + The frozen vocabulary has four batchable kinds and none of them can create a + subgraph definition, a canvas group, or a reroute point — so a graph that + carries any of those is not reconstructible from ops, full stop. Enumerated + positively (a closed list of things we know we CAN'T do) rather than by + trying and checking, so an unexpressible template fails before it has + written anything. + """ + if not isinstance(workflow, dict) or not isinstance(workflow.get("nodes"), list): + return "not a frontend-format workflow (no `nodes` list) — only the save/UI format can be op-ified" + definitions = workflow.get("definitions") + if isinstance(definitions, dict) and definitions.get("subgraphs"): + return "the workflow contains a subgraph definition, which no frozen op kind can create" + if workflow.get("groups"): + return "the workflow contains canvas groups, which no frozen op kind can create" + extra = workflow.get("extra") + if isinstance(extra, dict) and (extra.get("reroutes") or extra.get("linkExtensions")): + return "the workflow contains reroute points, which no frozen op kind can create" + for node in workflow["nodes"]: + if not isinstance(node, dict) or node.get("id") is None or not node.get("type"): + return "the workflow contains a node with no id or no type" + for link in workflow.get("links") or []: + if not isinstance(link, list) or len(link) < 5: + return "the workflow contains a link that is not a [id, from, from_slot, to, to_slot, type] tuple" + return None + + +def _slot_ref(node: dict, slots_key: str, index: Any, alias: str) -> str: + """`$alias.` for a spec-form connect, preferring the slot NAME. + + Names are the canonical reference form and survive slot reordering; the + index is the fallback for a node whose slot list the template omits. + ``_split_ref_slot`` partitions on the FIRST dot, so a name containing one + would resolve wrong — those fall back to the index too. + """ + slots = node.get(slots_key) + if isinstance(slots, list) and isinstance(index, int) and 0 <= index < len(slots): + name = (slots[index] or {}).get("name") if isinstance(slots[index], dict) else None + if isinstance(name, str) and name and "." not in name: + return f"${alias}.{name}" + return f"${alias}.{index}" + + +def _alias_for(class_type: str, used: dict[str, int]) -> str: + """A deterministic, batch-unique alias for a node — `ksampler`, `ksampler_2`.""" + base = re.sub(r"[^a-z0-9_]", "", str(class_type).lower()) or "node" + used[base] = used.get(base, 0) + 1 + return base if used[base] == 1 else f"{base}_{used[base]}" + + +def replace_ops(old: dict, new: dict, *, actor: str = "cli", base_version: int = 0) -> list[dict]: + """The stamped op batch that turns ``old`` into ``new``. + + This is what makes a BULK WRITER (a template fetch, a saved-workflow open) + an attributed, incremental edit instead of a whole-document replacement. + Without it the only way to land a replaced canvas in a shared document is to + re-seed it — and §8.6 is explicit that independently re-seeding a base is + the one thing a replica must never do, because the duplicate identities + only show up on the first merge. + + Shape: ``delete_node`` for everything currently in ``old`` (in order), then + ``add_node`` for every node in ``new``, then ``connect`` for every link. + Widget values need no ``set_widget`` ops — they ride inside the ``add_node`` + payload, which §8.5 makes authoritative at replay. + + **Identity is re-minted, never inherited.** Template graphs are numbered + from small frontend counters (1, 2, 3…); replaying those ids into a live + document would reuse identities a concurrent replica may still hold, which + §1.5 calls out as letting a merge resurrect a deleted node. Every node and + link gets a fresh ``mint_id`` and every interior reference is remapped onto + it. + + **Dual-shape on purpose.** Each returned dict is a fully minted op (``op_id`` + / ``actor`` / ``stamp`` + the kind's minted fields) AND carries that kind's + SPEC keys (``class_type``/``at``/``as``, ``from``/``to``, ``node``). So the + same array replays through :func:`apply_op` losslessly *and* is accepted + verbatim by :func:`apply_specs` — one artifact, both consumers. The two are + not equivalent: ``apply_specs`` re-mints each node from the live catalog, so + it reproduces the STRUCTURE (classes + wiring) while the op path reproduces + the graph exactly, widget values included. + + :raises NotExpressibleError: ``new`` uses structure no frozen op can create. + """ + reason = _inexpressible_reason(new) + if reason: + raise NotExpressibleError(reason) + + ops: list[dict] = [] + old_links = [link for link in (old.get("links") or []) if isinstance(link, list) and len(link) >= 5] + for node in old.get("nodes") or []: + if not isinstance(node, dict) or node.get("id") is None: + continue + nid = node["id"] + ops.append( + _new_op( + "delete_node", + actor, + base_version, + node_id=nid, + removed_links=[link[0] for link in old_links if link[1] == nid or link[3] == nid], + # spec key, so apply_specs dispatches the same entry + node=nid, + ) + ) + + node_ids: dict[Any, int] = {n["id"]: mint_id() for n in new["nodes"]} + link_ids: dict[Any, int] = {link[0]: mint_id() for link in (new.get("links") or [])} + aliases: dict[Any, str] = {} + used: dict[str, int] = {} + + for original in new["nodes"]: + node = copy.deepcopy(original) + node["id"] = node_ids[original["id"]] + for slot in node.get("inputs") or []: + if isinstance(slot, dict) and slot.get("link") is not None: + slot["link"] = link_ids.get(slot["link"]) + for slot in node.get("outputs") or []: + if isinstance(slot, dict) and isinstance(slot.get("links"), list): + slot["links"] = [link_ids[x] for x in slot["links"] if x in link_ids] + alias = _alias_for(original.get("type"), used) + aliases[original["id"]] = alias + pos = original.get("pos") + ops.append( + _new_op( + "add_node", + actor, + base_version, + node_id=node["id"], + class_type=original.get("type"), + pos=pos, + node=node, + # spec keys + **{"at": pos, "as": alias}, + ) + ) + + by_original_id = {n["id"]: n for n in new["nodes"]} + for link in new.get("links") or []: + lid, from_node, from_slot, to_node, to_slot = link[0], link[1], link[2], link[3], link[4] + if from_node not in node_ids or to_node not in node_ids: + # A link to a node the graph does not contain is already broken in + # the source; dropping it is the faithful translation of a graph the + # canvas would render with a dangling edge. + continue + ops.append( + _new_op( + "connect", + actor, + base_version, + link_id=link_ids[lid], + from_node=node_ids[from_node], + from_slot=from_slot, + to_node=node_ids[to_node], + to_slot=to_slot, + link_type=link[5] if len(link) > 5 else None, + # spec keys + **{ + "from": _slot_ref(by_original_id[from_node], "outputs", from_slot, aliases[from_node]), + "to": _slot_ref(by_original_id[to_node], "inputs", to_slot, aliases[to_node]), + }, + ) + ) + return ops + + def delete_node( workflow: dict, graph, @@ -1144,11 +1370,11 @@ def apply_specs( workflow, op = delete_node( workflow, graph, resolve_ref(spec["node"], aliases), actor=actor, base_version=base_version ) - elif kind == "clear": + elif kind in _NOT_BATCHABLE: # In the frozen vocabulary but standalone-only — surfaced with # its own registered code so the caller learns the standalone # command instead of a generic "unknown op". - raise NotBatchableError(i) + raise NotBatchableError(i, kind) else: raise ValueError(f"spec #{i}: unknown op {kind!r}") except KeyError as e: @@ -1185,9 +1411,16 @@ def apply_op(workflow: dict, op: dict, graph) -> dict: _apply_delete_node(workflow, op) elif kind == "clear": _apply_clear(workflow, op) + elif kind == "reset_doc": + _apply_reset_doc(workflow, op) else: raise ValueError(f"unknown op {kind!r}") - applied.append(op["op_id"]) + # NOT ``applied.append`` — ``_apply_reset_doc`` REPLACES ``_applied_ops`` + # with a fresh list (that is what makes it a history barrier), so the local + # binding above is stale for that kind and the reset's own op_id would be + # written into a discarded list. Re-read, so a re-delivered reset_doc is a + # no-op rather than a second wipe. + workflow.setdefault("_applied_ops", []).append(op["op_id"]) return workflow @@ -1382,6 +1615,32 @@ def _apply_clear(workflow: dict, op: dict) -> None: workflow["groups"] = [] +#: Document-identity keys a ``reset_doc`` keeps. Everything else is discarded: +#: the point of the op is that nothing from the old document survives it. The +#: id stays so the reset document is still THIS workflow, not a new one. +_RESET_DOC_KEEP = ("id",) + + +def _apply_reset_doc(workflow: dict, op: dict) -> None: + """Replace the whole document with the empty baseline, bookkeeping included. + + Unlike ``_apply_clear`` this drops ``last_node_id``/``last_link_id``, + ``_applied_ops`` and ``_widget_stamps`` — the history barrier of §1.6. Ids + are minted at random in ``[2**40, 2**53)`` (``mint_id``), never allocated + from the high-water marks, so resetting them to 0 cannot cause id reuse. + """ + kept = {k: workflow[k] for k in _RESET_DOC_KEEP if k in workflow} + workflow.clear() + workflow.update(kept) + workflow["nodes"] = [] + workflow["links"] = [] + workflow["groups"] = [] + workflow["last_node_id"] = 0 + workflow["last_link_id"] = 0 + workflow["_applied_ops"] = [] + workflow["_widget_stamps"] = {} + + # --------------------------------------------------------------------------- # conflict detection + canonicalization (for ask-to-merge / convergence checks) # --------------------------------------------------------------------------- diff --git a/docs/op-vocabulary-v1.md b/docs/op-vocabulary-v1.md index 5c604599c..9af9facdf 100644 --- a/docs/op-vocabulary-v1.md +++ b/docs/op-vocabulary-v1.md @@ -27,13 +27,14 @@ Six kinds. No other kind is valid in v1: `apply_op` rejects an unknown kind with | `set_widget` | yes | `comfy workflow set-widget` | Set one widget value by name | | `delete_node` | yes | `comfy workflow delete` | Remove one node and its incident links | | `clear` | no | `comfy workflow clear` | Remove every node, link, and group | -| `reset_doc` | no | (deferred) | Reset the whole document to an empty baseline | +| `reset_doc` | no | `comfy workflow reset-doc --confirm` | Reset the whole document to an empty baseline | Batchable = the kind is accepted by `apply_specs` (the `workflow apply` / `workflow foreach` batch surface). `clear` and `reset_doc` rewrite the whole -document, so they are standalone-only: a batch containing `clear` is rejected -atomically with error code `workflow_clear_not_batchable` and a hint naming the -standalone `comfy workflow clear` command. Nothing from such a batch is applied. +document, so they are standalone-only: a batch containing either is rejected +atomically with its own registered error code — +`workflow_clear_not_batchable` / `workflow_reset_doc_not_batchable` — and a hint +naming the standalone command. Nothing from such a batch is applied. Every op carries the common envelope stamped by `_new_op`: @@ -141,19 +142,31 @@ monotonic — id reuse would let a merge resurrect a deleted node's identity. names the standalone command. * Idempotency: `op_id` no-op; clearing an empty document changes nothing. -### 1.6 `reset_doc` — standalone only, deferred - -Defined here; **implementation is deferred to the bulk-writers ticket**. -`apply_op` currently rejects it (`unknown op 'reset_doc'`), and the contract -tests pin that it stays rejected until it is un-deferred by amendment. - -Semantics when implemented: replace the entire document with the empty baseline, -including apply bookkeeping — unlike `clear`, which preserves the id high-water -marks and the applied-op history. Because it erases replay history, it is a -history barrier: ops minted against a pre-reset `base_version` do not replay -across it. Guard semantics: the CLI surface requires an explicit `--confirm` -flag; without it the command fails closed and applies nothing. Not batchable, -for the same reason as `clear`. +### 1.6 `reset_doc` — standalone only + +Command: `comfy workflow reset-doc --confirm`. Implemented by amendment +v1.1 (§10); `DEFERRED_OPS` is now empty. Minted op fields: `removed_nodes` (ids +present at mint time), same as `clear`. + +Replaces the entire document with the empty baseline, **including apply +bookkeeping** — unlike `clear`, which preserves the id high-water marks and the +applied-op history. `last_node_id` / `last_link_id` go to 0 (safe: ids come from +`mint_id`, never from the high-water marks — §8.3), `_applied_ops` and +`_widget_stamps` are dropped, and only the document `id` survives. Because it +erases replay history it is a **history barrier**: ops minted against a +pre-reset `base_version` do not replay across it. + +* Guard: the CLI surface requires an explicit `--confirm`; without it the + command fails closed with `workflow_reset_doc_unconfirmed` and writes nothing. + The check runs before the file is read, so an unconfirmed call cannot fail + halfway. It is the only edit command with a guard, because it is the only one + no later op can undo. +* Idempotency: the reset's own `op_id` is written into the freshly-emptied + `_applied_ops`, so a re-delivered `reset_doc` is a no-op, not a second wipe. +* Batchable: **no**, for the same reason as `clear` — rejected with + `workflow_reset_doc_not_batchable`. +* Never emitted implicitly: no `--emit-ops` surface and no bulk writer (§8.8) + mints one. It exists only where a caller asked for it by name. ## 2. Idempotency and identity @@ -390,6 +403,43 @@ Current contract, pinned: to sibling instances, definition garbage collection) is owed before this document's v1.1, together with the FE stable-ID reconciliation (section 6). +### 8.8 Bulk writers emit ops, they do not re-seed + +A **bulk writer** is any command that replaces the working file wholesale rather +than editing it: `comfy templates fetch -o ` today, `workflow get -o` +next. Downstream, such a replacement used to become a new document — the +consumer re-minted a snapshot from the new file. §8.6 forbids exactly that for a +replica, and even for the store owner it throws away the attributed history the +op log exists to keep. + +`workflow_ops.replace_ops(old, new)` is the alternative, and `templates fetch +--emit-ops` is its first caller. The rules: + +* **Shape**: `delete_node` for every node in `old` (in order), then `add_node` + for every node in `new`, then `connect` for every link. No `set_widget` ops — + widget values ride inside the `add_node` payload, which §8.5 makes + authoritative. +* **Identity is re-minted, never inherited.** Template graphs are numbered from + small frontend counters; replaying those ids into a live document reuses + identities a concurrent replica may still hold (§1.5's resurrection hazard). + Every node and link gets a fresh `mint_id` and every interior reference + (`inputs[].link`, `outputs[].links`, the `links` tuples) is remapped onto it. +* **Dual shape.** Each emitted entry is a fully minted op (envelope + the kind's + minted fields) AND carries that kind's spec keys (`class_type`/`at`/`as`, + `from`/`to`, `node`). The same array therefore replays through `apply_op` + losslessly and is accepted verbatim by `apply_specs`. The two are not + equivalent: `apply_specs` re-mints each node from the live catalog, so it + reproduces the structure (classes + wiring) while the op path reproduces the + graph exactly, widget values included. +* **All or nothing.** A graph the vocabulary cannot express — a subgraph + definition, a canvas group, a reroute point, a malformed node or link — emits + **no ops at all** (`NotExpressibleError`, surfaced as `ops_skipped`), never a + partial batch. A partial batch applies cleanly and leaves a document that is + not the graph the caller asked for; the consumer is expected to keep its + whole-document fallback for these cases. +* **`reset_doc` is never part of a bulk batch** (§1.6). Replacing a canvas is + expressed as deletes + adds, which merge; a history barrier does not. + ## 9. Amendments * Post-freeze changes require a **versioned amendment section** appended to @@ -401,3 +451,26 @@ Current contract, pinned: * Adding, removing, or re-scoping an op kind requires updating `FROZEN_OPS` / `DEFERRED_OPS` / `BATCHABLE_OPS`, the dispatch tables, and this document in one commit — `tests/comfy_cli/test_op_vocabulary_contract.py` fails otherwise. + +## 10. Amendment v1.1 — 2026-08-12 (V1-038 / BE-7171) + +**`reset_doc` is un-deferred.** `DEFERRED_OPS` is now empty; `apply_op` +dispatches `reset_doc` and `apply_specs` rejects it as standalone-only with its +own registered code. §1.6 is rewritten from "semantics when implemented" to the +implemented contract, and the frozen table's standalone-command cell names +`comfy workflow reset-doc --confirm` instead of "(deferred)". No frozen kind was +added, removed, or re-scoped: `reset_doc` was already in `FROZEN_OPS` and +already `Batchable = no`. + +*Why now*: the bulk-writers ticket needed a real, guarded "start this document +over" primitive so that "replace the canvas" and "erase the document" stopped +being the same operation. They are now distinct: §8.8's bulk batch replaces the +canvas with merging deletes+adds, and `reset_doc` is the explicit, confirmed +barrier a caller asks for by name. + +**§8.8 is new** and normative for bulk writers (`replace_ops`, +`templates fetch --emit-ops`). It adds no op kind — it constrains how existing +kinds are minted for a whole-file replacement. + +**No change to §§2-7, 8.1-8.7.** Stamping, LWW, abort-remainder, aliases and +replication semantics are untouched. diff --git a/tests/comfy_cli/command/test_templates_fetch_emit_ops.py b/tests/comfy_cli/command/test_templates_fetch_emit_ops.py new file mode 100644 index 000000000..13ad8860c --- /dev/null +++ b/tests/comfy_cli/command/test_templates_fetch_emit_ops.py @@ -0,0 +1,285 @@ +"""``comfy templates fetch --emit-ops`` (V1-038 / BE-7171). + +``templates fetch -o workflow.json`` is a BULK WRITER: it replaces the working +file wholesale. Downstream (the cloud agent's document) that replacement had to +be expressed as a **re-mint** — a brand-new document with no attributed, +incremental history, and §8.6's "one common initial snapshot" rule makes an +independent re-seed the one thing a replica must never do. + +``--emit-ops`` closes that: the fetch also emits ``data.ops`` — the stamped op +batch that turns the file it is replacing INTO the template, in the frozen +vocabulary. Two contracts, both tested here: + +* **the op contract** (what the cloud forwards): replaying the batch with + ``apply_op`` reproduces the template's graph exactly — same node types, same + wiring, same widget values; +* **the spec contract** (what ``nodes path --emit-ops`` already promises): the + same array is accepted by ``apply_specs`` verbatim, so the batch is a legal + ``comfy workflow apply --ops`` input. + +Templates the frozen vocabulary cannot express (subgraph definitions, groups) +emit NO ops and say why, so the consumer falls back to its re-mint path rather +than silently landing a partial graph. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli import workflow_ops +from comfy_cli.caller import Caller +from comfy_cli.command import templates as templates_cmd +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import OutputMode, Renderer, reset_renderer_for_testing, set_renderer + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _object_info() -> dict[str, Any]: + return { + "TinyLoader": { + "input": {"required": {"ckpt_name": [["a.safetensors", "b.safetensors"]]}}, + "input_order": {"required": ["ckpt_name"]}, + "output": ["MODEL"], + "output_name": ["MODEL"], + "category": "loaders", + "display_name": "Tiny Loader", + "python_module": "nodes", + }, + "TinySink": { + "input": {"required": {"model": ["MODEL"]}}, + "input_order": {"required": ["model"]}, + "output": [], + "output_name": [], + "category": "test", + "display_name": "Tiny Sink", + "python_module": "nodes", + }, + } + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +# A two-node template in frontend/save format: loader -> sink, one link. +def _template() -> dict[str, Any]: + return { + "id": "tpl-1", + "revision": 0, + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 1, + "type": "TinyLoader", + "pos": [10, 20], + "inputs": [], + "outputs": [{"name": "MODEL", "type": "MODEL", "links": [1]}], + "widgets_values": ["b.safetensors"], + }, + { + "id": 2, + "type": "TinySink", + "pos": [300, 20], + "inputs": [{"name": "model", "type": "MODEL", "link": 1}], + "outputs": [], + "widgets_values": [], + }, + ], + "links": [[1, 1, 0, 2, 0, "MODEL"]], + "groups": [], + } + + +def _existing() -> dict[str, Any]: + """A workflow already on the canvas — what the fetch replaces.""" + return { + "id": "wf-old", + "nodes": [ + { + "id": 77, + "type": "TinyLoader", + "pos": [0, 0], + "inputs": [], + "outputs": [{"name": "MODEL", "type": "MODEL", "links": []}], + "widgets_values": ["a.safetensors"], + } + ], + "links": [], + "last_node_id": 77, + "last_link_id": 0, + } + + +_GALLERY_ROW = { + "name": "tiny_template", + "title": "Tiny Template", + "output_type": "image", + "category": "Basics", + "tags": [], + "models": [], + "providers": [], +} + + +@pytest.fixture +def patched_fetch(monkeypatch: pytest.MonkeyPatch): + """Resolve the gallery + the workflow body locally: no network.""" + monkeypatch.setattr(templates_cmd, "_load_gallery", lambda *a, **kw: [{"templates": []}]) + monkeypatch.setattr(templates_cmd, "_flatten_templates", lambda cats: [dict(_GALLERY_ROW)]) + monkeypatch.setattr( + templates_cmd, + "_fetch_template_workflow", + lambda name, **kw: json.dumps(_template()).encode("utf-8"), + ) + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(templates_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + for line in reversed(captured.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +class TestTemplateFetchEmitsOpBatch: + def test_template_fetch_emits_op_batch(self, tmp_path: Path, patched_fetch, capsys): + """The batch is stamped, ordered delete→add→connect, and replaying it + onto the file being replaced reproduces the template exactly.""" + out = tmp_path / "workflow_bulkops.json" + base = _existing() + out.write_text(json.dumps(base), encoding="utf-8") + + env = _run( + ["fetch", "tiny_template", "-o", str(out), "--emit-ops", "--actor", "agent:th_1:7", "--base-version", "4"], + capsys, + ) + + assert env["ok"] is True, env + ops = env["data"]["ops"] + + # Every entry is a real, stamped op — the cloud drops anything without + # an op_id, so an unstamped entry is silently lost, not rejected. + for op in ops: + assert len(op["op_id"]) == 32 and op["op_id"].islower() + assert op["actor"] == "agent:th_1:7" + assert op["base_version"] == 4 + assert op["stamp"] == [4, "agent:th_1:7"] + assert len({op["op_id"] for op in ops}) == len(ops) + + # Replace = delete what was there, then build the template. + assert [op["op"] for op in ops] == ["delete_node", "add_node", "add_node", "connect"] + assert ops[0]["node_id"] == 77 + + # THE OP CONTRACT: replay onto the pre-fetch graph == the template. + replayed: dict[str, Any] = json.loads(json.dumps(base)) + for op in ops: + replayed = workflow_ops.apply_op(replayed, op, _graph()) + workflow_ops.strip_internal(replayed) + + assert [n["type"] for n in replayed["nodes"]] == ["TinyLoader", "TinySink"] + # Widget values ride inside the add_node payload (§8.5), so a fetched + # template keeps its demo values instead of catalog defaults. + loader = next(n for n in replayed["nodes"] if n["type"] == "TinyLoader") + assert loader["widgets_values"] == ["b.safetensors"] + assert loader["pos"] == [10, 20] + assert len(replayed["links"]) == 1 + link = replayed["links"][0] + sink = next(n for n in replayed["nodes"] if n["type"] == "TinySink") + assert link[1] == loader["id"] and link[3] == sink["id"] + assert sink["inputs"][0]["link"] == link[0] + + # Identity is minted, never inherited: the template's small counter ids + # (1, 2) would resurrect ids a concurrent replica may still hold. + assert all(n["id"] >= 1 << 40 for n in replayed["nodes"]) + assert link[0] >= 1 << 40 + + # The file the fetch wrote is still the template itself (unchanged + # behavior — --emit-ops adds a payload, it does not change the write). + assert [n["id"] for n in json.loads(out.read_text(encoding="utf-8"))["nodes"]] == [1, 2] + + def test_emitted_batch_round_trips_through_apply_specs(self, tmp_path: Path, patched_fetch, capsys): + """THE SPEC CONTRACT: the same array is a legal `workflow apply --ops` + batch — apply_specs accepts it verbatim and rebuilds the structure.""" + out = tmp_path / "workflow_bulkspecs.json" + base = _existing() + out.write_text(json.dumps(base), encoding="utf-8") + + env = _run(["fetch", "tiny_template", "-o", str(out), "--emit-ops"], capsys) + specs = env["data"]["ops"] + + wf, ops, aliases = workflow_ops.apply_specs(json.loads(json.dumps(base)), _graph(), specs) + + assert [n["type"] for n in wf["nodes"]] == ["TinyLoader", "TinySink"] + assert 77 not in [n["id"] for n in wf["nodes"]] + assert len(wf["links"]) == 1 + assert wf["links"][0][1] == aliases[specs[1]["as"]] + assert wf["links"][0][3] == aliases[specs[2]["as"]] + + def test_without_the_flag_the_envelope_is_unchanged(self, tmp_path: Path, patched_fetch, capsys): + out = tmp_path / "workflow_noops.json" + out.write_text(json.dumps(_existing()), encoding="utf-8") + env = _run(["fetch", "tiny_template", "-o", str(out)], capsys) + assert env["ok"] is True + assert "ops" not in env["data"] + assert "ops_skipped" not in env["data"] + + def test_emit_ops_on_a_fresh_canvas_has_no_deletes(self, tmp_path: Path, patched_fetch, capsys): + out = tmp_path / "does_not_exist_yet.json" + env = _run(["fetch", "tiny_template", "-o", str(out), "--emit-ops"], capsys) + assert [op["op"] for op in env["data"]["ops"]] == ["add_node", "add_node", "connect"] + + def test_inexpressible_template_emits_no_ops_and_says_why( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patched_fetch, capsys + ): + """A template the frozen vocabulary cannot express (a subgraph + definition) must emit NOTHING — a partial batch would land a graph that + is not the template. The consumer keeps its re-mint fallback for these.""" + tpl = _template() + tpl["definitions"] = {"subgraphs": [{"id": "sg-1", "nodes": []}]} + monkeypatch.setattr( + templates_cmd, "_fetch_template_workflow", lambda name, **kw: json.dumps(tpl).encode("utf-8") + ) + out = tmp_path / "workflow_subgraph.json" + env = _run(["fetch", "tiny_template", "-o", str(out), "--emit-ops"], capsys) + + assert env["ok"] is True + assert "ops" not in env["data"] + assert "subgraph" in env["data"]["ops_skipped"] + + def test_emit_ops_without_out_still_emits(self, patched_fetch, capsys): + """Without -o there is no file being replaced, so the batch is a pure + build — still emitted, so a caller that materializes the envelope's + workflow itself can use the ops.""" + env = _run(["fetch", "tiny_template", "--emit-ops"], capsys) + assert [op["op"] for op in env["data"]["ops"]] == ["add_node", "add_node", "connect"] diff --git a/tests/comfy_cli/test_reset_doc_op.py b/tests/comfy_cli/test_reset_doc_op.py new file mode 100644 index 000000000..d4ebc7196 --- /dev/null +++ b/tests/comfy_cli/test_reset_doc_op.py @@ -0,0 +1,194 @@ +"""``reset_doc`` — the guarded, standalone-only document reset (V1-038 / BE-7171). + +``reset_doc`` was frozen in ``docs/op-vocabulary-v1.md`` §1.6 but left deferred: +``apply_op`` rejected it and ``DEFERRED_OPS`` pinned that rejection. This ticket +un-defers it, so the guarantees that make it safe move from prose into tests: + +* it is **guarded** — ``comfy workflow reset-doc `` fails closed without an + explicit ``--confirm`` and writes nothing; +* it is **standalone-only** — a batch containing it is rejected atomically with a + registered error code, exactly like ``clear``; +* it is a **history barrier** — unlike ``clear`` it drops the id high-water marks + and the applied-op bookkeeping, so it is not merely "delete every node". +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from comfy_cli import error_codes, workflow_ops +from comfy_cli.caller import Caller +from comfy_cli.command import workflow as workflow_cmd +from comfy_cli.command import workflow_edit +from comfy_cli.cql.engine import Graph +from comfy_cli.output.renderer import OutputMode, Renderer, reset_renderer_for_testing, set_renderer + + +@pytest.fixture(autouse=True) +def reset_singleton(): + reset_renderer_for_testing() + yield + reset_renderer_for_testing() + + +def _force_json_renderer(): + r = Renderer.resolve( + is_stdout_tty=False, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + json_flag=True, + ) + r.mode = OutputMode.JSON + set_renderer(r) + return r + + +def _object_info() -> dict[str, Any]: + return { + "TinyLoader": { + "input": {"required": {"ckpt_name": [["a.safetensors"]]}}, + "input_order": {"required": ["ckpt_name"]}, + "output": ["MODEL"], + "output_name": ["MODEL"], + "category": "loaders", + "display_name": "Tiny Loader", + "python_module": "nodes", + }, + } + + +def _graph() -> Graph: + return Graph.from_object_info(_object_info()) + + +def _populated() -> dict[str, Any]: + return { + "id": "wf-1", + "revision": 0, + "nodes": [ + {"id": 1, "type": "TinyLoader", "pos": [0, 0], "inputs": [], "outputs": [], "widgets_values": []}, + {"id": 2, "type": "TinyLoader", "pos": [10, 0], "inputs": [], "outputs": [], "widgets_values": []}, + ], + "links": [], + "groups": [{"title": "g"}], + "last_node_id": 2, + "last_link_id": 0, + "_applied_ops": ["deadbeef" * 4], + } + + +def _run(args: list[str], capsys) -> dict[str, Any]: + _force_json_renderer() + runner = CliRunner() + result = runner.invoke(workflow_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr().out + if not captured.strip(): + captured = result.stdout or "" + for line in reversed(captured.strip().splitlines()): + try: + return json.loads(line) + except json.JSONDecodeError: + continue + raise AssertionError(f"no JSON envelope (rc={result.exit_code}, exc={result.exception}, out={captured[:600]})") + + +class TestResetDocGuard: + def test_reset_doc_requires_confirm(self, tmp_path: Path, capsys): + """Without --confirm the command fails closed: nothing is written. + + The guard is the whole reason reset_doc is safe to expose — it erases + replay history, so an accidental invocation is unrecoverable by replay. + """ + wf = tmp_path / "wf_reset_guard.json" + before = _populated() + wf.write_text(json.dumps(before), encoding="utf-8") + + env = _run(["reset-doc", str(wf)], capsys) + + assert env["ok"] is False + assert env["error"]["code"] == "workflow_reset_doc_unconfirmed" + assert "--confirm" in (env["error"].get("hint") or "") + # The file is byte-for-byte untouched — a guard that writes anything is + # not a guard. + assert json.loads(wf.read_text(encoding="utf-8")) == before + + def test_reset_doc_with_confirm_empties_the_document(self, tmp_path: Path, capsys): + wf = tmp_path / "wf_reset_confirm.json" + wf.write_text(json.dumps(_populated()), encoding="utf-8") + + env = _run(["reset-doc", str(wf), "--confirm"], capsys) + + assert env["ok"] is True, env + op = env["data"]["op"] + assert op["op"] == "reset_doc" + assert op["removed_nodes"] == [1, 2] + assert op["stamp"] == [op["base_version"], op["actor"]] + + after = json.loads(wf.read_text(encoding="utf-8")) + assert after["nodes"] == [] + assert after["links"] == [] + assert after["groups"] == [] + # History barrier, not a clear: the high-water marks go back to the + # empty baseline (clear preserves them, §1.5 vs §1.6). + assert after["last_node_id"] == 0 + assert after["last_link_id"] == 0 + + +class TestResetDocIsNotBatchable: + def test_reset_doc_rejected_in_batch(self): + """A batch containing reset_doc is rejected atomically, with its own + registered code naming the standalone command.""" + with pytest.raises(workflow_ops.NotBatchableError) as ei: + workflow_ops.apply_specs( + {"nodes": [], "links": []}, + _graph(), + [{"op": "add_node", "class_type": "TinyLoader"}, {"op": "reset_doc"}], + ) + err = ei.value + assert err.code == "workflow_reset_doc_not_batchable" + assert error_codes.is_registered(err.code) + registered = error_codes.get(err.code) + assert registered is not None and "comfy workflow reset-doc" in (registered.hint or "") + assert "comfy workflow reset-doc" in err.hint + assert "no changes were applied" in str(err).lower() + + def test_reset_doc_rejected_through_the_apply_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys + ): + monkeypatch.setattr(workflow_edit, "_get_graph", lambda *a, **kw: _graph()) + wf = tmp_path / "wf_reset_batch.json" + wf.write_text(json.dumps(_populated()), encoding="utf-8") + ops = tmp_path / "reset_batch_ops.json" + ops.write_text(json.dumps([{"op": "reset_doc"}]), encoding="utf-8") + + env = _run(["apply", str(wf), "--ops", str(ops)], capsys) + + assert env["ok"] is False + assert env["error"]["code"] == "workflow_reset_doc_not_batchable" + # Atomic: the graph the batch was rejected against is untouched. + assert len(json.loads(wf.read_text(encoding="utf-8"))["nodes"]) == 2 + + +class TestResetDocReplay: + def test_apply_op_replays_reset_doc_and_records_it(self): + """Un-deferred: apply_op dispatches reset_doc. Its own op_id survives + the wipe, so a re-delivered reset is a no-op rather than a second wipe.""" + wf, op = workflow_ops.reset_doc(_populated(), actor="agent:t:1", base_version=3) + assert wf["nodes"] == [] and wf["links"] == [] + assert wf["_applied_ops"] == [op["op_id"]] + + # Idempotent re-delivery: put a node back, replay the same op — the + # op_id gate drops it, so the node survives. + wf["nodes"].append({"id": 9, "type": "TinyLoader"}) + wf = workflow_ops.apply_op(wf, op, None) + assert [n["id"] for n in wf["nodes"]] == [9] + + def test_reset_doc_is_no_longer_deferred(self): + assert "reset_doc" in workflow_ops.FROZEN_OPS + assert "reset_doc" not in workflow_ops.DEFERRED_OPS + assert "reset_doc" not in workflow_ops.BATCHABLE_OPS From a534630fda3b11acb49481dbc479aaed46fe2b39 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 12 Aug 2026 22:39:02 -0700 Subject: [PATCH 46/53] =?UTF-8?q?fix(ops):=20make=20concurrent=20connects?= =?UTF-8?q?=20converge=20=E2=80=94=20concrete=20inputs=20are=20LWW=20regis?= =?UTF-8?q?ters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial testing against the TypeScript port of this applier (cloud PR #6722, FINDING 1) found that only set_widget-family writes passed through _lww_gate. A connect onto an occupied concrete input displaced the occupant by ARRIVAL ORDER, and composed with delete-wins that escalated from "a different link id" to "a link that exists in one interleaving and not the other": A: [add_node 400, connect 400 -> 200.positive] B: [connect 300 -> 200.positive, delete_node 300] A-then-B left 200.positive EMPTY; B-then-A left link 9003 in place. Both are legal interleavings of two writers who each kept their own causal order. apply_op and the port behaved identically, so this was a gap in the op contract rather than a port bug. The rule (amendment v1.2, §11.1): the occupant of a CONCRETE input slot is a scalar target ("input", to_node, to_slot) under the same [base_version, actor, op_id] comparison set_widget already uses. The winner retires the prior occupant with _remove_link; the loser is dropped whole — no link tuple, no out-link entry — while still consuming its op_id. Claiming the register happens before the source endpoint is resolved, so a winning connect whose source was concurrently deleted still clears the input: "delete wins" means the new link does not appear, not that the previous link is preserved. Autogrow keeps its non-clobbering, ungated behaviour by explicit carve-out. Second finding, same class (adversarial PR #6725, §11.2): _write_target built its key from the raw node id while every lookup resolved ids as strings, so an op carrying 7 and one carrying "7" addressed one node through two registers and converged by arrival order. Node ids are legitimately either JSON type. Targets now normalize with str(), and the apply path resolves nodes by string id (_find_by_str) so the key and the node it names cannot disagree. tests/comfy_cli/test_connect_lww.py mirrors comfy-multi-player's test/connect-lww.test.ts op-for-op, including the exact repro, every order-preserving interleaving, 12 generated two-writer streams, and pinning tests for the three gaps v1.2 deliberately does NOT close (out-links ordering, autogrow racing a source delete, add_node id collisions). Co-Authored-By: Claude Fable 5 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- comfy_cli/workflow_ops.py | 97 ++++- docs/op-vocabulary-v1.md | 145 ++++++- tests/comfy_cli/test_connect_lww.py | 620 ++++++++++++++++++++++++++++ 3 files changed, 835 insertions(+), 27 deletions(-) create mode 100644 tests/comfy_cli/test_connect_lww.py diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index d7ed9485b..9095b0054 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -346,9 +346,16 @@ def _stamp_key(op: dict) -> list: def _lww_gate(workflow: dict, op: dict) -> bool: - """True iff this ``set_widget`` should apply under last-writer-wins. A write - to a target already claimed by a higher-or-equal stamp is dropped, making the - surviving value independent of apply order.""" + """True iff this op's write should apply under last-writer-wins. A write to a + target already claimed by a higher-or-equal stamp is dropped, making the + surviving value independent of apply order. + + Gated targets (``_write_target``): ``set_widget``'s ``("widget", …)``, the + connect-embedded ``inputcount`` bump that shares a connect's stamp (§8.4), + and — since amendment v1.2 — a concrete connect's ``("input", to_node, + to_slot)``. The register store is still spelled ``_widget_stamps`` for + on-the-wire compatibility with documents written before v1.2; it holds every + gated target, not just widgets.""" prior = workflow.get("_widget_stamps", {}).get(json.dumps(_write_target(op), default=str)) return prior is None or _stamp_key(op) > list(prior) @@ -1425,11 +1432,17 @@ def apply_op(workflow: dict, op: dict, graph) -> dict: def _apply_add_node(workflow: dict, op: dict) -> None: + # Node identity is compared as a STRING everywhere in the apply path + # (amendment v1.2): ids are legitimately either JSON type, and an exact + # ``==`` made ``7`` and ``"7"`` two different nodes. nodes = workflow.setdefault("nodes", []) - if any(n.get("id") == op["node_id"] for n in nodes): + if any(str(n.get("id")) == str(op["node_id"]) for n in nodes): return nodes.append(copy.deepcopy(op["node"])) - workflow["last_node_id"] = max(workflow.get("last_node_id") or 0, op["node_id"]) + # last_node_id is a max-register over INT ids only; a string id (subgraph + # address, historical workflow) is not comparable and never bumps it. + if isinstance(op["node_id"], int) and not isinstance(op["node_id"], bool): + workflow["last_node_id"] = max(workflow.get("last_node_id") or 0, op["node_id"]) def _apply_set_widget(workflow: dict, op: dict, graph) -> None: @@ -1453,7 +1466,7 @@ def _apply_set_widget(workflow: dict, op: dict, graph) -> None: _engine._write_widget(target, op["inner_widget"], op["value"], graph, extend=False) _lww_commit(workflow, op) return - node = _find(workflow, op["node_id"]) + node = _find_by_str(workflow, op["node_id"]) if node is None: return # target concurrently deleted => no-op (delete wins). from comfy_cli.cql import engine as _engine @@ -1499,15 +1512,21 @@ def _apply_inputcount_bump(workflow: dict, dst: dict, op: dict, graph, widget: s def _apply_connect(workflow: dict, op: dict, graph) -> None: - # Totality: either endpoint concurrently deleted => no-op (delete wins), so a - # merge consumer can replay a connect and a delete in either order without a - # crash or a dangling link. Resolve both before mutating anything. - dst = _find(workflow, op["to_node"]) - src = _find(workflow, op["from_node"]) - if dst is None or src is None: + # Totality: an endpoint concurrently deleted => no crash and no dangling + # link, so a merge consumer can replay a connect and a delete in either + # order. Resolve the destination before mutating anything; if it is gone the + # target slot does not exist and never will (ids are never reused), so there + # is no register to claim and delete simply wins. + dst = _find_by_str(workflow, op["to_node"]) + if dst is None: return grow = op.get("grow") if grow is not None: + # Autogrow is NOT a shared register: every grow mints its own slot keyed + # by ``grow_id``, so two concurrent grows onto one base both survive and + # there is nothing to gate (§1.2 / amendment v1.2's carve-out). + if _find_by_str(workflow, op["from_node"]) is None: + return # Autogrow: grow a concrete slot and wire it. Keyed by ``grow_id`` (the # link id) so replay is idempotent AND non-clobbering — a concurrent # autogrow that minted the same requested name gets its own fresh slot @@ -1557,11 +1576,32 @@ def _apply_connect(workflow: dict, op: dict, graph) -> None: _apply_inputcount_bump(workflow, dst, op, graph, inputcount["widget"], inputcount["value"]) else: to_idx = op["to_slot"] - # A concrete input holds at most one link. Replacing it must fully retire - # the old link (drop the tuple + scrub the old source's out-links). + # --- The concrete-input LWW register (op-vocabulary-v1.md amendment v1.2) + # + # A concrete input holds at most one link, so "who occupies this slot" is + # a SCALAR target — ``("input", to_node, to_slot)`` — resolved by exactly + # the ``_lww_gate``/``_lww_commit`` pair ``set_widget`` uses. Without the + # gate the occupant was decided by ARRIVAL ORDER, and composed with + # delete-wins that produced graphs where a link exists in one + # interleaving and not in another (found adversarially against the + # TypeScript port: cloud PR #6722, FINDING 1). + if not _lww_gate(workflow, op): + return + # Claiming the register is UNCONDITIONAL once the gate passes: the prior + # occupant is retired even if this op then turns out to be a delete-wins + # no-op below. Deferring the retirement until the link is known to be + # installable would reintroduce order dependence — whether the incumbent + # survives would depend on whether the concurrent delete of THIS op's + # source had arrived yet. + _lww_commit(workflow, op) prev = dst["inputs"][to_idx].get("link") if prev is not None and prev != op["link_id"]: _remove_link(workflow, prev) + # Source concurrently deleted => the winning connect leaves the input EMPTY + # (delete wins over the link, not over the register claim). + src = _find_by_str(workflow, op["from_node"]) + if src is None: + return link = [op["link_id"], op["from_node"], op["from_slot"], op["to_node"], to_idx, op["link_type"]] links = workflow.setdefault("links", []) if not any(ln[0] == op["link_id"] for ln in links): @@ -1593,10 +1633,14 @@ def _remove_link(workflow: dict, link_id: Any) -> None: def _apply_delete_node(workflow: dict, op: dict) -> None: - node_id = op["node_id"] - workflow["nodes"] = [n for n in workflow.get("nodes") or [] if n.get("id") != node_id] + node_id = str(op["node_id"]) # node identity is compared as a string (amendment v1.2) + workflow["nodes"] = [n for n in workflow.get("nodes") or [] if str(n.get("id")) != node_id] removed = set(op.get("removed_links") or []) - kept = [ln for ln in workflow.get("links") or [] if ln[0] not in removed and ln[1] != node_id and ln[3] != node_id] + kept = [ + ln + for ln in workflow.get("links") or [] + if ln[0] not in removed and str(ln[1]) != node_id and str(ln[3]) != node_id + ] workflow["links"] = kept kept_ids = {ln[0] for ln in kept} # Scrub dangling references so no input/output points at a gone link. @@ -1647,6 +1691,17 @@ def _apply_reset_doc(workflow: dict, op: dict) -> None: def _write_target(op: dict) -> tuple: + """The conflict/write target of an op — the LWW register it claims. + + NODE IDS ARE NORMALIZED WITH ``str()`` (amendment v1.2). Node ids are + legitimately either JSON type — historical workflows carry string ids and + subgraph addresses are strings like ``"57:3"`` — while every lookup path + resolves them as strings (``_find_by_str``). Building the target from the + raw value gave ``7`` and ``"7"`` two different registers for one node, so + ``_lww_gate`` never compared them and the pair converged by apply order + (adversarial finding, comfy-multi-player PR #6725). Interior writes already + normalized their path; every case now matches. + """ kind = op["op"] if kind == "set_widget": # Subgraph writes target the resolved interior path so the flat promoted @@ -1654,17 +1709,17 @@ def _write_target(op: dict) -> tuple: # same interior widget share one write target (converge, not clobber). if op.get("path"): return ("widget", tuple(str(s) for s in op["path"]), op["inner_widget"]) - return ("widget", op["node_id"], op["widget"]) + return ("widget", str(op["node_id"]), op["widget"]) if kind in ("add_node", "delete_node"): - return ("node", op["node_id"]) + return ("node", str(op["node_id"])) if kind == "connect": grow = op.get("grow") if grow is not None: # Two autogrow connects onto the same base share a target (their # relative order in the batch is the sequence decision the merge # consumer must make); distinct bases don't collide. - return ("input", op["to_node"], "grow", str(grow["name"]).split(".", 1)[0]) - return ("input", op["to_node"], op["to_slot"]) + return ("input", str(op["to_node"]), "grow", str(grow["name"]).split(".", 1)[0]) + return ("input", str(op["to_node"]), op["to_slot"]) return (kind,) diff --git a/docs/op-vocabulary-v1.md b/docs/op-vocabulary-v1.md index 9af9facdf..1a487a047 100644 --- a/docs/op-vocabulary-v1.md +++ b/docs/op-vocabulary-v1.md @@ -84,11 +84,14 @@ and optionally `grow` (autogrow slot descriptor: `{name, type, widget?, inputcou * Idempotency: `op_id` no-op; a link tuple with an already-present `link_id` is not appended twice. -* Conflict: a concrete input holds at most one link — a connect to an occupied - input replaces it and fully retires the prior link (`_remove_link`). Two - concurrent connects to the same concrete input are an update-vs-update - conflict on that input (section 3). Autogrow connects are non-clobbering: - each grows a fresh slot keyed by `grow_id` (the link id), so both survive. +* Conflict: a concrete input holds at most one link, so its occupant is a + **scalar LWW register** on the target `("input", to_node, to_slot)`, resolved + by `_stamp_key`/`_lww_gate` exactly like a `set_widget` (section 3, and + amendment v1.2 for the full rule). The winning connect retires the prior + occupant with `_remove_link`; the losing connect is dropped whole — no link + tuple, no out-link entry. Autogrow connects are non-clobbering and therefore + **not** gated: each grows a fresh slot keyed by `grow_id` (the link id), so + both survive. * Invalid: type-mismatched slots are rejected at mint time; a link cannot cross a subgraph boundary (rejected with the boundary explanation). @@ -196,10 +199,17 @@ for its target. Higher `base_version` wins; ties break by `actor`, then by the unique `op_id` — so no two distinct ops ever compare equal, the order is total, and the surviving value is independent of apply order. +Gated targets: the `set_widget` rows, the connect-embedded `inputcount` bump +(8.4), and — since amendment v1.2 — a concrete `connect`'s +`("input", to_node, to_slot)`. **Node ids in a target are compared as strings** +(v1.2): ids are legitimately either JSON type, and comparing them raw gave `7` +and `"7"` two registers for one node. + | Scenario | Ruling | Where in code | |----------|--------|---------------| | update vs update (same widget) | LWW on `stamp` with `op_id` tiebreak; loser dropped | `_lww_gate` / `_stamp_key` | -| update vs delete | **delete wins**: `set_widget` to a deleted node is a no-op; `connect` with either endpoint deleted is a no-op; replay never raises on a since-removed target | `_apply_set_widget` (missing node → return), `_apply_connect` (missing endpoint → return) | +| **concurrent `connect` to the same concrete input** | LWW on `stamp` with `op_id` tiebreak, target `("input", to_node, to_slot)`; the loser is dropped whole (no link tuple, no out-link entry) and the winner retires the prior occupant. Amendment v1.2 — previously **undefined** and decided by arrival order | `_apply_connect` (concrete branch) / `_lww_gate` | +| update vs delete | **delete wins**: `set_widget` to a deleted node is a no-op; a `connect` whose destination is gone is a no-op; a `connect` whose SOURCE is gone still claims its input register and leaves that input empty (v1.2 — otherwise the incumbent's survival depends on when the delete arrives); replay never raises on a since-removed target | `_apply_set_widget` (missing node → return), `_apply_connect` (missing endpoint → return) | | concurrent moves | no `move` op exists in v1 — positions are decided once at `add_node` mint time and frozen into the op; live position editing is frontend view state, out of scope until the FE stable-ID reconciliation (section 6) | `add_node` / `layout.cascade_pos` | | edges referencing deleted nodes | the connect no-ops (delete wins); a delete removes incident links and scrubs every dangling input/output reference, so no dangling edge survives either order | `_apply_connect`, `_apply_delete_node` | | duplicate entity creation | impossible by construction across writers (random 53-bit `mint_id`, no shared counter); a replayed `add_node` whose `node_id` already exists is a no-op; a re-sent op is dropped by `op_id` | `mint_id`, `_apply_add_node` | @@ -474,3 +484,126 @@ kinds are minted for a whole-file replacement. **No change to §§2-7, 8.1-8.7.** Stamping, LWW, abort-remainder, aliases and replication semantics are untouched. + +## 11. Amendment v1.2 — 2026-08-12 (concrete-input contention; id-type identity) + +Two convergence rules that v1 left undefined, both found by **adversarial +testing against the TypeScript port of this applier** — not by review. The +Python `apply_op` and the port agreed with each other in every case below, +which is what made these contract gaps rather than port bugs. + +### 11.1 A concrete input is an LWW register + +**The rule.** The occupant of a CONCRETE input slot is a scalar target +`("input", to_node, to_slot)` under exactly the comparison of §3/§8.1 — +`[base_version, actor, op_id]`, numeric then code-point, `op_id` breaking +exact ties. `_apply_connect`'s concrete branch now runs `_lww_gate` / +`_lww_commit` around its write, the same pair `_apply_set_widget` uses. + +**The repro** (writer A and writer B, each keeping its own causal order): + +``` +A: [add_node 400, connect 400 -> 200.positive] +B: [connect 300 -> 200.positive, delete_node 300] +``` + +Before v1.2, order A-then-B left `200.positive` EMPTY (B's connect displaced +A's link by arrival, then B's delete retired B's link) while order B-then-A +left link 9003 in place. Same op set, two legal interleavings, two different +graphs: one user sees a wired sampler, the other an unwired one. + +**The displaced link.** A concrete input holds at most one link, so exactly one +link record survives per register: + +* the **winning** connect fully retires the prior occupant (`_remove_link`: + the link tuple plus the old source's out-link entry). The displaced link is + deleted, never orphaned and never re-parented to another slot. +* the **losing** connect is dropped WHOLE — no link tuple, no out-link entry, + no slot write. It still consumes its `op_id` (§2): a dropped write is a + protocol-level apply, exactly like a losing `set_widget`. + +**Composition with delete-wins.** Claiming the register is unconditional once +the gate passes, and it happens BEFORE the source endpoint is resolved: + +* destination node gone → the slot does not exist and never will (ids are + never reused), so there is no register and the op is a plain no-op; +* source node gone → the winning connect still claims the register and clears + the input. Deferring the retirement until the link is known to be + installable would reintroduce order dependence: whether the incumbent + survives would depend on whether the concurrent delete had arrived yet. + "Delete wins" therefore means *the new link does not appear*, not *the + previous link is preserved*. +* a stamp outlives the node it names, which is what makes the composed case + converge: a later, lower-stamped connect onto that input is still dropped. + +**Autogrow is explicitly NOT gated.** An autogrow connect grows a fresh slot +keyed by `grow_id`, so two concurrent autogrows onto one base never contend: +both survive (§1.2). Gating `("input", to_node, "grow", base)` would silently +discard one writer's connection. That target keeps its §3 role as conflict +*identity* for `detect_conflict`, not as a gate. + +**Alternatives considered and rejected.** + +1. *Allow multiple links on one concrete input and let the projection pick.* + Rejected: it breaks the graph invariant that a concrete input has at most + one link, and every downstream consumer (`convert_ui_to_api`, the executor, + the frontend) assumes it. It also just relocates the decision into a + projection rule that would itself have to be stamp-ordered. +2. *Reject the later connect (return an error to the second writer).* + Rejected: it breaks "nobody's work is rejected" — a merge consumer replays + ops that were already accepted from the writer's point of view, and §4's + abort-remainder would then discard the remainder of an innocent batch. LWW + drops a write silently and locally; rejection propagates. +3. *Order by receipt (status quo).* Rejected: that is the finding. + +**What v1.2 does NOT close** (filed, tested, unchanged): + +* `outputs[].links` is appended in arrival order, so two connects out of ONE + source into two DIFFERENT inputs record the same set in two different + sequences. No link is lost or invented; closing it means canonicalizing a + set-valued field in both implementations' projections. +* An autogrow connect racing a delete of its source leaves the grown slot + present in one order and absent in the other — the structural sibling of the + gap above, on a target that is deliberately not a register. +* Two `add_node` ops with the SAME `node_id` and different payloads resolve + first-writer-wins by arrival (`("node", node_id)` is reserved but ungated). + §1.1 rules this out by construction — `mint_id` draws 53-bit random ids — so + it is a property of hand-authored or replayed streams, not of minted ones. + +**Batch caveat, now stated.** `apply_specs` stamps every op in one batch with +the same `base_version`, so two writes to the SAME target inside one batch are +decided by the `op_id` tiebreak, not by spec order — "last spec wins" does not +hold. This has been true of `set_widget` since the freeze; v1.2 extends the +same property to `connect` and names it rather than leaving it implicit. + +### 11.2 Write targets compare node ids as strings + +`_write_target` built its key from the raw `node_id` / `to_node` while every +apply-path lookup resolves ids as strings. An op carrying `7` and one carrying +`"7"` therefore addressed the same node through two different registers: the +gate never compared them and the pair converged by arrival order. Node ids are +legitimately either JSON type — historical workflows carry string ids, and +subgraph-scoped addresses are strings like `"57:3"` (§6) — so this is legal +traffic, not malformed input. Interior `set_widget` targets already normalized +their path (`tuple(str(s) for s in path)`) and were unaffected. + +**The rule:** every node id in a write target is normalized with `str()`. +Equivalently: **node identity is compared as a string throughout the apply +path.** `_apply_add_node`, `_apply_set_widget`, `_apply_connect` and +`_apply_delete_node` now resolve nodes by string id (`_find_by_str`) so the +register key and the node it names can never disagree. `last_node_id` stays a +max-register over INT ids only — a string id is not comparable and never bumps +it (§8.3). + +This changes the BYTES of a stamp key (`["widget", 7, "steps"]` becomes +`["widget", "7", "steps"]`). Stamp maps are apply-time bookkeeping stripped +before serialization (`strip_internal`, §2), and the doc-side `__stamps` map +lives only inside a live document, so the change is not a data migration; a +document mid-flight across the upgrade loses prior stamp claims for +numerically-keyed targets and falls back to first-writer-wins for those +targets until the next write. Downstream repos that pin this document by SHA +must move the SHA and their applier pin together. + +**No change to §§2, 4-7, 8.1-8.8** beyond the §3 table row and the §1.2 +conflict bullet cited above. No op kind was added, removed, or re-scoped; +`FROZEN_OPS` / `DEFERRED_OPS` / `BATCHABLE_OPS` are untouched. diff --git a/tests/comfy_cli/test_connect_lww.py b/tests/comfy_cli/test_connect_lww.py new file mode 100644 index 000000000..dce639804 --- /dev/null +++ b/tests/comfy_cli/test_connect_lww.py @@ -0,0 +1,620 @@ +"""Concrete-input contention: ``connect`` is a stamp-gated LWW register. + +Amendment v1.2 of ``docs/op-vocabulary-v1.md``: the occupant of a CONCRETE +input slot is a scalar target ``("input", to_node, to_slot)`` resolved by the +same ``[base_version, actor, op_id]`` last-writer-wins comparison +(``_stamp_key`` / ``_lww_gate``) that already governs ``set_widget``. + +Before the amendment only ``set_widget``-family writes passed through +``_lww_gate``, so a connect onto an occupied input displaced the occupant by +ARRIVAL ORDER. Composed with delete-wins that escalated from "a different link +id" to "a link that exists in one interleaving and not in the other". Found by +adversarial testing against the TypeScript port of this applier (cloud +PR #6722, FINDING 1), not by review — ``_apply_connect`` and the port behaved +identically, which is what made it a contract gap rather than a port bug. + +The mirror of these tests lives in comfy-multi-player +``test/connect-lww.test.ts``; the two suites assert the same outcomes for the +same op sets so the CLI's local application agrees with the document's. +""" + +from __future__ import annotations + +import copy +import itertools +import random +from typing import Any + +import pytest + +from comfy_cli import workflow_ops as ops + +AGENT = "agent:th_8f2c:12" +HUMAN = "human:u_41ab:tab_2" + +SAMPLER = 200 +ENCODER = 300 +OTHER_ENCODER = 310 +FRESH = 400 +#: KSampler input index of ``positive`` — the contested concrete slot. +POSITIVE = 1 +NEGATIVE = 2 + + +# --------------------------------------------------------------------------- +# fixtures — hand-built graphs and ops, applied with graph=None (the connect +# path needs a catalog only for autogrow templates / inputcount) +# --------------------------------------------------------------------------- + + +def _encoder(node_id: int, text: str) -> dict[str, Any]: + return { + "id": node_id, + "type": "CLIPTextEncode", + "pos": [40, 60], + "inputs": [{"name": "clip", "type": "CLIP", "link": None}], + "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": []}], + "widgets_values": [text], + } + + +def _sampler() -> dict[str, Any]: + return { + "id": SAMPLER, + "type": "KSampler", + "pos": [360, 60], + "inputs": [ + {"name": "model", "type": "MODEL", "link": None}, + {"name": "positive", "type": "CONDITIONING", "link": None}, + {"name": "negative", "type": "CONDITIONING", "link": None}, + {"name": "latent_image", "type": "LATENT", "link": None}, + ], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": []}], + "widgets_values": [0, "fixed", 20, 8.0, "euler", "simple", 1.0], + } + + +def _base() -> dict[str, Any]: + """``200.positive`` is EMPTY — the FINDING's own base.""" + return { + "last_node_id": 400, + "last_link_id": 0, + "nodes": [_encoder(ENCODER, "the human's prompt"), _sampler()], + "links": [], + "groups": [], + } + + +def _wired_base() -> dict[str, Any]: + """``200.positive`` already holds link 9000 from node 310 — the displacement case.""" + incumbent = _encoder(OTHER_ENCODER, "the incumbent") + incumbent["outputs"][0]["links"] = [9000] + sampler = _sampler() + sampler["inputs"][POSITIVE]["link"] = 9000 + return { + "last_node_id": 400, + "last_link_id": 9000, + "nodes": [_encoder(ENCODER, "the human's prompt"), incumbent, sampler], + "links": [[9000, OTHER_ENCODER, 0, SAMPLER, POSITIVE, "CONDITIONING"]], + "groups": [], + } + + +def _op_id(tag: str) -> str: + """32 lowercase hex (§8.2) — load-bearing: it is the final LWW tiebreak.""" + return (tag + "0" * 32)[:32] + + +def _connect( + tag: str, + actor: str, + base_version: int, + link_id: int, + from_node: int, + to_node: int = SAMPLER, + to_slot: int = POSITIVE, +) -> dict[str, Any]: + return { + "op": "connect", + "op_id": _op_id(tag), + "actor": actor, + "base_version": base_version, + "stamp": [base_version, actor], + "link_id": link_id, + "from_node": from_node, + "from_slot": 0, + "to_node": to_node, + "to_slot": to_slot, + "link_type": "CONDITIONING", + } + + +def _add_encoder(tag: str, actor: str, base_version: int, node_id: int, text: str) -> dict[str, Any]: + return { + "op": "add_node", + "op_id": _op_id(tag), + "actor": actor, + "base_version": base_version, + "stamp": [base_version, actor], + "node_id": node_id, + "class_type": "CLIPTextEncode", + "pos": [40, 300], + "node": _encoder(node_id, text), + } + + +def _delete(tag: str, actor: str, base_version: int, node_id: int, removed: list[int]) -> dict[str, Any]: + return { + "op": "delete_node", + "op_id": _op_id(tag), + "actor": actor, + "base_version": base_version, + "stamp": [base_version, actor], + "node_id": node_id, + "removed_links": removed, + } + + +# --------------------------------------------------------------------------- +# permutation harness +# --------------------------------------------------------------------------- + + +def _interleavings(a: list[dict], b: list[dict]) -> list[list[dict]]: + """Every order-preserving interleaving of two causal sequences.""" + out: list[list[dict]] = [] + n, m = len(a), len(b) + for positions in itertools.combinations(range(n + m), n): + order: list[dict] = [] + ia = ib = 0 + for k in range(n + m): + if k in positions: + order.append(a[ia]) + ia += 1 + else: + order.append(b[ib]) + ib += 1 + out.append(order) + return out + + +def _comparable(workflow: dict) -> dict: + """``canonical`` plus a sort of every ``outputs[].links``. + + SEPARATE KNOWN GAP, deliberately not closed by amendment v1.2: an output + port's ``links`` list is appended in ARRIVAL ORDER, so two concurrent + connects out of one source node into two DIFFERENT inputs record the same + set in two different orders. No link is lost or invented; closing it means + canonicalizing a set-valued field in both implementations' projections, + which is its own contract change. ``test_known_gap_out_links_order`` keeps + it a tested fact; sorting here keeps the register tests measuring the + register. + """ + w = ops.canonical(workflow) + for node in w.get("nodes") or []: + for out in node.get("outputs") or []: + if isinstance(out.get("links"), list): + out["links"] = sorted(out["links"], key=str) + return w + + +def _run(base: dict, order: list[dict]) -> dict: + wf = copy.deepcopy(base) + for op in order: + wf = ops.apply_op(wf, op, None) + return wf + + +def _tags(order: list[dict]) -> str: + return ",".join(op["op_id"].rstrip("0") for op in order) + + +def _expect_convergent(base: dict, writer_a: list[dict], writer_b: list[dict]) -> dict: + """Assert every interleaving converges; return the agreed workflow.""" + orders = _interleavings(writer_a, writer_b) + assert len(orders) > 1 + want = None + agreed = None + for order in orders: + wf = _run(base, order) + got = _comparable(wf) + if want is None: + want, agreed = got, wf + continue + assert got == want, f"interleaving [{_tags(order)}] diverged" + return agreed + + +def _input_link(wf: dict, node_id: int = SAMPLER, slot: int = POSITIVE) -> Any: + node = next(n for n in wf["nodes"] if n["id"] == node_id) + return node["inputs"][slot]["link"] + + +def _link_ids(wf: dict) -> list[Any]: + return sorted(ln[0] for ln in wf.get("links") or []) + + +# --------------------------------------------------------------------------- +# 1. the FINDING's own repro +# --------------------------------------------------------------------------- + + +def _writer_a(base_version: int) -> list[dict]: + """Mint a fresh encoder, wire it into 200.positive.""" + return [ + _add_encoder("a1", AGENT, base_version, FRESH, "replacement"), + _connect("a2", AGENT, base_version, 9003, FRESH), + ] + + +def _writer_b(base_version: int) -> list[dict]: + """Wire the EXISTING encoder into the same input, then delete it.""" + return [ + _connect("b1", HUMAN, base_version, 9004, ENCODER), + _delete("b2", HUMAN, base_version, ENCODER, [9004]), + ] + + +def test_finding1_repro_agent_holds_the_register(): + """Order A-then-B used to leave ``positive`` empty while B-then-A left link + 9003 in place. With the register gate the agent's higher stamp owns the + input in all six interleavings and the human's link never lands.""" + wf = _expect_convergent(_base(), _writer_a(9), _writer_b(5)) + assert _input_link(wf) == 9003 + assert _link_ids(wf) == [9003] + assert 9004 not in _link_ids(wf) + + +def test_finding1_repro_human_holds_the_register(): + """The mirror polarity: the human's connect wins the register in every + order and its own delete then retires the winning link, so ``positive`` is + deterministically EMPTY and neither link survives.""" + wf = _expect_convergent(_base(), _writer_a(5), _writer_b(9)) + assert _input_link(wf) is None + assert _link_ids(wf) == [] + assert any(n["id"] == FRESH for n in wf["nodes"]) # the node still exists + + +def test_finding1_repro_tie_breaks_by_actor(): + """Same base_version: ``agent:...`` < ``human:...`` by code point, so the + human wins on actor alone — no op_id tiebreak needed.""" + wf = _expect_convergent(_base(), _writer_a(5), _writer_b(5)) + assert _input_link(wf) is None + assert _link_ids(wf) == [] + + +# --------------------------------------------------------------------------- +# 2. the register rule stated directly +# --------------------------------------------------------------------------- + + +def test_higher_stamp_owns_the_input_whichever_connect_arrives_last(): + agent_connect = _connect("c1", AGENT, 5, 9101, ENCODER) + human_add = _add_encoder("c2", HUMAN, 9, FRESH, "rival") + human_connect = _connect("c3", HUMAN, 9, 9102, FRESH) + + human_last = _run(_base(), [agent_connect, human_add, human_connect]) + agent_last = _run(_base(), [human_add, human_connect, agent_connect]) + + assert _input_link(human_last) == 9102 + assert _input_link(agent_last) == 9102 + assert _comparable(human_last) == _comparable(agent_last) + # The losing connect contributes NO link record in either order. + assert _link_ids(human_last) == [9102] + assert _link_ids(agent_last) == [9102] + + +def test_losing_connect_neither_displaces_nor_leaves_a_link(): + winner = _connect("d1", HUMAN, 9, 9201, ENCODER) + loser = _connect("d2", AGENT, 5, 9202, OTHER_ENCODER) + wf = _run(_wired_base(), [winner, loser]) + assert _input_link(wf) == 9201 + assert _link_ids(wf) == [9201] + # The loser's source output must not advertise a link that does not exist. + src = next(n for n in wf["nodes"] if n["id"] == OTHER_ENCODER) + assert src["outputs"][0]["links"] == [] + + +def test_distinct_inputs_on_one_node_are_independent_registers(): + a = _connect("e1", AGENT, 5, 9301, ENCODER, to_slot=POSITIVE) + b = _connect("e2", HUMAN, 9, 9302, OTHER_ENCODER, to_slot=NEGATIVE) + wf = _expect_convergent(_wired_base(), [a], [b]) + assert _input_link(wf, slot=POSITIVE) == 9301 + assert _input_link(wf, slot=NEGATIVE) == 9302 + + +# --------------------------------------------------------------------------- +# 3. composition with delete-wins +# --------------------------------------------------------------------------- + + +def test_winning_connect_with_a_deleted_source_still_clears_the_input(): + """The second divergence the ungated path carried, independent of + two-writer contention: connect-first retired the incumbent and then lost + its own link to the delete (input empty), while delete-first made the + connect a silent no-op and left the incumbent in place. Claiming the + register is now unconditional, so both orders end empty.""" + writer_a = [_connect("f1", AGENT, 9, 9401, ENCODER)] + writer_b = [_delete("f2", HUMAN, 5, ENCODER, [])] + wf = _expect_convergent(_wired_base(), writer_a, writer_b) + assert _input_link(wf) is None + assert _link_ids(wf) == [] + + +def test_losing_connect_with_a_deleted_source_leaves_the_winner_untouched(): + writer_a = [ + _connect("g1", AGENT, 5, 9501, ENCODER), + _delete("g2", AGENT, 5, ENCODER, [9501]), + ] + writer_b = [_connect("g3", HUMAN, 9, 9502, OTHER_ENCODER)] + wf = _expect_convergent(_wired_base(), writer_a, writer_b) + assert _input_link(wf) == 9502 + assert _link_ids(wf) == [9502] + + +def test_deleting_the_destination_wins_over_any_connect(): + writer_a = [_connect("h1", AGENT, 9, 9601, ENCODER)] + writer_b = [_delete("h2", HUMAN, 5, SAMPLER, [9000])] + wf = _expect_convergent(_wired_base(), writer_a, writer_b) + assert all(n["id"] != SAMPLER for n in wf["nodes"]) + assert _link_ids(wf) == [] + + +# --------------------------------------------------------------------------- +# 4. autogrow stays UNGATED (the amendment's explicit carve-out) +# --------------------------------------------------------------------------- + + +def _autogrow_base() -> dict[str, Any]: + def loader(node_id: int) -> dict[str, Any]: + return { + "id": node_id, + "type": "LoadImage", + "pos": [0, 0], + "inputs": [], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + } + + return { + "last_node_id": 700, + "last_link_id": 0, + "nodes": [ + loader(500), + loader(510), + { + "id": 700, + "type": "BatchImagesNode", + "pos": [0, 0], + "inputs": [{"name": "images.image0", "type": "IMAGE", "link": None}], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": []}], + "widgets_values": [], + }, + ], + "links": [], + "groups": [], + } + + +def _grow_connect(tag: str, actor: str, base_version: int, link_id: int, from_node: int) -> dict[str, Any]: + op = _connect(tag, actor, base_version, link_id, from_node, to_node=700, to_slot=None) + op["link_type"] = "IMAGE" + op["grow"] = {"name": "images.image0", "type": "IMAGE"} + return op + + +def test_concurrent_autogrows_are_not_a_shared_register(): + """Autogrow connects mint their own slot keyed by ``grow_id``, so two + writers never contend for one register and BOTH links survive — gating + them on ``("input", to_node, "grow", base)`` would silently drop one.""" + a = _grow_connect("i1", AGENT, 5, 9701, 500) + b = _grow_connect("i2", HUMAN, 9, 9702, 510) + forward = _run(_autogrow_base(), [a, b]) + reverse = _run(_autogrow_base(), [b, a]) + assert _link_ids(forward) == [9701, 9702] + assert _link_ids(reverse) == [9701, 9702] + assert _comparable(forward) == _comparable(reverse) + + +# --------------------------------------------------------------------------- +# 5. generated interleavings — breadth over the hand-picked cases +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("seed", list(range(1, 13))) +def test_generated_two_writer_streams_converge(seed: int): + rng = random.Random(seed) + bv_a = rng.choice([3, 5, 7, 9]) + bv_b = rng.choice([3, 5, 7, 9]) + src_a = rng.choice([ENCODER, OTHER_ENCODER, FRESH]) + src_b = rng.choice([ENCODER, OTHER_ENCODER]) + slot_a = rng.choice([POSITIVE, NEGATIVE]) + slot_b = rng.choice([POSITIVE, NEGATIVE]) + + writer_a = [ + _add_encoder("j1", AGENT, bv_a, FRESH, "generated"), + _connect("j2", AGENT, bv_a, 9801, src_a, to_slot=slot_a), + ] + writer_b = [_connect("j3", HUMAN, bv_b, 9802, src_b, to_slot=slot_b)] + if rng.random() < 0.5: + writer_b.append(_delete("j4", HUMAN, bv_b, src_b, [9802])) + + _expect_convergent(_wired_base(), writer_a, writer_b) + + +# --------------------------------------------------------------------------- +# 6. the caveats, pinned honestly +# --------------------------------------------------------------------------- + + +def test_same_batch_connects_resolve_by_op_id_not_batch_position(): + """``apply_specs`` stamps every op in a batch with the SAME base_version, + so a same-target pair inside one batch is decided by the op_id tiebreak, + not by spec order. Convergence holds — "last spec wins" does not. This has + been true of ``set_widget`` since the freeze; amendment v1.2 extends the + same property to ``connect`` and says so.""" + first = _connect("k1", AGENT, 5, 9901, ENCODER) + second = _connect("k0", AGENT, 5, 9902, OTHER_ENCODER) + wf = _run(_base(), [first, second]) + # "k0…" < "k1…" by code point, so the FIRST spec wins despite arriving first. + assert _input_link(wf) == 9901 + assert _link_ids(wf) == [9901] + + +def test_known_gap_out_links_order(): + """KNOWN GAP amendment v1.2 does NOT close: ``outputs[].links`` is appended + in arrival order, so two connects out of one source into two DIFFERENT + inputs record the same SET in two different sequences. Filed, not fixed: + closing it canonicalizes a set-valued projection field in both + implementations.""" + a = _connect("l1", AGENT, 5, 9001, ENCODER, to_slot=POSITIVE) + b = _connect("l2", HUMAN, 9, 9002, ENCODER, to_slot=NEGATIVE) + + def out_links(order: list[dict]) -> list[Any]: + wf = _run(_base(), order) + return next(n for n in wf["nodes"] if n["id"] == ENCODER)["outputs"][0]["links"] + + assert out_links([a, b]) == [9001, 9002] + assert out_links([b, a]) == [9002, 9001] + # …and the SET is convergent, which is the part v1.2 guarantees. + assert sorted(out_links([a, b])) == sorted(out_links([b, a])) + + +def test_known_gap_autogrow_racing_a_delete_of_its_source(): + """KNOWN GAP amendment v1.2 does NOT close: an autogrow connect grows a + STRUCTURAL slot rather than writing a register, so racing a delete of its + source leaves the grown slot present in one order and absent in the other. + Filed as the autogrow-shaped sibling of FINDING 1.""" + grow = _grow_connect("m1", AGENT, 5, 9701, 500) + delete = _delete("m2", HUMAN, 9, 500, [9701]) + + def slots(order: list[dict]) -> list[str]: + wf = _run(_autogrow_base(), order) + return [i["name"] for i in next(n for n in wf["nodes"] if n["id"] == 700)["inputs"]] + + assert slots([grow, delete]) == ["images.image0", "images.image1"] + assert slots([delete, grow]) == ["images.image0"] + + +# --------------------------------------------------------------------------- +# 7. stamp-target identity is node-id-TYPE independent +# +# FINDING (adversarial, comfy-multi-player PR #6725): ``_write_target`` built +# the register key from the RAW ``node_id`` while every lookup resolves ids as +# strings, so an op carrying ``7`` and one carrying ``"7"`` addressed the same +# node through two different registers — ``_lww_gate`` never compared them and +# the pair converged by apply order. ``NodeId`` is legitimately either JSON +# type (historical string ids; subgraph addresses like ``"57:3"``), so this is +# legal traffic, not malformed input. Interior writes already normalized their +# path and survived the attack; amendment v1.2 makes every case match them. +# --------------------------------------------------------------------------- + + +def _ksampler_graph(): + """A one-node catalog whose KSampler widget order matches ``_sampler()``: + seed, control_after_generate, steps, cfg, sampler_name, scheduler, denoise. + ``set_widget`` needs a graph to resolve widget NAME -> positional index; + the connect path does not, which is why the rest of this file passes None.""" + from comfy_cli.cql.engine import Graph + + return Graph.from_object_info( + { + "KSampler": { + "input": { + "required": { + "model": "MODEL", + "positive": "CONDITIONING", + "negative": "CONDITIONING", + "latent_image": "LATENT", + "seed": ["INT", {"default": 0, "control_after_generate": True}], + "steps": ["INT", {"default": 20}], + "cfg": ["FLOAT", {"default": 8.0}], + "sampler_name": [["euler", "euler_ancestral"]], + "scheduler": [["normal", "karras"]], + "denoise": ["FLOAT", {"default": 1.0}], + } + }, + "input_order": { + "required": [ + "model", + "positive", + "negative", + "latent_image", + "seed", + "steps", + "cfg", + "sampler_name", + "scheduler", + "denoise", + ] + }, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "sampling", + "display_name": "KSampler", + "python_module": "nodes", + } + } + ) + + +def _run_with_graph(base: dict, order: list[dict]) -> dict: + wf = copy.deepcopy(base) + g = _ksampler_graph() + for op in order: + wf = ops.apply_op(wf, op, g) + return wf + + +def _set_steps(tag: str, actor: str, base_version: int, node_id: Any, value: int) -> dict[str, Any]: + return { + "op": "set_widget", + "op_id": _op_id(tag), + "actor": actor, + "base_version": base_version, + "stamp": [base_version, actor], + "node_id": node_id, + "widget": "steps", + "value": value, + } + + +def _steps(wf: dict) -> Any: + node = next(n for n in wf["nodes"] if str(n["id"]) == str(SAMPLER)) + # KSampler widget order: seed, control_after_generate, steps, ... + return node["widgets_values"][2] + + +def test_write_target_normalizes_node_id_type(): + assert ops._write_target(_set_steps("n1", AGENT, 5, SAMPLER, 111)) == ops._write_target( + _set_steps("n2", HUMAN, 9, str(SAMPLER), 999) + ) + assert ops._write_target(_connect("n3", AGENT, 5, 1, ENCODER, to_node=SAMPLER)) == ops._write_target( + _connect("n4", HUMAN, 9, 2, ENCODER, to_node=str(SAMPLER)) + ) + assert ops._write_target(_delete("n5", AGENT, 5, SAMPLER, [])) == ops._write_target( + _delete("n6", AGENT, 5, str(SAMPLER), []) + ) + + +def test_set_widget_mixed_id_types_converge(): + numeric = _set_steps("o1", AGENT, 5, SAMPLER, 111) + stringy = _set_steps("o2", HUMAN, 9, str(SAMPLER), 999) + assert _steps(_run_with_graph(_base(), [numeric, stringy])) == 999 + assert _steps(_run_with_graph(_base(), [stringy, numeric])) == 999 + # …and the lower stamp loses whichever id type it carries. + wins = _set_steps("o3", HUMAN, 9, SAMPLER, 111) + loses = _set_steps("o4", AGENT, 5, str(SAMPLER), 999) + assert _steps(_run_with_graph(_base(), [wins, loses])) == 111 + assert _steps(_run_with_graph(_base(), [loses, wins])) == 111 + + +def test_connect_mixed_to_node_types_share_one_register(): + numeric = _connect("p1", AGENT, 5, 501, ENCODER, to_node=SAMPLER) + stringy = _connect("p2", HUMAN, 9, 502, OTHER_ENCODER, to_node=str(SAMPLER)) + forward = _run(_wired_base(), [numeric, stringy]) + reverse = _run(_wired_base(), [stringy, numeric]) + assert _input_link(forward) == 502 + assert _input_link(reverse) == 502 + assert _link_ids(forward) == [502] + assert _link_ids(reverse) == [502] From 9dd976cbc0b2c014460b5b065e1e085855a31afc Mon Sep 17 00:00:00 2001 From: kishore Date: Thu, 13 Aug 2026 18:50:48 -0700 Subject: [PATCH 47/53] =?UTF-8?q?fix:=20reconcile=20the=20main=20merge=20?= =?UTF-8?q?=E2=80=94=20prefer=20main's=20model,=20keep=20511's=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears all 10 failures left by the merge commit. Suite is now 29 failed / 5034 passed, the SAME 29 as both parents (511 = 29/4603, main = 29/4617) and ~420 more passing tests than either alone. Zero regressions against the union of both baselines. Dynamic-combo widget order — adopt main's model: `widget_order` is value-INDEPENDENT (a combo contributes only its selector); expansion lives in the value-aware `widget_order_for_node`; an INT `seed`/`noise_seed` implicitly gains control_after_generate, mirroring the frontend's useIntWidget. Fallout fixed: - main homes dynamic_options on Port, not PortOptions. The 511-derived code restored in the merge read the dead PortOptions field, so no combo ever expanded. Repointed 4 refs. - `_parse_input_spec` gained a 6th return value on main but one caller still unpacked 5 ("too many values to unpack"). Fixed. - Added `Graph.widget_order_default`: the static order with each combo expanded at its FIRST key. A catalog has no node and no selection, but consumers still need the sub-input names to address them (`set-widget .model.resolution`), and a FRESH node materializes exactly this order. widget_catalog, add_node, node sizing and the widget-name check now use it; `widget_order` stays main's. - Retargeted 511's and #714's tests to main's semantics rather than bending main to theirs. run cloud lifecycle — keep 511's fix, restore its scoping: The merge left `run_inner.execute(...)` OUTSIDE the `else:` branch, so a local submit ran after every cloud submit. main can dedent it because its cloud branch ends in `return`; 511 removed that return deliberately (an early return skipped the try's `else` and so never fired `execution_success`). Both are correct together only if the local path stays inside the else. Re-indented. test targets, not behaviour: Two @patch targets pointed at comfy_cli.utils.requests / comfy_cli.standalone.requests, which do not exist under the lazy import (verified: importing comfy_cli.utils does not load requests). Repointed to "requests.get" — the pattern the same file already used. The timeout main added is still asserted. ruff format applied to the 4 files the merge left unformatted. Lint is 19 errors, all UP038 style, zero F/E/B class — unchanged in kind from both parents. --- comfy_cli/cmdline.py | 72 ++++++++++--------- comfy_cli/command/preview.py | 1 + comfy_cli/cql/engine.py | 35 +++++++-- comfy_cli/cql/widget_catalog.py | 2 +- comfy_cli/workflow_ops.py | 6 +- tests/comfy_cli/command/github/test_pr.py | 2 +- .../command/test_nodes_widget_catalog.py | 5 +- tests/comfy_cli/command/test_preview.py | 1 + tests/comfy_cli/command/test_workflow_edit.py | 9 ++- tests/comfy_cli/cql/test_engine.py | 29 ++++++-- 10 files changed, 108 insertions(+), 54 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index c430be2c9..aa9d311ea 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -1098,40 +1098,44 @@ def run( allow_spend=allow_spend, ) else: - from comfy_cli.host_port import parse_host_port_arg, resolve_host_port - - from comfy_cli.host_port import parse_host_port_arg, report_usage_error, resolve_host_port - - # ``report_usage_error``: a bad ``--host``/``--port`` is a - # ``typer.BadParameter``, which click turns into a stderr usage panel + - # exit 2 — leaving stdout empty in JSON/NDJSON mode while every other - # failure here ends with an envelope. Emit the terminating envelope - # first; the exception still propagates, so exit 2 is unchanged. - with report_usage_error(renderer): - if host: - host, parsed_port = parse_host_port_arg(host) - # ``port is None``, not ``not port``: a typed ``--port`` always wins - # over one embedded in ``--host h:p``, including ``--port 0``, which - # ``resolve_host_port`` then rejects as out of range instead of - # silently running against the embedded port. - if port is None and parsed_port is not None: - port = parsed_port - - host, port = resolve_host_port(host, port) - - run_inner.execute( - workflow, - host, - port, - wait=wait, - verbose=verbose, - timeout=timeout, - notify=effective_notify, - api_key=api_key, - print_prompt=print_prompt, - preloaded=preloaded, - allow_spend=allow_spend, - ) + # The whole local path stays INSIDE this else. main could dedent it + # because its cloud branch ended in `return`; that return is exactly + # what this branch removed (it skipped the try's `else` and so never + # fired `execution_success`), so unindenting here would run the local + # submit after every cloud submit. + from comfy_cli.host_port import parse_host_port_arg, report_usage_error, resolve_host_port + + # ``report_usage_error``: a bad ``--host``/``--port`` is a + # ``typer.BadParameter``, which click turns into a stderr usage panel + # + exit 2 — leaving stdout empty in JSON/NDJSON mode while every + # other failure here ends with an envelope. Emit the terminating + # envelope first; the exception still propagates, so exit 2 is + # unchanged. + with report_usage_error(renderer): + if host: + host, parsed_port = parse_host_port_arg(host) + # ``port is None``, not ``not port``: a typed ``--port`` always + # wins over one embedded in ``--host h:p``, including + # ``--port 0``, which ``resolve_host_port`` then rejects as out + # of range instead of silently running against the embedded one. + if port is None and parsed_port is not None: + port = parsed_port + + host, port = resolve_host_port(host, port) + + run_inner.execute( + workflow, + host, + port, + wait=wait, + verbose=verbose, + timeout=timeout, + notify=effective_notify, + api_key=api_key, + print_prompt=print_prompt, + preloaded=preloaded, + allow_spend=allow_spend, + ) except typer.Exit as e: if (e.exit_code or 0) == 0: tracking.track_event("execution_success", _track_props) diff --git a/comfy_cli/command/preview.py b/comfy_cli/command/preview.py index e8fa4fd0e..6bec4f8e2 100644 --- a/comfy_cli/command/preview.py +++ b/comfy_cli/command/preview.py @@ -168,6 +168,7 @@ def _bundled_ffmpeg() -> str | None: except Exception: # noqa: BLE001 — package absent or no bundled build for this platform return None + def _classify_by_ext(path: Path) -> dict: """Fallback classification when ffprobe is unavailable (e.g. only the imageio-ffmpeg static ffmpeg is present): pick kind from the extension. diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index 5eb13b606..e72864bbf 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -408,6 +408,7 @@ def _is_wildcard_type(type_id: str) -> bool: return False return type_id in _WILDCARD_TYPES or type_id.startswith(_WILDCARD_TYPE_PREFIX) + def _is_dynamic_combo_type(type_id: str) -> bool: """V3 dynamic-combo types (e.g. ``COMFY_DYNAMICCOMBO_V3``): a selector widget whose chosen option contributes its own sub-inputs. Same rule the @@ -599,7 +600,7 @@ def _dynamic_sub_widget_defaults(base: str, options: list, selected: Any = _FIRS if not isinstance(section_def, dict): continue for sub_name, spec in section_def.items(): - _t, _e, enum_values, opts, _declared = _parse_input_spec(spec) + _t, _e, enum_values, opts, _declared, _dyn = _parse_input_spec(spec) default = opts.default if default is None and enum_values: default = enum_values[0] @@ -1143,8 +1144,31 @@ def widget_order(self, class_name: str) -> list[str]: if p.is_link: continue order.append(p.name) - if p.options.dynamic_options: - order.extend(_dynamic_sub_widget_names(p.name, p.options.dynamic_options)) + if p.options.control_after_generate: + order.append("control_after_generate") + return order + + def widget_order_default(self, class_name: str) -> list[str]: + """Static order with every dynamic combo expanded at its FIRST key. + + :meth:`widget_order` is deliberately value-independent — a combo + contributes only its selector, because which sub-inputs exist depends on + the node's current selection. A CATALOG has no node and no selection, but + its consumers still need the sub-input names in order to address them + (``set-widget .model.resolution``). So the catalog publishes the order + a FRESH node would have, which is the first key — the same option + ``add_node`` materializes via :meth:`widget_defaults`. + """ + m = self._nodes.get(class_name) + if m is None: + return [] + order: list[str] = [] + for p in m.inputs: + if p.is_link: + continue + order.append(p.name) + if p.dynamic_options: + order.extend(_dynamic_sub_widget_names(p.name, p.dynamic_options)) if p.options.control_after_generate: order.append("control_after_generate") return order @@ -1171,9 +1195,9 @@ def widget_defaults(self, class_name: str) -> dict[str, Any]: for p in m.inputs: if p.is_link: continue - if p.options.dynamic_options: + if p.dynamic_options: out[p.name] = p.enum_values[0] if p.enum_values else None # selected key - out.update(_dynamic_sub_widget_defaults(p.name, p.options.dynamic_options)) + out.update(_dynamic_sub_widget_defaults(p.name, p.dynamic_options)) elif p.options.default is not None: out[p.name] = p.options.default elif p.enum_values: @@ -2208,6 +2232,7 @@ def _widgets_as_list(widgets_values: Any) -> list[Any]: """ return list(widgets_values) if isinstance(widgets_values, list) else [] + # A dynamic combo may nest another dynamic combo among its sub-inputs. Real # schemas are one or two levels deep; the cap defends the expansion walk # against a pathological/malicious object_info entry. diff --git a/comfy_cli/cql/widget_catalog.py b/comfy_cli/cql/widget_catalog.py index 7965f2095..e9c65f9cf 100644 --- a/comfy_cli/cql/widget_catalog.py +++ b/comfy_cli/cql/widget_catalog.py @@ -76,7 +76,7 @@ def build_types(graph) -> dict[str, dict[str, Any]]: types: dict[str, dict[str, Any]] = {} for m in graph.all_nodes(): - entry: dict[str, Any] = {"widget_order": list(graph.widget_order(m.id))} + entry: dict[str, Any] = {"widget_order": list(graph.widget_order_default(m.id))} # V3 autogrow (COMFY_AUTOGROW_V3): one declared input, one wire slot per # connection (`images` → `images.image0`, `images.image1`, …). The diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index af75ab0f2..4020af202 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -484,7 +484,7 @@ def add_node( size = layout.estimate_size( len([p for p in m.inputs if p.is_link]), len(m.outputs), - len(graph.widget_order(class_type)), + len(graph.widget_order_default(class_type)), ) if pos is None: # Layout-aware default: right of the current graph, collision-free. @@ -1214,7 +1214,7 @@ def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | N node = by_id.get(node_id) if node is None: raise RecipeError(f"--param target node {node_id!r} not in workflow") - if widget not in graph.widget_order(node.get("type", "")): + if widget not in graph.widget_order_default(node.get("type", "")): raise RecipeError(f"--param target {node_id}.{widget!r}: not a widget on {node.get('type')}") alias_by_id: dict[Any, str] = {} @@ -1825,7 +1825,7 @@ def _build_node(node_id: int, class_type: str, m, graph, pos: list, size: list) # Widget values in positional order, including dynamic-combo selectors and # their sub-widgets — sourced from the engine so add-node matches the converter. defaults = graph.widget_defaults(class_type) - widgets = [defaults.get(name) for name in graph.widget_order(class_type)] + widgets = [defaults.get(name) for name in graph.widget_order_default(class_type)] return { "id": node_id, "type": class_type, diff --git a/tests/comfy_cli/command/github/test_pr.py b/tests/comfy_cli/command/github/test_pr.py index 0778ca99a..b2f204fe9 100644 --- a/tests/comfy_cli/command/github/test_pr.py +++ b/tests/comfy_cli/command/github/test_pr.py @@ -181,7 +181,7 @@ def test_find_pr_by_branch_error(self, mock_get): @patch("requests.get") def test_find_pr_by_branch_rate_limit(self, mock_get): - """A rate-limited 403 surfaces as GitHubRateLimitError, not a silent "no PR found\"""" + """A rate-limited 403 surfaces as GitHubRateLimitError, not a silent "no PR found\" """ mock_response = Mock() mock_response.status_code = 403 mock_response.headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": "1777415867"} diff --git a/tests/comfy_cli/command/test_nodes_widget_catalog.py b/tests/comfy_cli/command/test_nodes_widget_catalog.py index 6c6146502..16412a841 100644 --- a/tests/comfy_cli/command/test_nodes_widget_catalog.py +++ b/tests/comfy_cli/command/test_nodes_widget_catalog.py @@ -204,7 +204,10 @@ def test_every_class_matches_the_engine(self, patched_loader, capsys): graph = _graph() assert set(types) == {m.id for m in graph.all_nodes()} for class_type, entry in types.items(): - assert entry["widget_order"] == graph.widget_order(class_type), class_type + # The catalog publishes the FRESH-node order (dynamic combos expanded + # at their first key), which is what a consumer can address before it + # has a node to read a selection from. + assert entry["widget_order"] == graph.widget_order_default(class_type), class_type def test_control_after_generate_is_in_the_order(self, patched_loader, capsys): """The synthetic widget the frontend injects after a seed occupies a diff --git a/tests/comfy_cli/command/test_preview.py b/tests/comfy_cli/command/test_preview.py index fa8b390d3..834a9e397 100644 --- a/tests/comfy_cli/command/test_preview.py +++ b/tests/comfy_cli/command/test_preview.py @@ -143,6 +143,7 @@ def test_classify_by_ext_unknown_for_non_media(): assert _classify_by_ext(Path("pic.png"))["kind"] == "image" assert _classify_by_ext(Path("sound.wav"))["kind"] == "audio" + # --- CWD binary-planting guard --------------------------------------------- diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 36738e2c2..7a94ab503 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -1275,9 +1275,12 @@ def test_add_node_fills_dynamiccombo_defaults(self, patched_graph, tmp_path, cap nid = _run(["add-node", str(path), "KlingFLFTest"], capsys)["data"]["op"]["node_id"] g = _graph() node = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid) - order = g.widget_order("KlingFLFTest") - assert "model" in order and "model.resolution" in order wv = node["widgets_values"] + # Index against the node's ACTUAL values: `widget_order` is value- + # independent (selector only), so a dynamic combo's sub-inputs only + # appear once the selection is known. + order = g.widget_order_for_node("KlingFLFTest", wv) + assert "model" in order and "model.resolution" in order assert wv[order.index("model")] == "kling-v3" # first key assert wv[order.index("model.resolution")] == "1080p" # sub default @@ -1288,8 +1291,8 @@ def test_set_widget_dynamiccombo_selector_and_sub(self, patched_graph, tmp_path, e2 = _run(["set-widget", str(path), f"{nid}.model.resolution", "720p"], capsys) assert e1["ok"] and e2["ok"], (e1, e2) g = _graph() - order = g.widget_order("KlingFLFTest") wv = next(n for n in json.loads(path.read_text())["nodes"] if n["id"] == nid)["widgets_values"] + order = g.widget_order_for_node("KlingFLFTest", wv) assert wv[order.index("model.resolution")] == "720p" from comfy_cli.workflow_to_api import convert_ui_to_api diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index fe18444b6..5be79236f 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -373,24 +373,41 @@ def _dyn_graph() -> Graph: } ) - def test_static_order_uses_first_key(self): + def test_static_order_is_selector_only(self): + """`widget_order` is value-INDEPENDENT: a dynamic combo contributes only + its selector, because which sub-inputs exist depends on the selection the + node actually carries. First-key expansion moved to `widget_order_default` + (what a fresh node has, and what the catalog publishes).""" g = self._dyn_graph() - assert g.widget_order("DynNode") == ["model", "model.res", "seed"] + assert g.widget_order("DynNode") == ["model", "seed"] + + def test_default_order_uses_first_key(self): + g = self._dyn_graph() + assert g.widget_order_default("DynNode") == ["model", "model.res", "seed"] def test_node_order_expands_selected_key(self): g = self._dyn_graph() - # Selecting "b" adds model.quality, pushing seed to index 3. + # Selecting "b" adds model.quality, pushing seed to index 3. The trailing + # control_after_generate is implicit: the frontend's useIntWidget always + # companions an INT `seed`, regardless of the schema flag. order = g.widget_order_for_node("DynNode", ["b", "x", "hi", 12345]) - assert order == ["model", "model.res", "model.quality", "seed"] + assert order == ["model", "model.res", "model.quality", "seed", "control_after_generate"] assert order.index("seed") == 3 def test_node_order_first_key_matches_static(self): g = self._dyn_graph() - assert g.widget_order_for_node("DynNode", ["a", "x", 999]) == ["model", "model.res", "seed"] + assert g.widget_order_for_node("DynNode", ["a", "x", 999]) == [ + "model", + "model.res", + "seed", + "control_after_generate", + ] def test_empty_widgets_falls_back_to_static(self): g = self._dyn_graph() - assert g.widget_order_for_node("DynNode", []) == g.widget_order("DynNode") + # No values to read -> no selection to expand, so it degrades to the + # selector-only static order (plus the implicit seed companion). + assert g.widget_order_for_node("DynNode", []) == ["model", "seed", "control_after_generate"] def test_set_widget_writes_seed_to_selected_slot(self): """End-to-end: set-widget on a "b"-selected node must land seed at index 3, From 5092a019c7430ad2bed83b372515bb2ecd4e4d62 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 14 Aug 2026 12:23:17 -0700 Subject: [PATCH 48/53] feat(workflow): typed outcome on the edit envelope (BE-7215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit comfy-cli returned `ok:true` with soft warnings for conditions the caller has to treat as fatal. Callers could not change that, so the cloud agent grew a Go layer that re-derives the outcome by parsing warning prose — ~750 lines in services/agent/internal/loop/enumgate.go, coupled to this repo by string shape rather than by contract. This makes the outcome a field. Severity is declared, never inferred Every catalog finding now carries `severity` (error|warning|info), stamped centrally on return from `Port.validate_catalog`, so a finding added at any site cannot ship without one. `FATAL_FINDING_CODES` + `finding_severity()` are the vocabulary; an unlisted code is advisory and never fatal, so a new finding cannot become silently fatal by being added. ok:true never coexists with a fatal finding unknown_enum_value, no_options_available, below_min and above_max now refuse the edit instead of applying it and warning. Each is a value the server rejects at validate_inputs time, and `Graph.validate_workflow` ALREADY classified all four as errors (`_validate_catalog_value`) — the edit path was the last surface still demoting them. This makes the two agree; it is not a new policy. Refusal is raised at one choke point (`workflow_ops._validate_widget`) so set-widget / add-node / connect / apply / foreach all inherit it rather than each re-deciding. On refusal the document is left byte-identical. Unchanged: an upload-backed port (`Port.is_upload_backed`) still produces no finding at all. A freshly uploaded file is legitimately absent from the catalog snapshot, so enum-checking it would refuse valid work. Operands are fields, not prose `value` is now a field on the finding, and `FatalFindingError` carries the finding verbatim so the command layer emits code / value / field / valid_options / did_you_mean as envelope details. This is exactly what enumgate.go's `enumValueRE` regexes back out of the message today. Documents are complete `complete_save_format()` runs at the serialization choke point (`strip_internal`, all four emit sites), so every workflow carries `version`, `last_node_id` and `last_link_id`. Only ABSENT keys are filled and counters are derived from content, so a producer's own values are never rewritten. This is what internal/draft/normalize.go exists to patch in. Contract tests/data/edit_findings_conformance.json is a language-neutral corpus of (object_info, class, widget, value) -> (outcome, code, severity, operands), replayed against the real engine by test_edit_findings_contract.py. A Go or TypeScript consumer replays it instead of re-deriving the semantics. The severity vocabulary is pinned alongside it; both mutation-checked (dropping a code from FATAL_FINDING_CODES fails the suite). Behaviour change: callers see ok:false where they previously saw ok:true with a warning. The agent pin must bump in lockstep. Until it does, enumgate.go simply finds no warnings to gate on because the CLI refuses first — safe, but the error reaches the model from a different place. NOT in this commit: the Go deletions (enumgate.go, normalize.go) live in Comfy-Org/cloud. Note enumgate.go's `placeholder_model` gate must SURVIVE that deletion — `put_checkpoints.safetensors` is a legal catalog member, so it passes membership and produces no finding here; it is a different bug on a different (validate) code path. Suite: 29 failed / 5051 passed — the same 29 as both parents (511 and main), zero regressions; +17 from the new contract test. --- comfy_cli/command/workflow_edit.py | 32 +++- comfy_cli/cql/engine.py | 41 ++++- comfy_cli/workflow_ops.py | 89 +++++++++- tests/comfy_cli/command/test_workflow_edit.py | 26 ++- .../comfy_cli/test_edit_findings_contract.py | 153 ++++++++++++++++++ tests/data/edit_findings_conformance.json | 119 ++++++++++++++ 6 files changed, 444 insertions(+), 16 deletions(-) create mode 100644 tests/comfy_cli/test_edit_findings_contract.py create mode 100644 tests/data/edit_findings_conformance.json diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index 3ae18e21b..c6a8e3f85 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -40,6 +40,26 @@ WhereOpt = Annotated[str | None, typer.Option("--where", show_default=False, help="Catalog target: local | cloud.")] +def _emit_edit_error(renderer, e: ValueError, *, hint: str) -> None: + """Emit an edit failure, preferring the typed form when one is available. + + A :class:`workflow_ops.FatalFindingError` carries the catalog finding, so + the envelope can name the offending ``value``, ``field``, ``valid_options`` + and ``did_you_mean`` as DETAILS rather than burying them in prose a caller + has to regex (BE-7215). Any other ValueError keeps the previous shape. + """ + if isinstance(e, workflow_ops.FatalFindingError): + f = e.finding + renderer.error( + code=f.get("code", "workflow_edit_invalid"), + message=f.get("message", str(e)), + hint=(f"did you mean: {', '.join(str(v) for v in f['did_you_mean'])}?" if f.get("did_you_mean") else hint), + details={k: v for k, v in f.items() if k != "message"}, + ) + return + renderer.error(code="workflow_edit_invalid", message=str(e), hint=hint) + + def _split_addr(addr: str, renderer) -> tuple[Any, str]: """Split ``.`` → (node_id, name). node_id is int when numeric.""" if "." not in addr: @@ -151,7 +171,7 @@ def add_node_cmd( ) raise typer.Exit(code=1) from e except ValueError as e: - renderer.error(code="workflow_edit_invalid", message=str(e)) + _emit_edit_error(renderer, e, hint="run `comfy workflow slots ` to list widget addresses") raise typer.Exit(code=1) from e _finish(renderer, p, workflow, op, base_version, stdout, "workflow add-node") @@ -189,11 +209,7 @@ def set_widget_cmd( workflow, graph, node_id, widget, _parse_value(value), actor=actor, base_version=base_version ) except ValueError as e: - renderer.error( - code="workflow_edit_invalid", - message=str(e), - hint="run `comfy workflow slots ` to list widget addresses", - ) + _emit_edit_error(renderer, e, hint="run `comfy workflow slots ` to list widget addresses") raise typer.Exit(code=1) from e _finish(renderer, p, workflow, op, base_version, stdout, "workflow set-widget") @@ -227,7 +243,7 @@ def connect_cmd( workflow, graph, from_node, from_slot, to_node, to_slot, actor=actor, base_version=base_version ) except ValueError as e: - renderer.error(code="workflow_edit_invalid", message=str(e)) + _emit_edit_error(renderer, e, hint="run `comfy workflow slots ` to list widget addresses") raise typer.Exit(code=1) from e _finish(renderer, p, workflow, op, base_version, stdout, "workflow connect") @@ -257,7 +273,7 @@ def delete_cmd( try: workflow, op = workflow_ops.delete_node(workflow, graph, node_id, actor=actor, base_version=base_version) except ValueError as e: - renderer.error(code="workflow_edit_invalid", message=str(e)) + _emit_edit_error(renderer, e, hint="run `comfy workflow slots ` to list widget addresses") raise typer.Exit(code=1) from e _finish(renderer, p, workflow, op, base_version, stdout, "workflow delete") diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index e72864bbf..e339960fa 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -249,7 +249,13 @@ def validate_shape(self, value: Any) -> str | None: return None def validate_catalog(self, value: Any) -> list[dict]: - """Soft checks against catalog snapshot. Returns warnings list.""" + """Catalog findings for ``value``. Returns a list of finding dicts. + + Every finding carries ``code``, ``severity`` and ``value`` (BE-7215): + ``severity`` so a caller never infers fatality from prose, and ``value`` + so the offending operand is a field rather than something to regex back + out of ``message``. Fatal codes are :data:`FATAL_FINDING_CODES`. + """ if self.validate_shape(value) is not None: return [] warnings: list[dict] = [] @@ -320,6 +326,11 @@ def validate_catalog(self, value: Any) -> list[dict]: "message": f"{self.name}={value} above catalog max {self.options.max}", } ) + # Stamp centrally: a finding added at any site above inherits its + # severity from the code table, so none can ship without one. + for w in warnings: + w.setdefault("severity", finding_severity(w.get("code", ""))) + w.setdefault("value", value) return warnings @@ -397,6 +408,34 @@ def can_apply(self, available: set[str]) -> bool: _WILDCARD_TYPES = frozenset({"*"}) +# Finding severity — BE-7215. Every catalog finding carries an explicit +# ``severity`` so a consumer never has to infer fatality from prose. The rule the +# codes below encode: a finding is an ERROR when the value cannot resolve at run +# time, which is precisely when `Graph.validate_workflow` already refuses it +# (see `_validate_catalog_value`) — the edit path was the only surface still +# demoting these to advisory warnings on an ``ok:true`` envelope. +SEVERITY_ERROR = "error" +SEVERITY_WARNING = "warning" +SEVERITY_INFO = "info" + +#: Codes whose finding means "the server will reject this value". Callers must +#: treat these as fatal; `workflow_ops` refuses the edit outright rather than +#: writing the value and warning about it. +FATAL_FINDING_CODES = frozenset( + { + "unknown_enum_value", + "no_options_available", + "below_min", + "above_max", + } +) + + +def finding_severity(code: str) -> str: + """Severity for a finding code. Unknown codes are advisory, never fatal.""" + return SEVERITY_ERROR if code in FATAL_FINDING_CODES else SEVERITY_WARNING + + def _is_wildcard_type(type_id: str) -> bool: """True when a socket type accepts/produces any type. diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 4020af202..853290267 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -1807,11 +1807,66 @@ def canonical(workflow: dict) -> dict: return w +#: Save-format version this module emits — array-style `links`, matching what +#: `apply_op` builds and what the frontend's zod schema expects. +SAVE_FORMAT_VERSION = 0.4 + + +def _max_id(values) -> int: + """Largest non-negative int in ``values``; 0 when there is none.""" + best = 0 + for v in values: + try: + n = int(v) + except (TypeError, ValueError): + continue + if n > best: + best = n + return best + + +def complete_save_format(workflow: dict) -> dict: + """Fill the save-format keys a consumer is entitled to assume (BE-7215). + + We emitted ``{nodes, links, last_node_id}`` and omitted ``version`` and + ``last_link_id``. The frontend's ``validateComfyWorkflow`` zod schema + requires them, so every consumer had to patch the document before it could + be used — the cloud agent carried a whole module (`internal/draft/ + normalize.go`) doing exactly this. Completing it at the source deletes that + for every current and future caller. + + Only ABSENT keys are filled: a document that already declares a version or + carries its own counters keeps them, so this never rewrites a producer's + intent. Counters are derived from content, so they cannot under-report an + id that is actually in use. + """ + workflow.setdefault("version", SAVE_FORMAT_VERSION) + + if "last_node_id" not in workflow: + nodes = workflow.get("nodes") + workflow["last_node_id"] = ( + _max_id(n.get("id") for n in nodes if isinstance(n, dict)) if isinstance(nodes, list) else 0 + ) + + if "last_link_id" not in workflow: + links = workflow.get("links") + # A link row is [link_id, src, src_slot, tgt, tgt_slot, type]. + workflow["last_link_id"] = ( + _max_id(ln[0] for ln in links if isinstance(ln, list | tuple) and ln) if isinstance(links, list) else 0 + ) + return workflow + + def strip_internal(workflow: dict) -> dict: - """Remove apply-only bookkeeping before serializing to disk.""" + """Remove apply-only bookkeeping and complete the save format before serializing. + + Called at every point the document leaves this process, so the two + guarantees — no internal bookkeeping, no missing save-format keys — hold for + file writes, ``--stdout`` and batch output alike. + """ workflow.pop("_applied_ops", None) workflow.pop("_widget_stamps", None) - return workflow + return complete_save_format(workflow) # --------------------------------------------------------------------------- @@ -1855,6 +1910,24 @@ def _widget_index(graph, class_type: str, widget: str, widgets_values=None) -> i return order.index(widget) +class FatalFindingError(ValueError): + """A catalog finding the server will reject — the edit is refused (BE-7215). + + Carries the finding verbatim so the command layer can emit ``code``, + ``value``, ``field`` and ``did_you_mean`` as ENVELOPE FIELDS. Previously + these shipped as a soft warning on an ``ok:true`` envelope and every + consumer had to re-derive fatality by parsing the message text; the agent + grew ~750 lines of Go doing exactly that. + + Subclasses ValueError so existing ``except ValueError`` handlers still + catch it — they just lose the structure. + """ + + def __init__(self, finding: dict): + self.finding = finding + super().__init__(finding.get("message", finding.get("code", "invalid value"))) + + def _validate_widget(graph, class_type: str, widget: str, value: Any) -> list[dict]: """Shape-validate a widget value (hard error) and collect catalog warnings (soft — e.g. unknown COMBO option, out-of-range number).""" @@ -1867,7 +1940,17 @@ def _validate_widget(graph, class_type: str, widget: str, value: Any) -> list[di err = port.validate_shape(value) if err: raise ValueError(err) - return port.validate_catalog(value) + findings = port.validate_catalog(value) + # A value the server will reject must not reach the document. `validate` + # already refused these (`Graph._validate_catalog_value` puts them in + # `errors`); the edit path was the last surface still writing them and + # returning ok:true. + from comfy_cli.cql.engine import SEVERITY_ERROR + + fatal = next((f for f in findings if f.get("severity") == SEVERITY_ERROR), None) + if fatal is not None: + raise FatalFindingError(fatal) + return findings def _normalize_slot_name(name: Any) -> str: diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index 7a94ab503..b9608cde8 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -1991,11 +1991,29 @@ def test_exact_value_is_untouched_and_unwarned(self): assert op["value"] == "euler" assert not any(w.get("code") == "normalized_value" for w in op.get("warnings", [])) - def test_unknown_value_is_left_for_validate_to_flag(self): + def test_unknown_value_is_refused_not_written(self): + """BE-7215: an unknown COMBO value on a non-upload-backed port is FATAL. + + It used to be applied and reported as a soft warning on an ``ok:true`` + envelope, so the bad value reached the canvas and only failed at run + time — the agent grew ~750 lines of Go re-deriving that this "warning" + was actually fatal. `validate` already refused it; the edit path now + agrees, and the document is left untouched. + """ g, wf = _graph(), _base_workflow() - _, op = workflow_ops.set_widget(wf, g, 3, "sampler_name", "totally_made_up") - assert op["value"] == "totally_made_up" # not silently changed - assert any(w.get("code") == "unknown_enum_value" for w in op.get("warnings", [])) + before = json.loads(json.dumps(wf)) + with pytest.raises(workflow_ops.FatalFindingError) as ei: + workflow_ops.set_widget(wf, g, 3, "sampler_name", "totally_made_up") + + f = ei.value.finding + assert f["code"] == "unknown_enum_value" + assert f["severity"] == "error" + # The offending operand is a FIELD, not something to regex out of prose. + assert f["value"] == "totally_made_up" + assert f["field"] == "sampler_name" + assert f["valid_options"] + # Refused means refused: the scratch document is byte-identical. + assert wf == before class TestWhereInvalid: diff --git a/tests/comfy_cli/test_edit_findings_contract.py b/tests/comfy_cli/test_edit_findings_contract.py new file mode 100644 index 000000000..363d21975 --- /dev/null +++ b/tests/comfy_cli/test_edit_findings_contract.py @@ -0,0 +1,153 @@ +"""Freeze the edit-findings contract (BE-7215). + +Two pins: + + * the severity vocabulary — ``cql.engine.FATAL_FINDING_CODES`` and + ``finding_severity`` — so a code cannot change fatality unnoticed; + * the language-neutral conformance corpus + (``tests/data/edit_findings_conformance.json``) replays against the real + engine, so the executable contract a non-Python consumer inherits cannot + rot. That file is the portable form of this contract: a Go or TypeScript + caller replays it instead of re-deriving the semantics. + +Plus the outcome rule itself: a fatal finding refuses the edit and leaves the +document untouched, which is the behaviour the ~750 lines of Go in +`services/agent/internal/loop/enumgate.go` existed to synthesize. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from comfy_cli import workflow_ops +from comfy_cli.cql.engine import ( + FATAL_FINDING_CODES, + SEVERITY_ERROR, + SEVERITY_INFO, + SEVERITY_WARNING, + Graph, + finding_severity, +) + +CORPUS = Path(__file__).resolve().parents[1] / "data" / "edit_findings_conformance.json" + +_VALID_SEVERITIES = {SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO} + + +class TestSeverityVocabulary: + """The severity table is `FATAL_FINDING_CODES` + `finding_severity`. + + Pinned here rather than against a prose doc: these assertions are what a + non-Python consumer needs to agree with, and the conformance corpus below is + their executable form. + """ + + def test_every_fatal_code_reports_error_severity(self): + for code in FATAL_FINDING_CODES: + assert finding_severity(code) == SEVERITY_ERROR, code + + def test_severity_values_are_the_declared_three(self): + assert _VALID_SEVERITIES == {"error", "warning", "info"} + + def test_unlisted_code_is_advisory_never_fatal(self): + """A finding added later cannot become silently fatal.""" + assert finding_severity("some_future_code") == SEVERITY_WARNING + assert "some_future_code" not in FATAL_FINDING_CODES + + def test_fatal_set_is_exactly_the_four_run_time_rejections(self): + """Locked deliberately: each is a value `validate_inputs` rejects, which + is why the edit path refuses it rather than warning about it.""" + assert set(FATAL_FINDING_CODES) == { + "unknown_enum_value", + "no_options_available", + "below_min", + "above_max", + } + + +# --------------------------------------------------------------------------- +# conformance corpus — the portable form of the contract +# --------------------------------------------------------------------------- + + +def _load_corpus() -> dict[str, Any]: + assert CORPUS.is_file(), f"conformance corpus missing: {CORPUS}" + return json.loads(CORPUS.read_text(encoding="utf-8")) + + +CORPUS_DATA = _load_corpus() +_GRAPH = Graph.from_object_info(CORPUS_DATA["object_info"]) + + +@pytest.mark.parametrize("case", CORPUS_DATA["cases"], ids=lambda c: c["name"]) +def test_conformance_corpus(case: dict[str, Any]): + expect = case["expect"] + call = lambda: workflow_ops._validate_widget( # noqa: E731 + _GRAPH, case["class_type"], case["widget"], case["value"] + ) + + if not expect["fatal"]: + assert call() == expect.get("findings", []), case["name"] + return + + with pytest.raises(workflow_ops.FatalFindingError) as ei: + call() + f = ei.value.finding + + assert f["code"] == expect["code"] + assert f["severity"] == expect["severity"] + # The operand is a FIELD. This is the assertion that makes regexing the + # message unnecessary, and it is the reason the corpus exists. + assert f["value"] == expect["value"] + if "field" in expect: + assert f["field"] == expect["field"] + if expect.get("has_valid_options"): + assert f["valid_options"] + if "did_you_mean_contains" in expect: + assert expect["did_you_mean_contains"] in f.get("did_you_mean", []) + + +class TestOutcomeRule: + """A fatal finding refuses the edit and leaves the document alone.""" + + @staticmethod + def _workflow() -> dict: + return { + "nodes": [{"id": 1, "type": "Loader", "widgets_values": ["v1-5-pruned-emaonly.safetensors", 20, ""]}], + "links": [], + "last_node_id": 1, + "last_link_id": 0, + } + + def test_fatal_edit_leaves_the_document_byte_identical(self): + wf = self._workflow() + before = json.loads(json.dumps(wf)) + with pytest.raises(workflow_ops.FatalFindingError): + workflow_ops.set_widget(wf, _GRAPH, 1, "ckpt_name", "nope.safetensors") + assert wf == before + + def test_non_fatal_edit_still_applies(self): + wf = self._workflow() + wf2, op = workflow_ops.set_widget(wf, _GRAPH, 1, "ckpt_name", "sd_xl_base_1.0.safetensors") + assert op["value"] == "sd_xl_base_1.0.safetensors" + assert wf2["nodes"][0]["widgets_values"][0] == "sd_xl_base_1.0.safetensors" + + +class TestDocumentCompleteness: + """A serialized document carries the save-format keys consumers assume.""" + + def test_absent_keys_are_filled_and_derived_from_content(self): + wf = {"nodes": [{"id": 7, "type": "X"}], "links": [[3, 7, 0, 9, 0, "IMAGE"]]} + workflow_ops.strip_internal(wf) + assert wf["version"] == workflow_ops.SAVE_FORMAT_VERSION + assert wf["last_node_id"] == 7 + assert wf["last_link_id"] == 3 + + def test_existing_values_are_never_rewritten(self): + wf = {"nodes": [], "links": [], "version": 0.4, "last_node_id": 999, "last_link_id": 42} + workflow_ops.strip_internal(wf) + assert (wf["last_node_id"], wf["last_link_id"]) == (999, 42) diff --git a/tests/data/edit_findings_conformance.json b/tests/data/edit_findings_conformance.json new file mode 100644 index 000000000..eac94b266 --- /dev/null +++ b/tests/data/edit_findings_conformance.json @@ -0,0 +1,119 @@ +{ + "_comment": "Language-neutral conformance corpus for docs/edit-findings-v1.md. Each case is (object_info, class_type, widget, value) -> expected outcome. A consumer in any language replays these to confirm it agrees with the engine instead of re-deriving the semantics. Run by tests/comfy_cli/test_edit_findings_contract.py.", + "version": 1, + "object_info": { + "Loader": { + "input": { + "required": { + "ckpt_name": [["v1-5-pruned-emaonly.safetensors", "sd_xl_base_1.0.safetensors"]], + "steps": ["INT", { "default": 20, "min": 1, "max": 100 }], + "label": ["STRING", { "default": "" }] + } + }, + "output": ["MODEL"], + "output_name": ["MODEL"], + "category": "loaders", + "display_name": "Loader", + "python_module": "nodes" + }, + "EmptyEnumLoader": { + "input": { "required": { "unet_name": [[]] } }, + "output": ["MODEL"], + "output_name": ["MODEL"], + "category": "loaders", + "display_name": "Empty Enum Loader", + "python_module": "nodes" + }, + "Uploader": { + "input": { "required": { "image": [["sample.png"], { "image_upload": true }] } }, + "output": ["IMAGE"], + "output_name": ["IMAGE"], + "category": "image", + "display_name": "Uploader", + "python_module": "nodes" + } + }, + "cases": [ + { + "name": "valid enum member applies cleanly", + "class_type": "Loader", + "widget": "ckpt_name", + "value": "sd_xl_base_1.0.safetensors", + "expect": { "fatal": false, "findings": [] } + }, + { + "name": "unknown enum on a non-upload port is FATAL and names the operand", + "class_type": "Loader", + "widget": "ckpt_name", + "value": "sdxl_hallucinated.ckpt", + "expect": { + "fatal": true, + "code": "unknown_enum_value", + "severity": "error", + "value": "sdxl_hallucinated.ckpt", + "field": "ckpt_name", + "has_valid_options": true + } + }, + { + "name": "near-miss typo carries engine-side suggestions", + "class_type": "Loader", + "widget": "ckpt_name", + "value": "sd_xl_base_1.0.safetensor", + "expect": { + "fatal": true, + "code": "unknown_enum_value", + "severity": "error", + "value": "sd_xl_base_1.0.safetensor", + "did_you_mean_contains": "sd_xl_base_1.0.safetensors" + } + }, + { + "name": "declared-but-empty enum is FATAL (nothing installed)", + "class_type": "EmptyEnumLoader", + "widget": "unet_name", + "value": "anything.safetensors", + "expect": { + "fatal": true, + "code": "no_options_available", + "severity": "error", + "value": "anything.safetensors" + } + }, + { + "name": "upload-backed port never enum-checks — a fresh upload is legitimately absent", + "class_type": "Uploader", + "widget": "image", + "value": "just-uploaded-9f2c.png", + "expect": { "fatal": false, "findings": [] } + }, + { + "name": "numeric below catalog min is FATAL", + "class_type": "Loader", + "widget": "steps", + "value": 0, + "expect": { "fatal": true, "code": "below_min", "severity": "error", "value": 0 } + }, + { + "name": "numeric above catalog max is FATAL", + "class_type": "Loader", + "widget": "steps", + "value": 999, + "expect": { "fatal": true, "code": "above_max", "severity": "error", "value": 999 } + }, + { + "name": "in-range numeric is clean", + "class_type": "Loader", + "widget": "steps", + "value": 20, + "expect": { "fatal": false, "findings": [] } + }, + { + "name": "unconstrained STRING is never enum-checked", + "class_type": "Loader", + "widget": "label", + "value": "anything at all", + "expect": { "fatal": false, "findings": [] } + } + ] +} From 56a37827f3c51ccf6add3e759a6bebf6cdfe2c52 Mon Sep 17 00:00:00 2001 From: kishore Date: Fri, 14 Aug 2026 16:21:41 -0700 Subject: [PATCH 49/53] style: format test_pr.py under the ruff version CI pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruff_check failed on this branch while passing on main. The repo's CI pins ruff==0.15.15 (.github/workflows/ruff.yml) and runs lint THEN format; the lint half passes, the format half did not — one docstring in tests/comfy_cli/command/github/test_pr.py is spaced differently by 0.15.15's formatter than by older versions. Worth recording because it cost time: locally I had ruff 0.12.7, which reports 19 UP038 findings that 0.15.15 does not, and does NOT flag this docstring. So the local signal was wrong in both directions — it showed 19 phantom errors and hid the one real failure. Verify lint/format against the pinned version (`uvx ruff@0.15.15`), not whatever is on PATH. Formatting only; test_pr.py still passes 82/82. --- tests/comfy_cli/command/github/test_pr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/comfy_cli/command/github/test_pr.py b/tests/comfy_cli/command/github/test_pr.py index b2f204fe9..0778ca99a 100644 --- a/tests/comfy_cli/command/github/test_pr.py +++ b/tests/comfy_cli/command/github/test_pr.py @@ -181,7 +181,7 @@ def test_find_pr_by_branch_error(self, mock_get): @patch("requests.get") def test_find_pr_by_branch_rate_limit(self, mock_get): - """A rate-limited 403 surfaces as GitHubRateLimitError, not a silent "no PR found\" """ + """A rate-limited 403 surfaces as GitHubRateLimitError, not a silent "no PR found\"""" mock_response = Mock() mock_response.status_code = 403 mock_response.headers = {"x-ratelimit-remaining": "0", "x-ratelimit-reset": "1777415867"} From 34d46d4e1868ee41b8cf27ef66fb92d9c568b7b4 Mon Sep 17 00:00:00 2001 From: kishore Date: Mon, 17 Aug 2026 14:21:06 -0700 Subject: [PATCH 50/53] fix(convert): companion guard peeks the next WIDGET input, not the next declared one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implicit-seed control_after_generate guard refuses to consume a control keyword when the next widget is a COMBO that legitimately lists it as an option. The peek handed it the next DECLARED input — so one connection-only input between an unflagged seed INT and the COMBO defeated the refusal and the combo's real saved value was consumed as a phantom marker, shifting every later widget. Scan forward to the next widget-owning input instead, at both the top level and inside dynamic-combo recursion. Co-Authored-By: Claude Fable 5 --- comfy_cli/workflow_to_api.py | 16 +++++- .../test_seed_companion_next_widget.py | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/comfy_cli/test_seed_companion_next_widget.py diff --git a/comfy_cli/workflow_to_api.py b/comfy_cli/workflow_to_api.py index 45f1ddefc..db4a06879 100644 --- a/comfy_cli/workflow_to_api.py +++ b/comfy_cli/workflow_to_api.py @@ -1114,6 +1114,18 @@ def _schema_widget_pairs(schema: Any, widget_values: list[Any]) -> list[tuple[st pairs: list[tuple[str, Any]] = [] vidx = 0 + def next_widget_spec(entries: list[tuple[str, Any]], start: int) -> Any: + # The spec of the next WIDGET-owning input, not the next declared one. + # Connection inputs own no widgets_values slot, so the slot following a + # seed belongs to the next widget — handing the companion guard a + # connection's spec defeated its COMBO-membership refusal, and one + # connection input between an unflagged seed INT and a COMBO made the + # guard consume the combo's real value as a phantom marker. + for _n, s in entries[start:]: + if _is_widget_input(s)[0]: + return s + return None + def consume(name: str, spec: Any, depth: int = 0, next_spec: Any = None) -> None: # ``next_spec`` is the schema of the widget that follows this one at the # same level. The implicit-seed rule matches any INT whose name contains @@ -1150,7 +1162,7 @@ def consume(name: str, spec: Any, depth: int = 0, next_spec: Any = None) -> None ) else: for j, (sub_name, sub_spec) in enumerate(subs): - consume(sub_name, sub_spec, depth + 1, subs[j + 1][1] if j + 1 < len(subs) else None) + consume(sub_name, sub_spec, depth + 1, next_widget_spec(subs, j + 1)) elif vidx < len(widget_values) and _has_control_after_generate_companion( name, spec, widget_values[vidx], next_spec ): @@ -1164,7 +1176,7 @@ def consume(name: str, spec: Any, depth: int = 0, next_spec: Any = None) -> None continue ordered.extend(section_def.items()) for i, (input_name, input_spec) in enumerate(ordered): - consume(input_name, input_spec, 0, ordered[i + 1][1] if i + 1 < len(ordered) else None) + consume(input_name, input_spec, 0, next_widget_spec(ordered, i + 1)) return pairs diff --git a/tests/comfy_cli/test_seed_companion_next_widget.py b/tests/comfy_cli/test_seed_companion_next_widget.py new file mode 100644 index 000000000..ff89e7cbb --- /dev/null +++ b/tests/comfy_cli/test_seed_companion_next_widget.py @@ -0,0 +1,53 @@ +"""The implicit-seed companion guard must peek at the next WIDGET, not the next input. + +``_has_control_after_generate_companion``'s implicit path refuses to consume a +control keyword when the NEXT widget is a COMBO that legitimately lists that +keyword as an option (the value is the combo's own selection, not a phantom +companion). The peek used to hand it the next *declared* input — so one +connection-only input sitting between an unflagged seed INT and the COMBO +defeated the guard and the combo's real value was consumed as a marker, +shifting every later widget. +""" + +from __future__ import annotations + +from comfy_cli.workflow_to_api import _schema_widget_pairs + +# An unflagged seed-like INT, then a CONNECTION input, then a COMBO whose legal +# values include a control keyword. widgets_values only carries widget slots: +# [seed, mode] — "fixed" here is mode's real saved selection. +_SCHEMA = { + "input": { + "required": { + "seed": ["INT", {"default": 0}], + "mask": ["MASK"], + "mode": [["fixed", "loop"], {}], + }, + }, + "input_order": {"required": ["seed", "mask", "mode"]}, +} + + +def test_connection_input_between_seed_and_combo_does_not_eat_the_combo_value(): + pairs = _schema_widget_pairs(_SCHEMA, [42, "fixed"]) + assert ("seed", 42) in pairs + assert ("mode", "fixed") in pairs + + +def test_phantom_companion_still_dropped_when_next_widget_is_not_that_combo(): + # Same shape but the trailing value is NOT a legal option of the next + # widget — that is a genuine frontend companion and must be dropped. + schema = { + "input": { + "required": { + "seed": ["INT", {"default": 0}], + "mask": ["MASK"], + "steps": ["INT", {"default": 20}], + }, + }, + "input_order": {"required": ["seed", "mask", "steps"]}, + } + pairs = _schema_widget_pairs(schema, [42, "randomize", 20]) + assert ("seed", 42) in pairs + assert ("steps", 20) in pairs + assert not any(v == "randomize" for _n, v in pairs) From f46bf2c4e29425c13ab5bddc64d11fc7f1780753 Mon Sep 17 00:00:00 2001 From: kishore Date: Mon, 17 Aug 2026 14:21:32 -0700 Subject: [PATCH 51/53] fix(cql): one control-companion predicate for every order surface; project dict widgets_values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guard-not-propagated fixes in the engine: 1. The implicit control_after_generate rule (frontend useIntWidget companions a seed-like INT regardless of the schema flag) lived in _has_control_after_generate_slot but only the expansion path called it — widget_order, widget_order_default and widget_defaults checked the raw schema flag. The exported widget catalog (sha256-versioned, the doc host's name<->index contract) was therefore off by one for every implicitly-companioned node: a consumer writing by index landed in the marker slot. All four surfaces now share the predicate, and its implicit rule matches the converter's ('seed' substring, case-insensitive — Tripo image_seed/model_seed, Rodin3D Seed, rand_seed, variation_seed all ship unflagged). catalog_version hashes change for affected classes. 2. _widgets_as_positional: the VHS-style named-dict widgets_values form is projected onto the class's default widget order wherever a catalog is in scope — named values land at their schema positions and SURVIVE writes, instead of the whole dict reading as [] (one set_widget destroyed every sibling value on the node; slots displayed real values as unset). Co-Authored-By: Claude Fable 5 --- comfy_cli/cql/engine.py | 50 ++++++++-- .../command/test_nodes_widget_catalog.py | 2 +- tests/comfy_cli/cql/test_engine.py | 7 +- .../comfy_cli/cql/test_implicit_seed_order.py | 97 +++++++++++++++++++ tests/comfy_cli/test_widgets_values_dict.py | 13 +-- 5 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 tests/comfy_cli/cql/test_implicit_seed_order.py diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index e339960fa..23a651bc5 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -466,8 +466,15 @@ def _has_control_after_generate_slot(port: Port) -> bool: schema-level test.""" if port.options.control_after_generate: return True + # Same seed-like rule as the converter's companion guard: partner nodes + # name the widget every which way — ``image_seed``/``model_seed`` (Tripo), + # ``Seed`` (Rodin3D), ``rand_seed``, ``noise_seed_sde``, ``variation_seed`` + # — and several ship it UNFLAGGED, yet the frontend still appends the + # companion. An exact ``seed``/``noise_seed`` match here made the exported + # widget catalog off by one for every such node, so a name<->index + # consumer wrote into the marker slot. leaf_name = port.name.rsplit(".", 1)[-1] - return port.type == "INT" and leaf_name in ("seed", "noise_seed") + return port.type == "INT" and "seed" in leaf_name.lower() def _is_link(type_id: str, is_enum: bool, force_input: bool) -> bool: @@ -1183,7 +1190,7 @@ def widget_order(self, class_name: str) -> list[str]: if p.is_link: continue order.append(p.name) - if p.options.control_after_generate: + if _has_control_after_generate_slot(p): order.append("control_after_generate") return order @@ -1208,7 +1215,7 @@ def widget_order_default(self, class_name: str) -> list[str]: order.append(p.name) if p.dynamic_options: order.extend(_dynamic_sub_widget_names(p.name, p.dynamic_options)) - if p.options.control_after_generate: + if _has_control_after_generate_slot(p): order.append("control_after_generate") return order @@ -1243,7 +1250,7 @@ def widget_defaults(self, class_name: str) -> dict[str, Any]: out[p.name] = p.enum_values[0] else: out[p.name] = None - if p.options.control_after_generate: + if _has_control_after_generate_slot(p): out["control_after_generate"] = "fixed" return out @@ -2272,6 +2279,30 @@ def _widgets_as_list(widgets_values: Any) -> list[Any]: return list(widgets_values) if isinstance(widgets_values, list) else [] +def _widgets_as_positional(widgets_values: Any, graph: Graph | None, class_type: str) -> list[Any]: + """Positional view of ``widgets_values`` that PRESERVES the named-dict form. + + Lists pass through unchanged. The VHS-style dict serialization is projected + onto the class's default widget order when the catalog knows it — each + named value lands at its schema position, names the schema doesn't know are + dropped (they have no positional home), and missing names read as ``None``. + Without a catalog (or an unknown class) this degrades to + :func:`_widgets_as_list`'s "no positional values known" behavior. + + Use this wherever a graph is in scope (set-widget, apply/replay, capture, + slot writes): reading a dict as ``[]`` there meant one write silently + destroyed every sibling value on the node (``{"width": 768, "height": 512, + "batch_size": 1}`` + set ``batch_size`` → ``[None, None, 4]``). + """ + if isinstance(widgets_values, list): + return list(widgets_values) + if isinstance(widgets_values, dict) and graph is not None: + order = graph.widget_order_default(class_type) + if order: + return [widgets_values.get(name) for name in order] + return _widgets_as_list(widgets_values) + + # A dynamic combo may nest another dynamic combo among its sub-inputs. Real # schemas are one or two levels deep; the cap defends the expansion walk # against a pathological/malicious object_info entry. @@ -2362,7 +2393,7 @@ def _node_widget_slots(node: dict, prefix: str, graph: Graph) -> list[dict]: m = graph.node(node_type) if m is None: return [] - widgets = _widgets_as_list(node.get("widgets_values")) + widgets = _widgets_as_positional(node.get("widgets_values"), graph, node_type) slots: list[dict] = [] for idx, entry in enumerate(_expand_widget_entries(m, widgets)): if entry.port is None: # control_after_generate marker — not a slot @@ -2508,7 +2539,7 @@ def _resolve_proxy_value(instance: dict, subgraph: dict, input_name: str, graph: if not isinstance(inode, dict) or str(inode.get("id", "")) != interior_id: continue interior_class = inode.get("type", "") - widgets = _widgets_as_list(inode.get("widgets_values")) + widgets = _widgets_as_positional(inode.get("widgets_values"), graph, interior_class) order = graph.widget_order_for_node(interior_class, widgets) try: idx = order.index(name) @@ -2536,7 +2567,12 @@ def _write_widget(node: dict, input_name: str, value: Any, graph: Graph, *, exte m = graph.node(node_type) if m is None: raise ValueError(f"unknown node type {node_type!r} for node {node.get('id')}") - widgets = _widgets_as_list(node.get("widgets_values")) + widgets = _widgets_as_positional(node.get("widgets_values"), graph, node_type) + if not isinstance(node.get("widgets_values"), list): + # Persist the positional projection so downstream re-reads (the + # dynamic-combo selector path re-reads from the node) see the same + # values this write is about to index against. + node["widgets_values"] = widgets order = graph.widget_order_for_node(node_type, widgets) try: widget_idx = order.index(input_name) diff --git a/tests/comfy_cli/command/test_nodes_widget_catalog.py b/tests/comfy_cli/command/test_nodes_widget_catalog.py index 16412a841..0579eb6f0 100644 --- a/tests/comfy_cli/command/test_nodes_widget_catalog.py +++ b/tests/comfy_cli/command/test_nodes_widget_catalog.py @@ -229,7 +229,7 @@ def test_link_only_class_keeps_an_empty_order(self, patched_loader, capsys): def test_dynamic_combo_sub_widgets_expand(self, patched_loader, capsys): types = _run(["widget-catalog"], capsys)["data"]["types"] - assert types["DynNode"]["widget_order"] == ["model", "model.resolution", "seed"] + assert types["DynNode"]["widget_order"] == ["model", "model.resolution", "seed", "control_after_generate"] # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 5be79236f..cd84d6b26 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -379,11 +379,14 @@ def test_static_order_is_selector_only(self): node actually carries. First-key expansion moved to `widget_order_default` (what a fresh node has, and what the catalog publishes).""" g = self._dyn_graph() - assert g.widget_order("DynNode") == ["model", "seed"] + # The trailing control_after_generate is implicit: the frontend's + # useIntWidget always companions a seed-like INT, schema flag or not — + # every order surface must agree with the expansion path on markers. + assert g.widget_order("DynNode") == ["model", "seed", "control_after_generate"] def test_default_order_uses_first_key(self): g = self._dyn_graph() - assert g.widget_order_default("DynNode") == ["model", "model.res", "seed"] + assert g.widget_order_default("DynNode") == ["model", "model.res", "seed", "control_after_generate"] def test_node_order_expands_selected_key(self): g = self._dyn_graph() diff --git a/tests/comfy_cli/cql/test_implicit_seed_order.py b/tests/comfy_cli/cql/test_implicit_seed_order.py new file mode 100644 index 000000000..fd638765a --- /dev/null +++ b/tests/comfy_cli/cql/test_implicit_seed_order.py @@ -0,0 +1,97 @@ +"""The implicit control_after_generate rule must reach EVERY widget-order surface. + +The frontend's ``useIntWidget`` composable appends a ``control_after_generate`` +companion widget after seed-like INT inputs even when the schema omits the +flag — partner nodes ship ``image_seed``/``model_seed``/``Seed``/``rand_seed`` +unflagged (Tripo, Rodin3D, …). The UI→API converter already models this +(``workflow_to_api._has_control_after_generate_companion``); the engine's +``_has_control_after_generate_slot`` predicate exists for the same purpose but +``widget_order`` / ``widget_order_default`` / ``widget_defaults`` were checking +the raw schema flag instead of the predicate — so the exported widget catalog +(sha256-versioned, consumed by the CRDT doc host for name<->index mapping) was +off by one for every implicitly-companioned node: a consumer writing by index +landed in the control marker slot. Silent canvas corruption. +""" + +from __future__ import annotations + +import pytest + +from comfy_cli.cql.engine import Graph + +_OBJECT_INFO = { + # Tripo shape: unflagged seed-like INT, then a combo the off-by-one would eat. + "TripoLike": { + "input": { + "required": { + "image_seed": ["INT", {"default": 0}], + "style": [["clay", "steel"], {}], + }, + }, + "input_order": {"required": ["image_seed", "style"]}, + "output": [], + "output_name": [], + "category": "test", + "display_name": "TripoLike", + "python_module": "nodes", + }, + # Explicitly flagged — the path that always worked; pins no regression. + "KSamplerLike": { + "input": { + "required": { + "seed": ["INT", {"default": 0, "control_after_generate": True}], + "steps": ["INT", {"default": 20}], + }, + }, + "input_order": {"required": ["seed", "steps"]}, + "output": [], + "output_name": [], + "category": "test", + "display_name": "KSamplerLike", + "python_module": "nodes", + }, + # A non-seed INT must NOT grow a companion. + "PlainInt": { + "input": {"required": {"steps": ["INT", {"default": 20}]}}, + "input_order": {"required": ["steps"]}, + "output": [], + "output_name": [], + "category": "test", + "display_name": "PlainInt", + "python_module": "nodes", + }, +} + + +@pytest.fixture +def graph() -> Graph: + return Graph.from_object_info(_OBJECT_INFO) + + +EXPECTED_TRIPO = ["image_seed", "control_after_generate", "style"] + + +class TestImplicitSeedCompanionInEveryOrderSurface: + def test_widget_order(self, graph: Graph): + assert graph.widget_order("TripoLike") == EXPECTED_TRIPO + + def test_widget_order_default(self, graph: Graph): + assert graph.widget_order_default("TripoLike") == EXPECTED_TRIPO + + def test_widget_order_for_node(self, graph: Graph): + assert graph.widget_order_for_node("TripoLike", [42, "fixed", "clay"]) == EXPECTED_TRIPO + + def test_widget_defaults_carry_the_marker(self, graph: Graph): + assert graph.widget_defaults("TripoLike").get("control_after_generate") == "fixed" + + def test_explicit_flag_unchanged(self, graph: Graph): + assert graph.widget_order_default("KSamplerLike") == ["seed", "control_after_generate", "steps"] + + def test_plain_int_gets_no_companion(self, graph: Graph): + assert graph.widget_order_default("PlainInt") == ["steps"] + + def test_all_three_order_surfaces_agree(self, graph: Graph): + """The three order functions may disagree only about dynamic-combo + expansion — never about control markers.""" + for cls in _OBJECT_INFO: + assert graph.widget_order(cls) == graph.widget_order_default(cls) == graph.widget_order_for_node(cls, []) diff --git a/tests/comfy_cli/test_widgets_values_dict.py b/tests/comfy_cli/test_widgets_values_dict.py index 7b6af8f42..2398fbbe2 100644 --- a/tests/comfy_cli/test_widgets_values_dict.py +++ b/tests/comfy_cli/test_widgets_values_dict.py @@ -73,8 +73,9 @@ def test_full_dict_does_not_crash(self, graph: Graph): slots = _extract_frontend_slots(wf, graph) names = {s["name"] for s in slots} assert names == {"width", "height", "batch_size"} - # Values are unknown (treated as empty), not silently wrong. - assert all(s["current_value"] is None for s in slots) + # The named-dict values are projected onto their schema positions — + # slots show the node's REAL values instead of pretending they're unset. + assert {s["name"]: s["current_value"] for s in slots} == {"width": 512, "height": 512, "batch_size": 1} def test_partial_dict_does_not_crash(self, graph: Graph): wf = _dict_widget_node({"width": 512}) @@ -92,9 +93,9 @@ class TestSetWidgetToleratesDictWidgetsValues: def test_apply_one_slot_grows_past_a_short_dict(self, graph: Graph): wf = _dict_widget_node({"width": 512}) _apply_one_slot(wf, "7.batch_size", 4, graph) - # The dict is replaced by a real positional list; the write lands at - # batch_size's schema position. - assert wf["nodes"][0]["widgets_values"] == [None, None, 4] + # The dict is projected onto a real positional list — the known value + # survives at its schema position and the write lands at batch_size's. + assert wf["nodes"][0]["widgets_values"] == [512, None, 4] def test_apply_one_slot_within_dict_len(self, graph: Graph): wf = _dict_widget_node({"width": 512, "height": 512, "batch_size": 1}) @@ -110,4 +111,4 @@ def test_set_widget_public_api_does_not_crash(self, graph: Graph): def test_set_widget_grows_past_a_short_dict(self, graph: Graph): wf = _dict_widget_node({"width": 512}) new_wf, op = W.set_widget(wf, graph, 7, "batch_size", 4) - assert new_wf["nodes"][0]["widgets_values"] == [None, None, 4] + assert new_wf["nodes"][0]["widgets_values"] == [512, None, 4] From b352b5ec8ad3ec06c77609bdf2b0f7533baa79c6 Mon Sep 17 00:00:00 2001 From: kishore Date: Mon, 17 Aug 2026 14:21:32 -0700 Subject: [PATCH 52/53] fix(ops): replay totality under slot drift; canonical() id normalization; dict-widget writes; applied_count=0 on failure; validate uses the resilient loader (amendment v1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the apply path against shape-drifted documents found a family of guards each added carefully in one place and missing from a sibling path. All are apply/replay semantics conformance consumers mirror — recorded as docs/op-vocabulary-v1.md amendment v1.3. * _apply_connect: a concrete connect whose to_slot is out of range (or malformed) raised IndexError AFTER _lww_commit and BEFORE the op_id append — a retry of the identical op then lost to its own stamp and the connect was silently dropped forever. Slot drift is now as total as node drift: no-op, no register claim. A vanished SOURCE slot gets the deleted-source treatment (register claim stands, input stays empty). apply_op additionally restores the stamp map when any handler raises, so no exception path can leave a stamp committed without its op_id recorded. * canonical(): sorted nodes/links by raw id and keyed the slot-identity lookup by raw id — TypeError on the int/string mix amendment v1.2 declares legal, and '7' vs 7 silently skipped link rewriting. Every key/sort normalizes with str(), matching _write_target. * _apply_inputcount_bump wrote an integer key INTO a dict-shaped widgets_values (inputcount left stale beside a garbage key); capture crashed with a bare KeyError on the same shape. Both go through the positional projection now; the mint-path set_widget sites do too. * apply_specs reported the discarded spec count as applied_count; the frozen doc (§4) says 0 on failure — nothing is written. Code now complies. * validate loaded object_info via a direct Graph.load, silently dropping COMFY_OBJECT_INFO_FILE, the cloud TTL cache, the forced-refresh retry and the stale fallback. It now routes through resilient_load_object_info like every other consumer. Co-Authored-By: Claude Fable 5 --- comfy_cli/command/workflow.py | 12 +- comfy_cli/workflow_ops.py | 111 +++++-- docs/op-vocabulary-v1.md | 79 +++++ tests/comfy_cli/command/test_ack_flag.py | 8 +- .../command/test_validate_command.py | 37 +++ tests/comfy_cli/test_apply_replay_drift.py | 280 ++++++++++++++++++ 6 files changed, 496 insertions(+), 31 deletions(-) create mode 100644 tests/comfy_cli/test_apply_replay_drift.py diff --git a/comfy_cli/command/workflow.py b/comfy_cli/command/workflow.py index 57a4f8232..97fc71f09 100644 --- a/comfy_cli/command/workflow.py +++ b/comfy_cli/command/workflow.py @@ -1593,7 +1593,17 @@ def validate_api_workflow( host, port = resolve_host_port(host, port) try: - graph = Graph.load(mode=mode, input_path=input_path, host=host, port=port) + # Through the resilient loader — NOT a direct `Graph.load` — so validate + # honors the same chain as every other consumer: `--input` > + # COMFY_OBJECT_INFO_FILE > cloud TTL cache > live fetch (+forced-refresh + # retry) > stale cache. A direct load silently dropped the env-pinned + # offline catalog and every fallback for the one command an agent runs + # before every submit. + from comfy_cli.cql.loader import resilient_load_object_info + + raw = resilient_load_object_info(mode=mode, host=host, port=port, input_path=input_path) + graph = Graph.from_object_info(raw) + graph._try_default_annotations() except LoadError as e: renderer.error( code="cql_no_graph", diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 853290267..3c4dbd697 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -573,7 +573,7 @@ def _set_widget_impl( target = _navigate_subgraph_path(workflow, segments) # read-only: current value + schema inner_type = target.get("type", "") value, norm_note = _normalize_combo(graph, inner_type, inner_widget, value) - cur = _engine._widgets_as_list(target.get("widgets_values")) + cur = _engine._widgets_as_positional(target.get("widgets_values"), graph, inner_type) order = graph.widget_order_for_node(inner_type, cur) old = None if inner_widget in order: @@ -599,7 +599,7 @@ def _set_widget_impl( node = _require(workflow, node_id) class_type = node.get("type", "") - widgets = _engine._widgets_as_list(node.get("widgets_values")) + widgets = _engine._widgets_as_positional(node.get("widgets_values"), graph, class_type) idx = _widget_index(graph, class_type, widget, widgets) # raises on unknown widget name value, norm_note = _normalize_combo(graph, class_type, widget, value) old = widgets[idx] if idx < len(widgets) else None @@ -1233,7 +1233,9 @@ def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | N if n.get("pos"): add["at"] = n["pos"] ops.append(add) - widgets = n.get("widgets_values") or [] + from comfy_cli.cql import engine as _engine + + widgets = _engine._widgets_as_positional(n.get("widgets_values"), graph, class_type) order = graph.widget_order_for_node(class_type, widgets) defaults = graph.widget_defaults(class_type) for i, wname in enumerate(order): @@ -1400,7 +1402,11 @@ def apply_specs( # variables at raise time; guard for a non-dict spec. err.spec_index = i # type: ignore[attr-defined] err.spec_op = spec.get("op") if isinstance(spec, dict) else None # type: ignore[attr-defined] - err.applied_count = len(ops) # type: ignore[attr-defined] + # The whole batch was discarded — nothing persisted. Reporting the + # specs that applied-then-were-discarded taught a merge consumer that + # k-1 ops survived (docs/op-vocabulary-v1.md: "``applied_count`` is + # always 0 on failure — nothing is written"). + err.applied_count = 0 # type: ignore[attr-defined] raise err from e return workflow, ops, aliases @@ -1417,20 +1423,30 @@ def apply_op(workflow: dict, op: dict, graph) -> dict: if op["op_id"] in applied: return workflow kind = op["op"] - if kind == "add_node": - _apply_add_node(workflow, op) - elif kind == "set_widget": - _apply_set_widget(workflow, op, graph) - elif kind == "connect": - _apply_connect(workflow, op, graph) - elif kind == "delete_node": - _apply_delete_node(workflow, op) - elif kind == "clear": - _apply_clear(workflow, op) - elif kind == "reset_doc": - _apply_reset_doc(workflow, op) - else: - raise ValueError(f"unknown op {kind!r}") + # Snapshot the LWW bookkeeping so an exception escaping a handler cannot + # leave a stamp committed WITHOUT its op_id recorded below. That pairing is + # the poison state: a retry of the identical op loses to the failed + # attempt's own stamp and is silently dropped forever. + stamps_before = dict(workflow.get("_widget_stamps") or {}) + try: + if kind == "add_node": + _apply_add_node(workflow, op) + elif kind == "set_widget": + _apply_set_widget(workflow, op, graph) + elif kind == "connect": + _apply_connect(workflow, op, graph) + elif kind == "delete_node": + _apply_delete_node(workflow, op) + elif kind == "clear": + _apply_clear(workflow, op) + elif kind == "reset_doc": + _apply_reset_doc(workflow, op) + else: + raise ValueError(f"unknown op {kind!r}") + except BaseException: + if stamps_before or "_widget_stamps" in workflow: + workflow["_widget_stamps"] = stamps_before + raise # NOT ``applied.append`` — ``_apply_reset_doc`` REPLACES ``_applied_ops`` # with a fresh list (that is what makes it a history barrier), so the local # binding above is stale for that kind and the reset's own op_id would be @@ -1480,7 +1496,7 @@ def _apply_set_widget(workflow: dict, op: dict, graph) -> None: return # target concurrently deleted => no-op (delete wins). from comfy_cli.cql import engine as _engine - widgets = _engine._widgets_as_list(node.get("widgets_values")) + widgets = _engine._widgets_as_positional(node.get("widgets_values"), graph, node.get("type", "")) node["widgets_values"] = widgets idx = _widget_index(graph, node.get("type", ""), op["widget"], widgets) if idx >= len(widgets): @@ -1512,7 +1528,13 @@ def _apply_inputcount_bump(workflow: dict, dst: dict, op: dict, graph, widget: s } if not _lww_gate(workflow, widget_op): return - widgets = dst.setdefault("widgets_values", []) + from comfy_cli.cql import engine as _engine + + # A VHS-style dict form must be projected, not indexed into: setdefault + # returned the dict itself, and the positional write below installed an + # integer key into it — leaving ``inputcount`` stale next to a garbage key. + widgets = _engine._widgets_as_positional(dst.get("widgets_values"), graph, dst.get("type", "")) + dst["widgets_values"] = widgets idx = _widget_index(graph, dst.get("type", ""), widget, widgets) if idx >= len(widgets): widgets.extend([None] * (idx + 1 - len(widgets))) @@ -1585,6 +1607,22 @@ def _apply_connect(workflow: dict, op: dict, graph) -> None: _apply_inputcount_bump(workflow, dst, op, graph, inputcount["widget"], inputcount["value"]) else: to_idx = op["to_slot"] + ins = dst.get("inputs") + # Slot drift: a replay against a document whose destination SLOT no + # longer exists (or never did — a node minted from a different catalog + # generation) must be as total as a vanished node. There is no register + # to claim: claiming it here would poison the target — the failed op's + # own stamp would outrank a retry of the identical op, silently losing + # the connect forever even after the document is repaired. + if ( + not isinstance(ins, list) + or not isinstance(to_idx, int) + or isinstance(to_idx, bool) + or to_idx < 0 + or to_idx >= len(ins) + or not isinstance(ins[to_idx], dict) + ): + return # --- The concrete-input LWW register (op-vocabulary-v1.md amendment v1.2) # # A concrete input holds at most one link, so "who occupies this slot" is @@ -1611,12 +1649,26 @@ def _apply_connect(workflow: dict, op: dict, graph) -> None: src = _find_by_str(workflow, op["from_node"]) if src is None: return + # Source SLOT drift gets the same treatment as a deleted source: delete + # wins over the LINK, not over the register claim — no crash, the input + # stays empty, and the claim above (concrete branch) stands. + outs = src.get("outputs") + from_slot = op["from_slot"] + if ( + not isinstance(outs, list) + or not isinstance(from_slot, int) + or isinstance(from_slot, bool) + or from_slot < 0 + or from_slot >= len(outs) + or not isinstance(outs[from_slot], dict) + ): + return link = [op["link_id"], op["from_node"], op["from_slot"], op["to_node"], to_idx, op["link_type"]] links = workflow.setdefault("links", []) if not any(ln[0] == op["link_id"] for ln in links): links.append(link) dst["inputs"][to_idx]["link"] = op["link_id"] - out_port = src["outputs"][op["from_slot"]] + out_port = outs[from_slot] # A real ComfyUI-serialized never-wired output carries `"links": null` — the # key EXISTS, so `setdefault` returns the existing `None` instead of # installing a fresh list, and the membership check below would raise @@ -1770,12 +1822,17 @@ def canonical(workflow: dict) -> dict: nodes = w.get("nodes") # Capture each node's original index -> slot identity BEFORE reordering # inputs, so links (which reference the raw index) can be rewritten. - slot_identity: dict[Any, dict[int, tuple]] = {} + # Node/link ids are legitimately either JSON type (amendment v1.2) — every + # key and sort below normalizes with ``str()``: the oracle must be able to + # COMPARE any legal document, and a raw-typed sort key raised ``TypeError`` + # on the very int/string mix the vocabulary declares legal, while a + # raw-typed identity key made ``7`` and ``"7"`` miss each other. + slot_identity: dict[str, dict[int, tuple]] = {} if isinstance(nodes, list): for n in nodes: if not isinstance(n, dict): continue - slot_identity[n.get("id")] = {i: _slot_identity(inp) for i, inp in enumerate(n.get("inputs") or [])} + slot_identity[str(n.get("id"))] = {i: _slot_identity(inp) for i, inp in enumerate(n.get("inputs") or [])} # Reorder each node's grown slots deterministically (by grow_id) and drop # their display name, which is order-dependent (image0 vs image1). for n in nodes: @@ -1784,23 +1841,23 @@ def canonical(workflow: dict) -> dict: fixed = [i for i in n["inputs"] if not (isinstance(i, dict) and i.get("grow_id") is not None)] grown = sorted( (i for i in n["inputs"] if isinstance(i, dict) and i.get("grow_id") is not None), - key=lambda i: i["grow_id"], + key=lambda i: str(i["grow_id"]), ) for i in grown: i["name"] = "\x00grow" n["inputs"] = fixed + grown - w["nodes"] = sorted(nodes, key=lambda n: n.get("id")) + w["nodes"] = sorted(nodes, key=lambda n: str(n.get("id"))) links = w.get("links") if isinstance(links, list): canon = [] for ln in links: ln = list(ln) if len(ln) >= 5: - ident = slot_identity.get(ln[3], {}).get(ln[4]) + ident = slot_identity.get(str(ln[3]), {}).get(ln[4]) if isinstance(ln[4], int) else None if ident is not None: ln[4] = ident canon.append(ln) - w["links"] = sorted(canon, key=lambda ln: ln[0]) + w["links"] = sorted(canon, key=lambda ln: str(ln[0])) defs = (w.get("definitions") or {}).get("subgraphs") if isinstance(defs, list): w["definitions"]["subgraphs"] = sorted(defs, key=lambda sg: str(sg.get("id", ""))) diff --git a/docs/op-vocabulary-v1.md b/docs/op-vocabulary-v1.md index 1a487a047..5c8b249f2 100644 --- a/docs/op-vocabulary-v1.md +++ b/docs/op-vocabulary-v1.md @@ -607,3 +607,82 @@ must move the SHA and their applier pin together. **No change to §§2, 4-7, 8.1-8.8** beyond the §3 table row and the §1.2 conflict bullet cited above. No op kind was added, removed, or re-scoped; `FROZEN_OPS` / `DEFERRED_OPS` / `BATCHABLE_OPS` are untouched. + +## 12. Amendment v1.3 — 2026-08-17 (slot-drift totality; oracle id normalization; dict widgets; implicit seed markers) + +Adversarial review of the apply path against shape-drifted documents (a merge +consumer replaying ops minted from a different catalog generation) found four +guard gaps, each a sibling of a rule that already existed elsewhere. All are +apply/replay semantics the conformance consumers must mirror. + +### 12.1 Concrete `connect`: slot drift is as total as node drift + +Totality (§1.2) covered a vanished *node*; it did not cover a vanished *slot*. +A concrete `connect` whose `to_slot` does not exist on the replayed document +(out of range, or a malformed entry) raised `IndexError` — **after** claiming +the LWW register and **before** recording the `op_id`. That pairing is a +poison state: a retry of the identical op loses to the failed attempt's own +stamp and the connect is silently dropped forever, even after the document is +repaired. + +**The rule:** a concrete `connect` whose destination slot is absent or +malformed is a total no-op that claims **no** register — there is no slot, so +there is nothing to occupy. A SOURCE slot that is absent or malformed gets the +deleted-source treatment (§1.2): the register claim stands, the input stays +empty, no link is recorded. Additionally, the apply dispatcher now guarantees +that an exception escaping any handler restores the stamp map to its +pre-dispatch state — no code path may leave a stamp committed without its +`op_id` recorded. + +### 12.2 `canonical()` accepts the id mix v1.2 declares legal + +v1.2 normalized *write targets* with `str()` but left the convergence oracle +sorting nodes and links by raw id — `canonical()` raised `TypeError` on a +document holding both int and string node ids, i.e. it could not compare the +exact traffic v1.2 legitimized. Every key and sort key in `canonical()` now +normalizes with `str()` (nodes, links, `grow_id`, and the link→slot-identity +lookup, which previously missed when the link stored `"7"` and the node `7`). +Ordering inside `canonical()` output changes for pure-int documents +(lexicographic, not numeric) — immaterial, since the oracle is an equality +check both replicas compute with the same function. + +### 12.3 Dict-shaped `widgets_values` is projected, never destroyed + +The VHS_* family serializes `widgets_values` as a named dict. Write paths that +read it through the list-only view treated it as "no values": one `set_widget` +replaced the whole dict with a sparse list (siblings destroyed, silently), the +`inputcount` bump wrote an integer key INTO the dict (leaving the count stale +next to a garbage key), and `capture` crashed. Wherever a catalog is in scope, +the dict form is now **projected onto the class's default widget order** — +named values land at their schema positions and survive the write; without a +catalog the old "values unknown" degradation stands. `comfy workflow slots` +now reports the real values for such nodes. + +### 12.4 The implicit seed companion reaches every order surface + +The frontend companions a seed-like INT with `control_after_generate` +regardless of the schema flag, and partner nodes ship such inputs unflagged +under many names (`image_seed`, `model_seed`, `Seed`, `rand_seed`, +`variation_seed`). The engine's order surfaces disagreed about this: +`widget_order_for_node` applied an exact-name implicit rule while +`widget_order`, `widget_order_default` and `widget_defaults` honored only the +explicit flag — so the exported **widget catalog** (the name↔index contract +the doc host consumes, pinned by `catalog_version`) was off by one for every +implicitly-companioned node. All four surfaces now share one predicate whose +implicit rule matches the converter's: an INT whose leaf name contains +``seed`` (case-insensitive). The UI→API converter's companion guard also now +peeks at the next *widget-owning* input rather than the next declared input, +so a connection-only input between a seed and a COMBO no longer defeats the +guard and eats the combo's real value. **`catalog_version` hashes change** for +affected classes; consumers pinning the catalog re-pin with this SHA. + +### 12.5 `applied_count` compliance + +§4 has always said "``applied_count`` is always 0 on failure — nothing is +written." The implementation reported the number of specs applied before the +abort (all discarded). The code now complies with the doc; no contract change. + +**No change to §§2, 4-7** beyond the compliance fix above. No op kind was +added, removed, or re-scoped; `FROZEN_OPS` / `DEFERRED_OPS` / `BATCHABLE_OPS` +are untouched. Downstream repos pinning this document by SHA move the SHA and +their applier/catalog pins together. diff --git a/tests/comfy_cli/command/test_ack_flag.py b/tests/comfy_cli/command/test_ack_flag.py index a6acf4582..86ca78f31 100644 --- a/tests/comfy_cli/command/test_ack_flag.py +++ b/tests/comfy_cli/command/test_ack_flag.py @@ -190,8 +190,10 @@ def test_ack_summary_partial_failure_reports_index_and_code(self, patched_graph, """Op 2 of 3 (0-based index 1) fails → same code/atomicity as full mode, plus a structured receipt: failed.{index,op,code} + applied_count. - `applied_count` counts specs applied before the abort; the batch is - atomic, so all of them were then discarded (nothing was written). + The batch is atomic — everything applied before the abort is discarded, + so `applied_count` is always 0 on failure (op-vocabulary-v1.md §4): + nothing was written, and reporting the discarded count taught a merge + consumer that k-1 ops persisted. """ path = _empty(tmp_path) before = path.read_text() @@ -208,7 +210,7 @@ def test_ack_summary_partial_failure_reports_index_and_code(self, patched_graph, assert path.read_text() == before details = env["error"]["details"] assert details["failed"] == {"index": 1, "op": "add_node", "code": "workflow_edit_invalid"} - assert details["applied_count"] == 1 + assert details["applied_count"] == 0 def test_full_mode_failure_envelope_unchanged(self, patched_graph, tmp_path, capsys): """Default mode keeps today's failure envelope: no details block.""" diff --git a/tests/comfy_cli/command/test_validate_command.py b/tests/comfy_cli/command/test_validate_command.py index b47369059..ad5005c81 100644 --- a/tests/comfy_cli/command/test_validate_command.py +++ b/tests/comfy_cli/command/test_validate_command.py @@ -582,3 +582,40 @@ def test_ipv6_source_is_unbracketed_in_payload_and_bracketed_for_display(runner, runner, _valid_workflow(tmp_path), "--host", "::1", "--port", "9000", json_mode="--no-json" ) assert "object_info from http://[::1]:9000" in pretty.stdout + + +class TestValidateUsesTheResilientLoader: + """`validate` must route its live catalog fetch through + ``resilient_load_object_info`` like every other consumer — the loader's own + docstring names it as one. Bypassing it (a direct ``Graph.load``) silently + dropped COMFY_OBJECT_INFO_FILE support, the cloud TTL cache, the + force-refresh retry, and the stale fallback for the one command an agent + runs before every submit.""" + + def test_object_info_file_env_is_honored_without_input(self, runner, tmp_path): + wf = _write( + tmp_path, + "wf.json", + {"9": {"class_type": "KSampler", "inputs": {}}}, + ) + import comfy_cli.cql.engine as engine + + def _no_network(**_kw): + raise AssertionError("validate must not open a socket when COMFY_OBJECT_INFO_FILE is set") + + with patch.object(engine, "_load_from_target", _no_network): + result = runner.invoke( + app, + ["--json", "validate", "--workflow", str(wf)], + env={"COMFY_WHERE": "local", "COMFY_OBJECT_INFO_FILE": str(OBJECT_INFO)}, + ) + env_out = _envelope(result) + # The verdict itself doesn't matter here — what matters is that an + # envelope was produced at all: the patched network fetch raises, so + # reaching the envelope proves the catalog came from the env-pinned + # file through the resilient loader. + assert env_out["command"] == "validate" + # The toy workflow legitimately fails validation (missing required + # inputs) — the point is that validation RAN, against the env catalog. + assert "valid" in env_out["data"] + assert "error_count" in env_out["data"] diff --git a/tests/comfy_cli/test_apply_replay_drift.py b/tests/comfy_cli/test_apply_replay_drift.py new file mode 100644 index 000000000..6adde9856 --- /dev/null +++ b/tests/comfy_cli/test_apply_replay_drift.py @@ -0,0 +1,280 @@ +"""Replay totality under document shape drift, and convergence-oracle robustness. + +The op plane's Totality guarantee says a merge consumer can replay any op +against any document state without a crash — a vanished *node* already +no-ops (delete wins). These tests pin the sibling cases that used to escape: + +* a vanished/never-existed *slot* (``to_slot`` out of range) must no-op the + same way, and must NOT claim the LWW register on the way out — the original + bug committed the stamp, then raised ``IndexError`` before recording the + ``op_id``, so a retry of the identical op lost to its own stamp and the + connect was silently dropped forever; +* a vanished *source slot* keeps the documented "delete wins over the link, + not the register claim" behavior — no crash, register claimed, input left + empty; +* an exception escaping a handler must leave the LWW bookkeeping untouched + (no stamp without a matching applied op_id); +* ``canonical()`` — the convergence oracle — must accept the id mix that + amendment v1.2 declares legal (int and string node ids in one document); +* dict-shaped ``widgets_values`` (the VHS_* serialization) must not be + corrupted by the inputcount bump, must not crash ``capture``, and known + values must survive a positional rewrite instead of being dropped. +""" + +from __future__ import annotations + +import json + +import pytest + +from comfy_cli import workflow_ops as W +from comfy_cli.cql.engine import Graph + +_OBJECT_INFO = { + "Src": { + "input": {"required": {}}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "test", + "display_name": "Src", + "python_module": "nodes", + }, + "Dst": { + "input": {"required": {"samples": ["LATENT"]}}, + "input_order": {"required": ["samples"]}, + "output": [], + "output_name": [], + "category": "test", + "display_name": "Dst", + "python_module": "nodes", + }, + "EmptyLatentImage": { + "input": { + "required": { + "width": ["INT", {"default": 512}], + "height": ["INT", {"default": 512}], + "batch_size": ["INT", {"default": 1}], + }, + }, + "input_order": {"required": ["width", "height", "batch_size"]}, + "output": ["LATENT"], + "output_name": ["LATENT"], + "category": "latent", + "display_name": "Empty Latent Image", + "python_module": "nodes", + }, +} + + +@pytest.fixture +def graph() -> Graph: + return Graph.from_object_info(_OBJECT_INFO) + + +def _two_node_workflow() -> dict: + return { + "nodes": [ + { + "id": 1, + "type": "Src", + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": None}], + "widgets_values": [], + }, + { + "id": 2, + "type": "Dst", + "inputs": [{"name": "samples", "type": "LATENT", "link": None}], + "outputs": [], + "widgets_values": [], + }, + ], + "links": [], + } + + +def _connect_op(to_slot: int, *, from_slot: int = 0, base_version: int = 1, actor: str = "agent") -> dict: + return W._new_op( + "connect", + actor, + base_version, + link_id=900, + from_node=1, + from_slot=from_slot, + to_node=2, + to_slot=to_slot, + link_type="LATENT", + ) + + +class TestConnectReplayAgainstDriftedDocuments: + def test_out_of_range_to_slot_is_a_total_noop(self, graph: Graph): + wf = _two_node_workflow() + op = _connect_op(to_slot=5) + W.apply_op(wf, op, graph) # must not raise + assert wf["links"] == [] + assert wf["nodes"][1]["inputs"][0]["link"] is None + assert op["op_id"] in wf["_applied_ops"] + + def test_out_of_range_to_slot_does_not_claim_the_register(self, graph: Graph): + wf = _two_node_workflow() + op = _connect_op(to_slot=5) + W.apply_op(wf, op, graph) + register = json.dumps(W._write_target(op)) + assert register not in (wf.get("_widget_stamps") or {}) + + def test_register_usable_after_slot_appears(self, graph: Graph): + """The original bug: the failed op's own stamp poisoned the register, so + even after the document was repaired a replay was silently dropped.""" + wf = _two_node_workflow() + W.apply_op(wf, _connect_op(to_slot=1), graph) # slot 1 doesn't exist -> no-op + # Document repaired: the slot now exists. + wf["nodes"][1]["inputs"].append({"name": "extra", "type": "LATENT", "link": None}) + later = _connect_op(to_slot=1, base_version=2) + W.apply_op(wf, later, graph) + assert wf["nodes"][1]["inputs"][1]["link"] == later["link_id"] + assert any(ln[0] == later["link_id"] for ln in wf["links"]) + + def test_malformed_slot_entry_is_a_total_noop(self, graph: Graph): + wf = _two_node_workflow() + wf["nodes"][1]["inputs"][0] = "not-a-slot" + op = _connect_op(to_slot=0) + W.apply_op(wf, op, graph) # must not raise + assert wf["links"] == [] + + def test_vanished_source_slot_keeps_register_leaves_input_empty(self, graph: Graph): + """Delete wins over the LINK, not over the register claim — the + documented semantics for a concurrently-deleted source, extended to a + source SLOT that is out of range on the replayed document.""" + wf = _two_node_workflow() + op = _connect_op(to_slot=0, from_slot=7) + W.apply_op(wf, op, graph) # must not raise + assert wf["links"] == [] + assert wf["nodes"][1]["inputs"][0]["link"] is None + register = json.dumps(W._write_target(op)) + assert register in (wf.get("_widget_stamps") or {}) + + def test_exception_in_a_handler_rolls_back_the_stamps(self, graph: Graph, monkeypatch): + """Defense in depth: no handler exception may leave a stamp committed + without its op_id recorded — that is the poison state.""" + wf = _two_node_workflow() + op = _connect_op(to_slot=0) + + def _explodes_after_committing(workflow, op_, graph_): + W._lww_commit(workflow, op_) + raise RuntimeError("boom") + + monkeypatch.setattr(W, "_apply_connect", _explodes_after_committing) + with pytest.raises(RuntimeError): + W.apply_op(wf, op, graph) + register = json.dumps(W._write_target(op)) + assert register not in (wf.get("_widget_stamps") or {}) + assert op["op_id"] not in (wf.get("_applied_ops") or []) + + +class TestCanonicalAcceptsLegalIdMixes: + def test_mixed_node_id_types_do_not_raise(self): + wf = { + "nodes": [ + {"id": 7, "type": "Src", "inputs": [], "outputs": []}, + {"id": "57:3", "type": "Dst", "inputs": [], "outputs": []}, + ], + "links": [], + } + first = W.canonical(wf) + assert first == W.canonical(wf) + + def test_mixed_link_id_types_do_not_raise(self): + wf = { + "nodes": [], + "links": [[1, 1, 0, 2, 0, "LATENT"], ["str-link", 1, 0, 2, 0, "LATENT"]], + } + assert W.canonical(wf) == W.canonical(wf) + + def test_link_slot_identity_survives_id_type_mismatch(self): + """A link that stores the destination id as a string while the node + carries an int (or vice versa) must still get its raw slot index + rewritten to the position-independent identity.""" + wf = { + "nodes": [ + { + "id": 7, + "type": "Dst", + "inputs": [{"name": "samples", "type": "LATENT", "link": 900}], + "outputs": [], + } + ], + "links": [[900, 1, 0, "7", 0, "LATENT"]], + } + canon = W.canonical(wf) + assert canon["links"][0][4] == ("name", "samples") + + +class TestDictWidgetsValuesSurvival: + def test_inputcount_bump_normalizes_a_dict(self, graph: Graph): + multi_info = dict(_OBJECT_INFO) + multi_info["MultiIn"] = { + "input": {"required": {"inputcount": ["INT", {"default": 2}]}}, + "input_order": {"required": ["inputcount"]}, + "output": [], + "output_name": [], + "category": "test", + "display_name": "MultiIn", + "python_module": "nodes", + } + g = Graph.from_object_info(multi_info) + wf = {"nodes": [{"id": 9, "type": "MultiIn", "inputs": [], "outputs": []}]} + dst = wf["nodes"][0] + dst["widgets_values"] = {"inputcount": 2} + op = W._new_op("connect", "agent", 1, link_id=901, from_node=1, from_slot=0, to_node=9, to_slot=None) + W._apply_inputcount_bump(wf, dst, op, g, "inputcount", 3) + assert dst["widgets_values"] == [3] + + def test_capture_tolerates_dict_widgets_and_keeps_values(self, graph: Graph): + wf = { + "nodes": [ + { + "id": 4, + "type": "EmptyLatentImage", + "inputs": [], + "outputs": [], + "widgets_values": {"width": 768, "height": 512, "batch_size": 1}, + } + ], + "links": [], + } + recipe = W.capture_recipe(wf, graph) # must not raise + sets = {(o["widget"], o["value"]) for o in recipe["ops"] if o["op"] == "set_widget"} + assert ("width", 768) in sets # non-default value survived the dict form + + def test_set_widget_preserves_dict_siblings(self, graph: Graph): + wf = { + "nodes": [ + { + "id": 4, + "type": "EmptyLatentImage", + "inputs": [], + "outputs": [], + "widgets_values": {"width": 768, "height": 512, "batch_size": 1}, + } + ], + "links": [], + } + new_wf, _op = W.set_widget(wf, graph, 4, "batch_size", 4) + assert new_wf["nodes"][0]["widgets_values"] == [768, 512, 4] + + +class TestBatchFailureReportsNothingApplied: + def test_applied_count_is_zero_on_failure(self, graph: Graph): + """docs/op-vocabulary-v1.md: 'applied_count is always 0 on failure — + nothing is written.' The whole batch is discarded, so reporting the + specs that applied-then-were-discarded teaches a merge consumer that + k-1 ops persisted when zero did.""" + wf = _two_node_workflow() + specs = [ + {"op": "add_node", "class_type": "EmptyLatentImage", "as": "latent"}, + {"op": "connect", "from": "$latent.LATENT", "to": "$missing.samples"}, + ] + with pytest.raises(ValueError) as exc: + W.apply_specs(wf, graph, specs, actor="agent", base_version=1) + assert getattr(exc.value, "applied_count", None) == 0 From ada1407f5d178fbe507f96ec61d133660d21fce7 Mon Sep 17 00:00:00 2001 From: kishore Date: Wed, 19 Aug 2026 13:08:02 -0700 Subject: [PATCH 53/53] fix(workflow): capture/apply agree on UI-only nodes; --stdout honors the envelope contract; node mode survives capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses bigcat88's review (Findings 1, 2, and the dropped node mode): Finding 1 — capture emitted add_node ops for UI-only nodes (Note/ MarkdownNote/Reroute/GetNode/SetNode/PrimitiveNode) that apply correctly refuses, so one doc note discarded a whole recipe atomically (114/306 official templates). capture now skips UI-only nodes and preserves the data flow they carried: links through Reroute and GetNode→SetNode chains are spliced to the real upstream source (the same resolution the UI→API converter applies) and a PrimitiveNode's value lands as the fed widget's captured value. Skipped nodes surface as registered warning codes on the capture envelope. Finding 2 — _finish (add-node/connect/set-widget/delete-node/clear/ reset-doc), delete-nodes and apply now follow the --stdout contract set-slot pins: JSON mode puts ONE envelope on stdout with the document riding in data.workflow_json; human mode puts exactly the raw workflow on stdout (success lines suppressed, warnings to stderr), so `--no-json ... --stdout > new.json` parses again. Node mode — add_node (primitive and batch spec) takes an optional litegraph mode; capture emits it for muted/bypassed nodes and apply rebuilds it, so a bypassed node no longer comes back live (op-vocabulary amendment v1.4). Co-Authored-By: Claude Fable 5 --- comfy_cli/command/workflow_edit.py | 86 ++++-- comfy_cli/error_codes.py | 19 ++ comfy_cli/skills/comfy/SKILL.md | 6 +- comfy_cli/workflow_ops.py | 215 +++++++++++++-- docs/op-vocabulary-v1.md | 41 ++- tests/comfy_cli/command/test_workflow_edit.py | 254 ++++++++++++++++++ tests/comfy_cli/test_apply_replay_drift.py | 2 +- 7 files changed, 576 insertions(+), 47 deletions(-) diff --git a/comfy_cli/command/workflow_edit.py b/comfy_cli/command/workflow_edit.py index c6a8e3f85..8ccaa0706 100644 --- a/comfy_cli/command/workflow_edit.py +++ b/comfy_cli/command/workflow_edit.py @@ -33,7 +33,16 @@ # between the 7 near-identical signatures. ActorOpt = Annotated[str, typer.Option("--actor", help="Op author id (for CRDT stamping).")] BaseVersionOpt = Annotated[int, typer.Option("--base-version", help="Draft version this edit is based on.")] -StdoutOpt = Annotated[bool, typer.Option("--stdout/--in-place", show_default=False)] +StdoutOpt = Annotated[ + bool, + typer.Option( + "--stdout/--in-place", + show_default=False, + help="Return the result instead of writing back to : `data.workflow_json` in the " + "envelope under --json, or the raw workflow on stdout with --no-json. Redirecting " + "stdout selects JSON mode, so `--stdout > new.json` needs --no-json to get a raw workflow.", + ), +] InputOpt = Annotated[str | None, typer.Option("--input", show_default=False)] HostOpt = Annotated[str | None, typer.Option(show_default=False)] PortOpt = Annotated[int | None, typer.Option(show_default=False)] @@ -76,16 +85,28 @@ def _split_addr(addr: str, renderer) -> tuple[Any, str]: def _finish(renderer, p, workflow: dict, op: dict, base_version: int, stdout: bool, command: str) -> None: - """Serialize the mutated workflow (file or stdout) and emit the op envelope.""" + """Serialize the mutated workflow (file or stdout) and emit the op envelope. + + ``--stdout`` follows the contract ``set-slot`` established (docs/json-output.md): + in human mode stdout is a pipe target and must hold EXACTLY the workflow — + the ``✓`` success line would land inside a ``> new.json`` redirect, so it is + suppressed and warnings go to stderr. In JSON mode stdout is reserved for + the envelope, so the document rides in ``data.workflow_json`` instead — a + bare workflow object is not an ``envelope/1`` and machine callers reject it. + """ workflow_ops.strip_internal(workflow) serialized = json.dumps(workflow, indent=2) - wrote: str | None = None - if stdout: + if stdout and renderer.is_pretty(): import sys sys.stdout.write(serialized) sys.stdout.write("\n") - else: + sys.stdout.flush() + for w in op.get("warnings") or []: + renderer.stderr_console().print(f"[yellow]warning:[/yellow] {w}") + return + wrote: str | None = None + if not stdout: _atomic_write_text(p, serialized) wrote = str(p) payload = { @@ -95,11 +116,14 @@ def _finish(renderer, p, workflow: dict, op: dict, base_version: int, stdout: bo "version": base_version + 1, "wrote": wrote, } + if stdout: + payload["out"] = "stdout" + payload["workflow_json"] = workflow if op.get("warnings"): payload["warnings"] = op["warnings"] if renderer.is_pretty(): rprint(f"[bold green]✓[/bold green] {op['op']} → [dim]{p}[/dim]") - renderer.emit(payload, command=command, changed=True) + renderer.emit(payload, command=command, changed=not stdout) def _graph_or_exit(input_path, host, port, renderer, where=None): @@ -327,12 +351,17 @@ def delete_nodes_cmd( workflow_ops.strip_internal(workflow) serialized = json.dumps(workflow, indent=2) - wrote: str | None = None - if stdout: + # --stdout: same contract as _finish — human mode gets exactly the raw + # workflow (success line suppressed); JSON mode keeps stdout for the + # envelope and the document rides in data.workflow_json. + if stdout and renderer.is_pretty(): import sys sys.stdout.write(serialized + "\n") - else: + sys.stdout.flush() + return + wrote: str | None = None + if not stdout: _atomic_write_text(p, serialized) wrote = str(p) payload = { @@ -343,9 +372,12 @@ def delete_nodes_cmd( "version": base_version + len(ops), "wrote": wrote, } + if stdout: + payload["out"] = "stdout" + payload["workflow_json"] = workflow if renderer.is_pretty(): rprint(f"[bold green]✓[/bold green] deleted {len(ops)} node(s) → [dim]{p}[/dim]") - renderer.emit(payload, command="workflow delete-nodes", changed=True) + renderer.emit(payload, command="workflow delete-nodes", changed=not stdout) # --------------------------------------------------------------------------- @@ -514,7 +546,7 @@ def capture_cmd( node_id: Any = int(node_str) if node_str.lstrip("-").isdigit() else node_str lift[(node_id, widget)] = pname.strip() try: - recipe = workflow_ops.capture_recipe(workflow, graph, name=name or p.stem, lift=lift) + recipe, warnings = workflow_ops.capture_recipe(workflow, graph, name=name or p.stem, lift=lift) except workflow_ops.RecipeError as e: renderer.error(code="workflow_edit_invalid", message=str(e)) raise typer.Exit(code=1) from e @@ -536,8 +568,18 @@ def capture_cmd( "out": wrote or "stdout", "recipe_doc": recipe, } - if renderer.is_pretty() and wrote: - rprint(f"[bold green]✓[/bold green] captured {len(recipe['ops'])} ops → [dim]{wrote}[/dim]") + if warnings: + payload["warnings"] = warnings + if renderer.is_pretty(): + if wrote: + rprint(f"[bold green]✓[/bold green] captured {len(recipe['ops'])} ops → [dim]{wrote}[/dim]") + for w in warnings: + rprint(f" [yellow]warning:[/yellow] {w.get('message', w)}") + else: + # stdout holds exactly the recipe JSON; warnings would corrupt a + # redirect, so they go to stderr rather than being dropped. + for w in warnings: + renderer.stderr_console().print(f"[yellow]warning:[/yellow] {w.get('message', w)}") renderer.emit(payload, command="workflow capture") @@ -650,12 +692,17 @@ def apply_cmd( workflow_ops.strip_internal(workflow) serialized = json.dumps(workflow, indent=2) - wrote: str | None = None - if stdout: + # --stdout: same contract as _finish — human mode gets exactly the raw + # workflow (success/summary lines suppressed); JSON mode keeps stdout for + # the envelope and the document rides in data.workflow_json. + if stdout and renderer.is_pretty(): import sys sys.stdout.write(serialized + "\n") - else: + sys.stdout.flush() + return + wrote: str | None = None + if not stdout: _atomic_write_text(p, serialized) wrote = str(p) if ack == "summary": @@ -684,6 +731,11 @@ def apply_cmd( "version": base_version + len(ops), "wrote": wrote, } + if stdout: + # Additive-only against the pinned summary shape: present only under + # --stdout, where the envelope is the sole place the document can ride. + payload["out"] = "stdout" + payload["workflow_json"] = workflow if renderer.is_pretty(): rprint(f"[bold green]✓[/bold green] applied {len(ops)} edit(s) → [dim]{p}[/dim]") if ack == "summary": @@ -692,7 +744,7 @@ def apply_cmd( rprint(f" [dim]{kinds}[/dim]") for alias, node_id in aliases.items(): rprint(f" [dim]alias {alias} → {node_id}[/dim]") - renderer.emit(payload, command="workflow apply", changed=True) + renderer.emit(payload, command="workflow apply", changed=not stdout) # --------------------------------------------------------------------------- diff --git a/comfy_cli/error_codes.py b/comfy_cli/error_codes.py index 933e6d1ef..04829f250 100644 --- a/comfy_cli/error_codes.py +++ b/comfy_cli/error_codes.py @@ -574,6 +574,25 @@ class ErrorCode: "the nearest matching option was used. Surfaced in the op's `warnings`.", "see the warning's `from`/`to`; pass an exact option to avoid the fuzzy match", ), + ErrorCode( + "ui_only_node_skipped", + "Warning (not fatal): `workflow capture` skipped a UI-only node (Note/MarkdownNote/" + "Reroute/GetNode/SetNode/PrimitiveNode) — those never reach the API and `apply` " + "refuses to mint them. Data flow through the node was spliced to the real source.", + "expected for annotated workflows; the recipe rebuilds the executable graph, not canvas decoration", + ), + ErrorCode( + "ui_only_link_dropped", + "Warning (not fatal): a captured link traced back through UI-only nodes to no real " + "source (e.g. a dangling Reroute), so `workflow capture` dropped it.", + "check the named node/input; wire it to a real source before capturing if the link matters", + ), + ErrorCode( + "primitive_feed_unrepresentable", + "Warning (not fatal): a PrimitiveNode feeds an input that is not a widget on the " + "target node, so `workflow capture` could not express its value as a set_widget.", + "set the target input from a real node or widget before capturing", + ), # --- workflow fragments / compose --------------------------------------- ErrorCode( "fragment_invalid", diff --git a/comfy_cli/skills/comfy/SKILL.md b/comfy_cli/skills/comfy/SKILL.md index b55bb0c06..572ce7803 100644 --- a/comfy_cli/skills/comfy/SKILL.md +++ b/comfy_cli/skills/comfy/SKILL.md @@ -375,7 +375,11 @@ comfy --json workflow apply fresh.json --ops t2i.recipe.json --param positive="a `capture --param .=` lifts that widget to a `${name}` hole (current value becomes its default) — use it for the fields you want to vary, since plain `capture` omits widgets left at their default. Recipes are UI-format op-batches -— mergeable and canvas-native. `compose`/`decompose` (the fragment/blueprint path) +— mergeable and canvas-native. UI-only nodes (Note/MarkdownNote/Reroute/GetNode/ +SetNode/PrimitiveNode) are **skipped at capture** (reported as `warnings` on the +envelope): links through Reroute and Get/Set chains are spliced to the real source +and a PrimitiveNode's value lands on the widget it feeds, so the recipe rebuilds +the executable graph — canvas decoration doesn't round-trip. `compose`/`decompose` (the fragment/blueprint path) are **legacy**: they emit API format and can't co-edit; use recipes for anything you'll reuse or edit on the canvas. diff --git a/comfy_cli/workflow_ops.py b/comfy_cli/workflow_ops.py index 3c4dbd697..7a17d4693 100644 --- a/comfy_cli/workflow_ops.py +++ b/comfy_cli/workflow_ops.py @@ -462,12 +462,19 @@ def _next_inputcount_name(ins: list, requested: str) -> str: # --------------------------------------------------------------------------- +# Litegraph node modes: 0 always, 1 on-event, 2 never (mute), 3 on-trigger, +# 4 bypass. Mirrors workflow_to_api._MODE_MUTED/_MODE_BYPASS and the +# _MODE_LABELS table in workflow_edit's ls-nodes. +_VALID_NODE_MODES = frozenset({0, 1, 2, 3, 4}) + + def add_node( workflow: dict, graph, class_type: str, *, pos: list | None = None, + mode: int = 0, actor: str = "cli", base_version: int = 0, ) -> tuple[dict, dict]: @@ -492,6 +499,16 @@ def add_node( # stays convergent (P1). Existing nodes are never moved. pos = layout.cascade_pos(workflow, size) node = _build_node(mint_id(), class_type, m, graph, pos, size) + if mode: + # Node mode (mute/bypass) is graph-semantic state — a bypassed node + # executes differently — so it must survive capture→apply. op.node is + # authoritative for replay (§8.5), so stamping the node covers it; the + # explicit op field keeps the receipt inspectable. + if not isinstance(mode, int) or isinstance(mode, bool) or mode not in _VALID_NODE_MODES: + raise ValueError( + f"invalid node mode {mode!r}; valid: 0 (always), 1 (on-event), 2 (mute), 3 (on-trigger), 4 (bypass)" + ) + node["mode"] = mode op = _new_op( "add_node", actor, @@ -500,6 +517,7 @@ def add_node( class_type=class_type, pos=node["pos"], node=node, + **({"mode": mode} if mode else {}), ) return apply_op(workflow, op, graph), op @@ -1194,10 +1212,26 @@ def _param(name: str, params: dict[str, Any]) -> Any: return params[name] -def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | None = None) -> dict: +def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | None = None) -> tuple[dict, list[dict]]: """Project a UI-format graph into a recipe — the op-batch that rebuilds it (add_node + non-default set_widget + connect). The inverse of `apply`: `apply(empty, capture(wf))` reproduces `wf`. Top-level nodes only. + Returns ``(recipe, warnings)``. + + UI-only node types (:data:`UI_ONLY_NODE_TYPES`) never reach the API and + ``add_node`` refuses to mint them, so capturing them verbatim produced + recipes ``apply`` could not run — one MarkdownNote in the source discarded + the whole atomic batch. capture therefore SKIPS them, preserving the data + flow they carried so the rebuilt graph's API prompt is unchanged: + + * links THROUGH ``Reroute`` and ``GetNode``→``SetNode`` chains are + spliced to the real upstream source; + * a ``PrimitiveNode``'s value lands as the fed widget's captured value; + * pure annotations (``Note``/``MarkdownNote``) simply drop. + + Each skipped node (and any link that could not be spliced) is reported in + ``warnings`` — the recipe rebuilds the executable graph, not the canvas + decoration. `lift` maps `(node_id, widget_name) -> param_name`: those widgets become `${param_name}` holes (with a `params` header entry defaulting to the current @@ -1206,32 +1240,151 @@ def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | N if (workflow.get("definitions") or {}).get("subgraphs"): raise RecipeError("capture does not support subgraphs yet — edit/flatten top-level nodes first") lift = lift or {} - nodes = [n for n in (workflow.get("nodes") or []) if isinstance(n, dict) and "id" in n] - by_id = {n["id"]: n for n in nodes} + all_nodes = [n for n in (workflow.get("nodes") or []) if isinstance(n, dict) and "id" in n] + by_id = {n["id"]: n for n in all_nodes} + nodes = [n for n in all_nodes if n.get("type") not in UI_ONLY_NODE_TYPES] + ui_nodes = [n for n in all_nodes if n.get("type") in UI_ONLY_NODE_TYPES] # Validate lift targets up front — no silently-ignored typos. for (node_id, widget), _pname in lift.items(): node = by_id.get(node_id) if node is None: raise RecipeError(f"--param target node {node_id!r} not in workflow") + if node.get("type") in UI_ONLY_NODE_TYPES: + raise RecipeError( + f"--param target node {node_id} is a UI-only {node.get('type')} — capture skips it (it never reaches the API)" + ) if widget not in graph.widget_order_default(node.get("type", "")): raise RecipeError(f"--param target {node_id}.{widget!r}: not a widget on {node.get('type')}") - alias_by_id: dict[Any, str] = {} + warnings: list[dict] = [] + for n in ui_nodes: + warnings.append( + { + "code": "ui_only_node_skipped", + "node_id": n["id"], + "class_type": n.get("type"), + "message": ( + f"{n.get('type')} (id {n['id']}) is UI-only and cannot be rebuilt by apply — skipped; " + "data flow through it (if any) is spliced to the real source" + ), + } + ) + + links = [ln for ln in (workflow.get("links") or []) if isinstance(ln, list) and len(ln) >= 5] + # link_id -> (source_id, source_slot). Node identity is compared as a STRING + # (amendment v1.2) — ids are legitimately either JSON type. + link_map = {ln[0]: (ln[1], ln[2]) for ln in links if isinstance(ln[0], int)} + node_by_sid = {str(n["id"]): n for n in all_nodes} + + def _first_input_source(n: dict) -> tuple[Any, Any] | None: + for inp in n.get("inputs") or []: + if isinstance(inp, dict): + lid = inp.get("link") + if isinstance(lid, int) and lid in link_map: + return link_map[lid] + return None + + # Same upstream-resolution model as workflow_to_api's tracers: hop through + # Reroute chains and GetNode -> SetNode pairs; a seen-set guards cycles. + reroute_src: dict[str, tuple[Any, Any]] = {} + set_src: dict[str, tuple[Any, Any]] = {} + get_var: dict[str, str] = {} + prim_val: dict[str, Any] = {} + for n in ui_nodes: + t = n.get("type") + if t == "Reroute": + src = _first_input_source(n) + if src is not None: + reroute_src[str(n["id"])] = src + elif t in ("SetNode", "GetNode"): + w = n.get("widgets_values") + var = w[0] if isinstance(w, list) and w else None + if not isinstance(var, str) or not var: + continue + if t == "GetNode": + get_var[str(n["id"])] = var + else: + src = _first_input_source(n) + if src is not None: + set_src[var] = src + elif t == "PrimitiveNode": + w = n.get("widgets_values") + if isinstance(w, list) and w: + prim_val[str(n["id"])] = w[0] + + def _trace(src_id: Any, src_slot: Any) -> tuple[Any, Any]: + seen: set[str] = set() + while str(src_id) not in seen: + key = str(src_id) + seen.add(key) + if key in reroute_src: + src_id, src_slot = reroute_src[key] + elif key in get_var and get_var[key] in set_src: + src_id, src_slot = set_src[get_var[key]] + else: + break + return src_id, src_slot + + alias_by_sid: dict[str, str] = {} counts: dict[str, int] = {} for n in nodes: slug = re.sub(r"[^a-z0-9]+", "_", str(n.get("type", "node")).lower()).strip("_") or "node" counts[slug] = counts.get(slug, 0) + 1 - alias_by_id[n["id"]] = slug if counts[slug] == 1 else f"{slug}_{counts[slug]}" + alias_by_sid[str(n["id"])] = slug if counts[slug] == 1 else f"{slug}_{counts[slug]}" + + # First pass over links: real-target links become connect specs (spliced + # through UI-only chains); a PrimitiveNode source becomes a widget value on + # the target (`prim_feeds`) rather than a wire. + connect_specs: list[dict] = [] + prim_feeds: dict[tuple[str, str], Any] = {} # (target_sid, widget_name) -> value + for ln in links: + _lid, from_id, from_slot, to_id, to_slot = ln[0], ln[1], ln[2], ln[3], ln[4] + to_node = node_by_sid.get(str(to_id)) + if to_node is None or to_node.get("type") in UI_ONLY_NODE_TYPES: + # Feeds a UI-only node — its flow is captured when tracing the + # downstream real consumer, so nothing is lost by skipping here. + continue + in_name = _slot_name(to_node.get("inputs"), to_slot) + src_id, src_slot = _trace(from_id, from_slot) + if str(src_id) in prim_val: + prim_feeds[(str(to_id), str(in_name))] = prim_val[str(src_id)] + continue + src_node = node_by_sid.get(str(src_id)) + if src_node is None or src_node.get("type") in UI_ONLY_NODE_TYPES: + warnings.append( + { + "code": "ui_only_link_dropped", + "node_id": to_node["id"], + "input": str(in_name), + "message": ( + f"link into {to_node.get('type')} (id {to_node['id']}).{in_name} traces back to a UI-only " + "node with no real source — dropped" + ), + } + ) + continue + out_name = _slot_name(src_node.get("outputs"), src_slot) + connect_specs.append( + { + "op": "connect", + "from": f"{alias_by_sid[str(src_id)]}.{out_name}", + "to": f"{alias_by_sid[str(to_id)]}.{in_name}", + } + ) ops: list[dict] = [] params_header: dict[str, Any] = {} for n in nodes: - alias = alias_by_id[n["id"]] + alias = alias_by_sid[str(n["id"])] class_type = n.get("type") add: dict[str, Any] = {"op": "add_node", "class_type": class_type, "as": alias} if n.get("pos"): add["at"] = n["pos"] + if n.get("mode"): + # mute (2) / bypass (4) change what executes — a recipe that + # silently revived a bypassed node produced a different API prompt. + add["mode"] = n["mode"] ops.append(add) from comfy_cli.cql import engine as _engine @@ -1239,31 +1392,37 @@ def capture_recipe(workflow: dict, graph, name: str = "captured", lift: dict | N order = graph.widget_order_for_node(class_type, widgets) defaults = graph.widget_defaults(class_type) for i, wname in enumerate(order): - if i >= len(widgets): - break + if i >= len(widgets) and (str(n["id"]), wname) not in prim_feeds: + continue pname = lift.get((n["id"], wname)) + # A PrimitiveNode feeding this widget-input is authoritative over the + # (possibly stale) serialized widgets_values slot — same precedence + # the UI→API converter applies. + value = prim_feeds.pop((str(n["id"]), wname), widgets[i] if i < len(widgets) else None) if pname is not None: # Explicitly lifted → a ${param} hole, current value as its default. ops.append({"op": "set_widget", "node": alias, "widget": wname, "value": f"${{{pname}}}"}) - params_header[pname] = {"type": _widget_param_type(graph, class_type, wname), "default": widgets[i]} - elif widgets[i] != defaults.get(wname): + params_header[pname] = {"type": _widget_param_type(graph, class_type, wname), "default": value} + elif value != defaults.get(wname): # Only widgets that differ from the fresh-node default — add_node fills the rest. - ops.append({"op": "set_widget", "node": alias, "widget": wname, "value": widgets[i]}) - - node_by_id = {n["id"]: n for n in nodes} - for ln in workflow.get("links") or []: - if not (isinstance(ln, list) and len(ln) >= 5): - continue - _lid, from_id, from_slot, to_id, to_slot = ln[0], ln[1], ln[2], ln[3], ln[4] - if from_id not in alias_by_id or to_id not in alias_by_id: - continue - out_name = _slot_name(node_by_id[from_id].get("outputs"), from_slot) - in_name = _slot_name(node_by_id[to_id].get("inputs"), to_slot) - ops.append( - {"op": "connect", "from": f"{alias_by_id[from_id]}.{out_name}", "to": f"{alias_by_id[to_id]}.{in_name}"} + ops.append({"op": "set_widget", "node": alias, "widget": wname, "value": value}) + + for (to_sid, in_name), value in prim_feeds.items(): + target = node_by_sid.get(to_sid, {}) + warnings.append( + { + "code": "primitive_feed_unrepresentable", + "node_id": target.get("id"), + "input": in_name, + "message": ( + f"PrimitiveNode value {value!r} feeds {target.get('type')} (id {target.get('id')}).{in_name}, " + "which is not a widget on that node — dropped" + ), + } ) - return {"recipe": name, "params": params_header, "ops": ops} + ops.extend(connect_specs) + return {"recipe": name, "params": params_header, "ops": ops}, warnings def _widget_param_type(graph, class_type: str, widget: str) -> str: @@ -1350,7 +1509,13 @@ def apply_specs( try: if kind == "add_node": workflow, op = add_node( - workflow, graph, spec["class_type"], pos=spec.get("at"), actor=actor, base_version=base_version + workflow, + graph, + spec["class_type"], + pos=spec.get("at"), + mode=spec.get("mode") or 0, + actor=actor, + base_version=base_version, ) alias = spec.get("as") if alias: diff --git a/docs/op-vocabulary-v1.md b/docs/op-vocabulary-v1.md index 5c8b249f2..44c1b41eb 100644 --- a/docs/op-vocabulary-v1.md +++ b/docs/op-vocabulary-v1.md @@ -53,13 +53,18 @@ Every op carries the common envelope stamped by `_new_op`: Spec form (batch input): ```json -{"op": "add_node", "class_type": "KSampler", "at": [x, y], "as": "sampler"} +{"op": "add_node", "class_type": "KSampler", "at": [x, y], "as": "sampler", "mode": 4} ``` `at` is optional (layout assigns a collision-free position at mint time; the position freezes into the op). `as` is optional and declares a batch-local alias -(section 5). Minted op fields beyond the envelope: `node_id` (mint_id int), -`class_type`, `pos`, `node` (the complete node object — replay inserts it verbatim). +(section 5). `mode` is optional (amendment v1.4): the litegraph execution mode +the node is minted with — `0` always (default, omitted), `1` on-event, `2` mute, +`3` on-trigger, `4` bypass. Mute/bypass change what executes, so a recipe that +dropped them rebuilt a different API prompt. Minted op fields beyond the +envelope: `node_id` (mint_id int), `class_type`, `pos`, `node` (the complete +node object — replay inserts it verbatim; a nonzero mode is stamped into it and +echoed as an op-level `mode` field). * Idempotency: re-applying the same `op_id` is a no-op; independently, replaying an `add_node` whose `node_id` already exists in the graph is a no-op. @@ -686,3 +691,33 @@ abort (all discarded). The code now complies with the doc; no contract change. added, removed, or re-scoped; `FROZEN_OPS` / `DEFERRED_OPS` / `BATCHABLE_OPS` are untouched. Downstream repos pinning this document by SHA move the SHA and their applier/catalog pins together. + +## 13. Amendment v1.4 — 2026-08-19 (node mode; capture/apply agreement on UI-only nodes) + +### 13.1 `add_node` carries an optional `mode` + +A spec (and the minted op) may set `mode` to a litegraph execution mode +(`0` always — the default, omitted; `1` on-event; `2` mute; `3` on-trigger; +`4` bypass). Mute and bypass are graph-semantic — a bypassed node passes its +input through instead of executing — so capture→apply previously revived +bypassed nodes and produced a *different API prompt* from the source workflow. +The mode is stamped into `op.node` (which stays authoritative for replay, §8.5) +and echoed as an op-level field when nonzero. An op without `mode` is exactly +the pre-amendment shape, so existing ops replay unchanged. + +### 13.2 `capture` no longer emits ops `apply` refuses + +`add_node` has always rejected UI-only node types (`Note`, `MarkdownNote`, +`PrimitiveNode`, `GetNode`, `SetNode`, `Reroute`) — they exist only in the +editor graph and never reach the API. `capture` nevertheless emitted `add_node` +specs for them, so any workflow containing so much as a documentation note +captured into a recipe the (correctly atomic) `apply` discarded whole. capture +now skips UI-only nodes and preserves the data flow they carried: links through +`Reroute` chains and `GetNode`→`SetNode` pairs are spliced to the real upstream +source (the same resolution the UI→API converter applies), and a +`PrimitiveNode`'s value is captured as the fed widget's value. Skipped nodes +are reported as structured warnings on the capture envelope. The recipe +rebuilds the executable graph — the API prompt — not the canvas decoration. + +**No change to §§2-8.** No op kind was added, removed, or re-scoped; +`FROZEN_OPS` / `DEFERRED_OPS` / `BATCHABLE_OPS` are untouched. diff --git a/tests/comfy_cli/command/test_workflow_edit.py b/tests/comfy_cli/command/test_workflow_edit.py index b9608cde8..e521b7964 100644 --- a/tests/comfy_cli/command/test_workflow_edit.py +++ b/tests/comfy_cli/command/test_workflow_edit.py @@ -483,6 +483,31 @@ def _write(tmp_path: Path, data: dict, name: str = "wf.json") -> Path: return p +def _force_pretty_renderer(): + r = Renderer.resolve( + is_stdout_tty=True, + env={}, + caller=Caller(kind="user", agentic=False, source_env=None), + no_json_flag=True, + ) + r.mode = OutputMode.PRETTY + set_renderer(r) + return r + + +def _invoke_raw(args: list[str], capsys, force=_force_json_renderer) -> tuple[str, str, Any]: + """Invoke and return raw (stdout, stderr, result) — for asserting on the + stream contract itself rather than the parsed envelope.""" + force() + runner = CliRunner() + result = runner.invoke(workflow_cmd.app, args, standalone_mode=False) + captured = capsys.readouterr() + out = captured.out + if not out.strip(): + out = result.stdout or "" + return out, captured.err, result + + def _run(args: list[str], capsys) -> dict[str, Any]: _force_json_renderer() runner = CliRunner() @@ -1529,6 +1554,235 @@ def test_capture_rejects_subgraphs(self, patched_graph, tmp_path, capsys): assert "subgraph" in env["error"]["message"].lower() +# --------------------------------------------------------------------------- +# capture ↔ apply agreement on UI-only nodes (PR-511 review, Finding 1): +# capture must not emit ops apply refuses — one MarkdownNote in the source +# used to discard the whole atomic batch (114/306 official templates). +# --------------------------------------------------------------------------- + + +class TestCaptureUiOnlyNodes: + def _apply_on_empty(self, recipe: dict, tmp_path, capsys) -> dict: + empty = _write(tmp_path, {"nodes": [], "links": [], "last_node_id": 0, "last_link_id": 0}, "empty.json") + rp = tmp_path / "recipe.json" + rp.write_text(json.dumps(recipe), encoding="utf-8") + env = _run(["apply", str(empty), "--ops", str(rp)], capsys) + assert env["ok"] is True, env + return json.loads(empty.read_text()) + + def test_capture_skips_markdown_note_and_apply_accepts(self, patched_graph, tmp_path, capsys): + wf = _base_workflow() + wf["nodes"].append( + { + "id": 40, + "type": "MarkdownNote", + "pos": [0, 300], + "inputs": [], + "outputs": [], + "widgets_values": ["# doc"], + } + ) + wf["last_node_id"] = 40 + src = _write(tmp_path, wf) + cap = _run(["capture", str(src)], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + assert not any(o.get("class_type") == "MarkdownNote" for o in recipe["ops"]) + warns = cap["data"]["warnings"] + assert any(w["code"] == "ui_only_node_skipped" and w["class_type"] == "MarkdownNote" for w in warns) + # The headline contract: the captured recipe round-trips through apply. + rebuilt = self._apply_on_empty(recipe, tmp_path, capsys) + assert sorted(n["type"] for n in rebuilt["nodes"]) == ["EmptyLatentImage", "KSampler"] + assert len(rebuilt["links"]) == 1 + + def test_capture_splices_links_through_reroute(self, patched_graph, tmp_path, capsys): + """EmptyLatentImage → Reroute → KSampler must capture as a direct + connect — skipping the Reroute must not lose the wire.""" + wf = _base_workflow() + ks = next(n for n in wf["nodes"] if n["id"] == 3) + ks["inputs"][3]["link"] = 2 + lat = next(n for n in wf["nodes"] if n["id"] == 7) + lat["outputs"][0]["links"] = [1] + wf["nodes"].append( + { + "id": 30, + "type": "Reroute", + "pos": [50, 50], + "inputs": [{"name": "", "type": "*", "link": 1}], + "outputs": [{"name": "", "type": "LATENT", "links": [2]}], + "widgets_values": [], + } + ) + wf["links"] = [[1, 7, 0, 30, 0, "LATENT"], [2, 30, 0, 3, 3, "LATENT"]] + wf["last_node_id"] = 30 + wf["last_link_id"] = 2 + src = _write(tmp_path, wf) + cap = _run(["capture", str(src)], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + connects = [o for o in recipe["ops"] if o["op"] == "connect"] + assert connects == [{"op": "connect", "from": "emptylatentimage.LATENT", "to": "ksampler.latent_image"}] + rebuilt = self._apply_on_empty(recipe, tmp_path, capsys) + assert len(rebuilt["links"]) == 1 # spliced wire survives the round-trip + + def test_capture_splices_links_through_get_set_nodes(self, patched_graph, tmp_path, capsys): + wf = _base_workflow() + ks = next(n for n in wf["nodes"] if n["id"] == 3) + ks["inputs"][3]["link"] = 2 + lat = next(n for n in wf["nodes"] if n["id"] == 7) + lat["outputs"][0]["links"] = [1] + wf["nodes"].extend( + [ + { + "id": 32, + "type": "SetNode", + "pos": [50, 0], + "inputs": [{"name": "LATENT", "type": "LATENT", "link": 1}], + "outputs": [], + "widgets_values": ["lat"], + }, + { + "id": 33, + "type": "GetNode", + "pos": [50, 100], + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": [2]}], + "widgets_values": ["lat"], + }, + ] + ) + wf["links"] = [[1, 7, 0, 32, 0, "LATENT"], [2, 33, 0, 3, 3, "LATENT"]] + wf["last_node_id"] = 33 + wf["last_link_id"] = 2 + src = _write(tmp_path, wf) + cap = _run(["capture", str(src)], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + connects = [o for o in recipe["ops"] if o["op"] == "connect"] + assert connects == [{"op": "connect", "from": "emptylatentimage.LATENT", "to": "ksampler.latent_image"}] + + def test_capture_carries_primitive_value_into_widget(self, patched_graph, tmp_path, capsys): + """A PrimitiveNode feeding a widget-input captures as the widget's + value, not a wire — the primitive itself is skipped.""" + wf = _base_workflow() + lat = next(n for n in wf["nodes"] if n["id"] == 7) + lat["inputs"] = [{"name": "width", "type": "INT", "link": 5, "widget": {"name": "width"}}] + wf["nodes"].append( + { + "id": 31, + "type": "PrimitiveNode", + "pos": [0, 200], + "inputs": [], + "outputs": [{"name": "INT", "type": "INT", "links": [5]}], + "widgets_values": [768, "fixed"], + } + ) + wf["links"].append([5, 31, 0, 7, 0, "INT"]) + wf["last_node_id"] = 31 + wf["last_link_id"] = 5 + src = _write(tmp_path, wf) + cap = _run(["capture", str(src)], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + assert not any(o.get("class_type") == "PrimitiveNode" for o in recipe["ops"]) + assert not any(o["op"] == "connect" and "primitive" in o.get("from", "") for o in recipe["ops"]) + assert any(o["op"] == "set_widget" and o["widget"] == "width" and o["value"] == 768 for o in recipe["ops"]) + rebuilt = self._apply_on_empty(recipe, tmp_path, capsys) + g = _graph() + lat = next(n for n in rebuilt["nodes"] if n["type"] == "EmptyLatentImage") + assert lat["widgets_values"][g.widget_order("EmptyLatentImage").index("width")] == 768 + + def test_capture_preserves_node_mode(self, patched_graph, tmp_path, capsys): + """A bypassed (mode 4) node must come back bypassed — reviving it + changes what executes (api_elevenLabs_speech_to_text: 4 API nodes in, + 5 out).""" + wf = _base_workflow() + next(n for n in wf["nodes"] if n["id"] == 3)["mode"] = 4 + src = _write(tmp_path, wf) + cap = _run(["capture", str(src)], capsys) + assert cap["ok"] is True, cap + recipe = cap["data"]["recipe_doc"] + add = next(o for o in recipe["ops"] if o["op"] == "add_node" and o["class_type"] == "KSampler") + assert add["mode"] == 4 + rebuilt = self._apply_on_empty(recipe, tmp_path, capsys) + assert next(n for n in rebuilt["nodes"] if n["type"] == "KSampler")["mode"] == 4 + + +# --------------------------------------------------------------------------- +# --stdout envelope contract (PR-511 review, Finding 2): same rules set-slot +# pins — JSON mode puts ONE envelope on stdout with the document riding in +# data.workflow_json; human mode puts exactly the raw workflow on stdout (the +# ✓ line used to land inside `> new.json` redirects and corrupt them). +# --------------------------------------------------------------------------- + + +class TestStdoutEnvelopeContract: + def test_add_node_stdout_json_is_single_envelope(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + original = path.read_text() + out, _err, _ = _invoke_raw(["add-node", str(path), "VAEDecode", "--stdout"], capsys) + lines = [ln for ln in out.strip().splitlines() if ln.strip()] + assert len(lines) == 1, f"JSON mode must put exactly one envelope on stdout, got {len(lines)} lines" + env = json.loads(lines[0]) + assert env["schema"] == "envelope/1" and env["ok"] is True + assert env["changed"] is False, "--stdout writes nothing, so the envelope must not claim a change" + data = env["data"] + assert data["out"] == "stdout" and data["wrote"] is None + assert any(n["type"] == "VAEDecode" for n in data["workflow_json"]["nodes"]) + assert path.read_text() == original + + def test_add_node_stdout_human_prints_raw_workflow(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + out, _err, _ = _invoke_raw(["add-node", str(path), "VAEDecode", "--stdout"], capsys, _force_pretty_renderer) + wf = json.loads(out) # the WHOLE stream must parse as one document + assert any(n["type"] == "VAEDecode" for n in wf["nodes"]) + + def test_delete_nodes_stdout_json_carries_workflow(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + original = path.read_text() + out, _err, _ = _invoke_raw(["delete-nodes", str(path), "7", "--stdout"], capsys) + lines = [ln for ln in out.strip().splitlines() if ln.strip()] + assert len(lines) == 1 + env = json.loads(lines[0]) + assert env["ok"] is True and env["changed"] is False + data = env["data"] + assert data["out"] == "stdout" and data["wrote"] is None + assert all(n["id"] != 7 for n in data["workflow_json"]["nodes"]) + assert path.read_text() == original + + def test_delete_nodes_stdout_human_prints_raw_workflow(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + out, _err, _ = _invoke_raw(["delete-nodes", str(path), "7", "--stdout"], capsys, _force_pretty_renderer) + wf = json.loads(out) + assert all(n["id"] != 7 for n in wf["nodes"]) + + def _ops_file(self, tmp_path) -> Path: + rp = tmp_path / "ops.json" + rp.write_text(json.dumps([{"op": "add_node", "class_type": "VAEDecode"}]), encoding="utf-8") + return rp + + def test_apply_stdout_json_carries_workflow(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + original = path.read_text() + out, _err, _ = _invoke_raw(["apply", str(path), "--ops", str(self._ops_file(tmp_path)), "--stdout"], capsys) + lines = [ln for ln in out.strip().splitlines() if ln.strip()] + assert len(lines) == 1 + env = json.loads(lines[0]) + assert env["ok"] is True and env["changed"] is False + data = env["data"] + assert data["out"] == "stdout" and data["wrote"] is None + assert any(n["type"] == "VAEDecode" for n in data["workflow_json"]["nodes"]) + assert path.read_text() == original + + def test_apply_stdout_human_prints_raw_workflow(self, patched_graph, tmp_path, capsys): + path = _write(tmp_path, _base_workflow()) + out, _err, _ = _invoke_raw( + ["apply", str(path), "--ops", str(self._ops_file(tmp_path)), "--stdout"], capsys, _force_pretty_renderer + ) + wf = json.loads(out) + assert any(n["type"] == "VAEDecode" for n in wf["nodes"]) + + # --------------------------------------------------------------------------- # op-model correctness — direct against workflow_ops (P1..P7) # --------------------------------------------------------------------------- diff --git a/tests/comfy_cli/test_apply_replay_drift.py b/tests/comfy_cli/test_apply_replay_drift.py index 6adde9856..c222fb747 100644 --- a/tests/comfy_cli/test_apply_replay_drift.py +++ b/tests/comfy_cli/test_apply_replay_drift.py @@ -243,7 +243,7 @@ def test_capture_tolerates_dict_widgets_and_keeps_values(self, graph: Graph): ], "links": [], } - recipe = W.capture_recipe(wf, graph) # must not raise + recipe, _warnings = W.capture_recipe(wf, graph) # must not raise sets = {(o["widget"], o["value"]) for o in recipe["ops"] if o["op"] == "set_widget"} assert ("width", 768) in sets # non-default value survived the dict form