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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,42 @@ history.

## [Unreleased]

### Added

- `--select` takes gjson row queries: `items.#(<cond>)#` keeps the elements
that match and `items.#(<cond>)` is the first one, with `==` `!=` `<` `<=`
`>` `>=` `%` (glob) `!%` against a quoted string, number, `true`, `false` or
`null` (e.g. `slots.#(node_type=="CLIPTextEncode")#.address`,
`inputs.#(name=="lora_name").choices.#(%"*detail*")#`). The corpus in
`tests/data/selector_conformance.json` pins the grammar for other
implementations.
- `comfy workflow connect` wires two nodes inside the same subgraph
(`connect 70/2005.0 70/2011.text`) and emits a `connect` op carrying the
instance `path`. A link that crosses the subgraph boundary is still refused,
and so is a link inside a definition shared by several instances.
- `comfy workflow validate` reports broken link rows of a canvas workflow as
`link_slot_out_of_range` / `link_source_missing` errors, each with the
`connect` that repairs it. The UI→API lowering used to drop such a row and
validate said the graph was valid.
- `comfy workflow validate --full-options` and `comfy nodes show --all-choices`
(also on `nodes search --expand-top`) list a long option list in full.

### Changed

- An `unknown_enum_value` finding (validate, set-widget, edit batches) names the
closest options in `suggestions` (at most 5) with `option_count`, and carries
`valid_options` only when the list has 12 options or fewer. It used to carry
the whole folder listing twice; one validate in production came to ~340K
tokens.
- `comfy nodes show` and `nodes search --expand-top` cut a combo input's
`choices` longer than 20 to the first 20, with `choices_total`,
`choices_truncated` and a `choices_note` naming the `--select` that filters
the full list. `--select` still projects the full schema.
- `comfy workflow print` renders a graph with broken links instead of refusing
it: the input prints `None`, the line is marked `BROKEN`, and a warning names
the `connect` that repairs it. A stale row whose value already reaches the
node through another link is reported as a leftover.

### Fixed

- A cloud request the account's plan does not allow (free generations used up,
Expand Down
72 changes: 70 additions & 2 deletions comfy_cli/command/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from __future__ import annotations

import difflib
import json
import shlex
from typing import Annotated, Any

import typer
Expand Down Expand Up @@ -156,6 +158,52 @@ def _category_matches(category: str | None, pat: str) -> bool:
# ---------------------------------------------------------------------------


#: A combo input's ``choices`` longer than this are capped in ``nodes show`` /
#: ``nodes search --expand-top``. A loader's choices are every installed file
#: (600+ LoRAs on cloud, ~31KB in one show), and a caller wiring the node
#: needs one of them, by a name it usually already has.
CHOICES_INLINE_MAX = 20


def _cap_choices(payload: dict[str, Any]) -> dict[str, Any]:
"""Cap every long ``choices`` list in a show payload (recursing into
dynamic-combo sub-inputs) to its first :data:`CHOICES_INLINE_MAX` entries,
with ``choices_total`` and ``choices_truncated``. Adds a top-level
``choices_note`` naming how to read or filter the full list, once."""
capped: list[str] = []
top_level: list[str] = []

def walk(inputs: Any, nested: bool = False) -> None:
if not isinstance(inputs, list):
return
for entry in inputs:
if not isinstance(entry, dict):
continue
choices = entry.get("choices")
if isinstance(choices, list) and len(choices) > CHOICES_INLINE_MAX:
entry["choices_total"] = len(choices)
entry["choices"] = choices[:CHOICES_INLINE_MAX]
entry["choices_truncated"] = True
capped.append(str(entry.get("name")))
if not nested:
top_level.append(str(entry.get("name")))
for option in entry.get("dynamic_options") or []:
if isinstance(option, dict):
walk(option.get("inputs"), nested=True)

walk(payload.get("inputs"))
if capped:
name = shlex.quote(str(payload["name"])) if payload.get("name") else "<class>"
note = f"{', '.join(capped)}: only the first {CHOICES_INLINE_MAX} choices are listed (see choices_total). "
if top_level:
# A nested (dynamic-combo) input is not under top-level `inputs`,
# so only a top-level one gets the filter query.
query = shlex.quote(f'inputs.#(name=={json.dumps(top_level[0])}).choices.#(%"*<text>*")#')
note += f"Check or find one with `comfy nodes show {name} --select {query}`; "
payload["choices_note"] = note + "--all-choices lists every choice."
return payload


@app.command(
"ls",
help="List node classes. Filter via --produces/--accepts/--category/--pack/--label or boolean flags.",
Expand Down Expand Up @@ -374,9 +422,18 @@ def show_cmd(
typer.Option(
"--select",
show_default=False,
help="Project the payload: dot path (inputs.0.name), wildcard (inputs.#.name), comma multi-select.",
help="Project the payload: dot path (inputs.0.name), wildcard (inputs.#.name), comma multi-select, "
'row query (inputs.#(name=="ckpt_name").choices). Projects the full schema, every choice included.',
),
] = None,
all_choices: Annotated[
bool,
typer.Option(
"--all-choices",
help=f"List every choice of a combo input. By default a list longer than {CHOICES_INLINE_MAX} is cut "
"to its first entries, with `choices_total`.",
),
] = False,
):
renderer = get_renderer()
_stale: dict = {}
Expand Down Expand Up @@ -457,6 +514,8 @@ def show_cmd(
from comfy_cli.selector import emit_selected

return emit_selected(renderer, payload, select, command="nodes show")
if not all_choices:
_cap_choices(payload)

if renderer.is_pretty():
from rich.table import Table
Expand Down Expand Up @@ -528,6 +587,14 @@ def search_cmd(
),
),
] = 0,
all_choices: Annotated[
bool,
typer.Option(
"--all-choices",
help=f"With --expand-top: list every choice of a combo input (default: the first {CHOICES_INLINE_MAX}, "
"with `choices_total`).",
),
] = False,
include_deprecated: IncludeDeprecatedOpt = False,
input_path: Annotated[
str | None,
Expand Down Expand Up @@ -667,7 +734,8 @@ def search_cmd(
}
)
continue
expanded.append({"class_type": m.id, **graph.morphism_to_dict(resolved)})
schema = graph.morphism_to_dict(resolved)
expanded.append({"class_type": m.id, **(schema if all_choices else _cap_choices(schema))})
payload["expanded"] = expanded

if _stale:
Expand Down
37 changes: 34 additions & 3 deletions comfy_cli/command/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,7 @@ def validate_api_workflow(
port: int | None = None,
input_path: str | None = None,
command: str = "workflow validate",
full_options: bool = False,
) -> None:
"""Validate an API-format workflow without submitting it.

Expand Down Expand Up @@ -1730,6 +1731,8 @@ def validate_api_workflow(
# The converter reuses the object_info the graph was already built from
# (`graph.object_info`), so offline `--input` works and no second fetch happens.
converted_from_ui = False
link_errors: list[dict] = []
link_warnings: list[dict] = []
if is_ui_workflow(wf_data):
if renderer.is_pretty():
rprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]")
Expand Down Expand Up @@ -1761,10 +1764,18 @@ def validate_api_workflow(
)
raise typer.Exit(code=1)
editable_ids = _editable_node_ids(wf_data)
from comfy_cli.link_integrity import broken_link_findings

# The lowering reads each input's own `link` and never a row's slots,
# so a broken row disappears in it; judge the canvas before it does.
link_errors, link_warnings = broken_link_findings(wf_data)
wf_data = converted
converted_from_ui = True

result = graph.validate_workflow(wf_data)
from comfy_cli.cql.engine import full_enum_options

with full_enum_options(full_options):
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
Expand All @@ -1779,6 +1790,11 @@ def validate_api_workflow(
if editable != nid:
issue["api_node_id"] = nid
issue["node_id"] = editable
if link_errors or link_warnings:
result["errors"].extend(link_errors)
result["warnings"].extend(link_warnings)
if link_errors:
result["valid"] = False

# Preview credit spend: partner-API (paid) nodes spend Comfy credits when the
# workflow is run. This is the same detection `comfy run` uses (authoritative
Expand Down Expand Up @@ -1881,7 +1897,8 @@ def _invalid_workflow_error(result: dict[str, Any]) -> dict[str, Any] | None:
for error in errors[:5]:
line = f"node {error.get('node_id') or '?'}: {error.get('message', '')}"
suggestions = error.get("suggestions") or []
if suggestions:
# An enum message already names its closest options ("— closest: …").
if suggestions and "closest:" not in line:
line += f" (did you mean: {', '.join(str(s) for s in suggestions)}?)"
hint_parts.append(line)
# The code is a registered catch-all raised for every verdict, so it alone
Expand Down Expand Up @@ -1958,9 +1975,23 @@ def validate_cmd(
str | None,
typer.Option("--input", show_default=False, help="Path to a saved object_info JSON (offline mode)."),
] = None,
full_options: Annotated[
bool,
typer.Option(
"--full-options",
help="List every option of a rejected enum value as `valid_options`. By default an error names the "
"closest options and `option_count`, and carries the whole list only when it is short.",
),
] = False,
):
validate_api_workflow(
workflow, where=where, host=host, port=port, input_path=input_path, command="workflow validate"
workflow,
where=where,
host=host,
port=port,
input_path=input_path,
command="workflow validate",
full_options=full_options,
)


Expand Down
Loading
Loading