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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 9 additions & 27 deletions comfy_cli/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,8 +787,9 @@ def run(
help=(
"Positive text prompt for the bundled default text2img workflow "
"(used when --workflow is omitted). Cannot be combined with --workflow. "
"The bundled graph loads an SD1.5 checkpoint (v1-5-pruned-emaonly.ckpt) "
"that is NOT downloaded for you — install it, or point elsewhere with "
"The bundled graph prefers an SD1.5 checkpoint "
"(v1-5-pruned-emaonly-fp16.safetensors); if the target doesn't have it, "
"an installed checkpoint is substituted and reported. Pin your own with "
"--set checkpoint=<name>."
),
),
Expand Down Expand Up @@ -944,7 +945,7 @@ def run(
# against OUR pinned node ids, so mixing them with a user --workflow —
# whose node ids are arbitrary — is rejected rather than silently
# misapplied. `preloaded` is handed straight to run's execute path.
preloaded: tuple[dict, str, bool] | None = None
preloaded: tuple[dict, str, bool, bool] | None = None
if prompt is not None or set_overrides:
if workflow is not None:
renderer.error(
Expand All @@ -956,37 +957,18 @@ def run(
from comfy_cli.cql.default_workflow import (
PromptInjectionError,
build_default_workflow,
default_checkpoint,
overrides_set_checkpoint,
)

try:
injected = build_default_workflow(prompt=prompt, overrides=set_overrides)
except PromptInjectionError as e:
renderer.error(code=e.code, message=str(e), hint=e.hint)
raise typer.Exit(code=1) from e
preloaded = (injected, "default_text2img", False)
# The bundled graph pins an SD1.5 checkpoint that comfy-cli neither
# ships nor auto-downloads. Without it the run dies server-side on a
# bare validation error, so state the dependency up front. Pretty
# output only — the JSON dialects carry a fixed event contract.
ckpt = default_checkpoint(injected)
if ckpt and renderer.is_pretty():
from rich.markup import escape as _escape

# The checkpoint has to exist wherever the run is routed, so
# name that environment: pointing a `--where cloud` run at the
# local models/checkpoints sends the user to fix the wrong box.
if decision.target is where_module.WhereTarget.CLOUD:
where_ckpt = "in your cloud assets (`comfy models search --where cloud`)"
else:
where_ckpt = "in models/checkpoints"
# `--set checkpoint=…` puts a user string here; escape it so a
# value containing [brackets] can't be read as rich markup.
rprint(
f"[dim]Using the bundled default text2img workflow — it needs the[/dim] "
f"[bold]{_escape(ckpt)}[/bold] [dim]checkpoint {where_ckpt}. "
f"Override it with --set checkpoint=<name>.[/dim]"
)
# If the user pinned the checkpoint (--set checkpoint=… / 4.ckpt_name=…),
# honor it verbatim: runtime resolution is skipped downstream.
checkpoint_user_set = overrides_set_checkpoint(set_overrides, injected)
preloaded = (injected, "default_text2img", False, checkpoint_user_set)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
elif workflow is None:
renderer.error(
code="prompt_rejected",
Expand Down
78 changes: 57 additions & 21 deletions comfy_cli/command/run/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from comfy_cli.command.run.preflight import _detect_partner_nodes as _detect_partner_nodes
from comfy_cli.command.run.preflight import _fetch_object_info as _fetch_object_info
from comfy_cli.command.run.preflight import _preflight_validate as _preflight_validate
from comfy_cli.command.run.preflight import _resolve_default_checkpoint_or_exit as _resolve_default_checkpoint_or_exit
from comfy_cli.command.run.preflight import fetch_object_info as fetch_object_info
from comfy_cli.command.run.watcher import _spawn_watcher as _spawn_watcher
from comfy_cli.command.run.watcher import _tail_state_file as _tail_state_file
Expand Down Expand Up @@ -165,7 +166,7 @@ def execute(
notify: bool = False,
api_key: str | None = None,
print_prompt: bool = False,
preloaded: tuple[dict, str, bool] | None = None,
preloaded: tuple[dict, str, bool, bool] | None = None,
allow_spend: bool = False,
):
# `0.0.0.0` is a wildcard bind, not a connect address. macOS / Windows
Expand All @@ -185,10 +186,13 @@ def execute(

# `preloaded` short-circuits file loading: an in-memory API-format graph
# (e.g. the `comfy run --prompt` injected default) is handed straight in as
# (workflow_dict, display_name, is_ui). Everything downstream is unchanged.
# (workflow_dict, display_name, is_ui, checkpoint_user_set). Everything
# downstream is unchanged; `checkpoint_user_set` gates runtime checkpoint
# resolution for the bundled default (skip it when the user pinned one).
if preloaded is not None:
raw_workflow, workflow_name, is_ui = preloaded
raw_workflow, workflow_name, is_ui, checkpoint_user_set = preloaded
else:
checkpoint_user_set = False
try:
raw_workflow, workflow_name, is_ui = _load_workflow_file(workflow)
except WorkflowLoadError as e:
Expand Down Expand Up @@ -254,11 +258,30 @@ def execute(
# foreach item map to stash on the job state at submit time.
compose_meta = pop_compose_meta(workflow)

# Partner-API node preflight (below) and runtime checkpoint resolution both
# need the server's object_info. `--print-prompt` is a documented
# no-server-hit dry-run, so skip the fetch + resolution there and print the
# graph as-is; the real submit flow resolves BEFORE the prompt_preview event
# so the streamed audit trail advertises the graph we actually submit.
object_info: dict = {}
if not print_prompt:
object_info = _fetch_object_info(host, port)

# Runtime checkpoint resolution for the bundled `--prompt` default: swap
# the pinned checkpoint for one the local server actually has (or
# hard-error if it has none). Guarded to the bundled default graph and
# skipped when the user pinned the checkpoint explicitly (honor it; let
# preflight reject it).
if preloaded is not None and workflow_name == "default_text2img" and not checkpoint_user_set:
_resolve_default_checkpoint_or_exit(renderer, workflow, object_info, where="local")

# Stream mode: emit the workflow graph so agents have a complete audit
# trail of what the CLI is about to submit (no-op otherwise).
renderer.event("prompt_preview", prompt=workflow)

# --print-prompt: emit/print the workflow and exit without submitting.
# --print-prompt: emit/print the workflow and exit without submitting. No
# server hit (documented) — the graph is shown as-is, before any
# server-dependent checkpoint resolution.
if print_prompt:
if renderer.is_pretty():
print(json.dumps(workflow, indent=2, ensure_ascii=False))
Expand All @@ -270,12 +293,6 @@ def execute(
)
return

# Partner-API node preflight. Reject up-front when the workflow
# depends on a partner node (Veo/Kling/BFL/Gemini/…) and we have no
# credential to inject. If we DO have a credential, plumb it into
# extra_data so the partner node finds it server-side — same shape
# the cloud submit path uses.
object_info = _fetch_object_info(host, port)
partner_nodes = _detect_partner_nodes(workflow, object_info)
# Spend gate (BE-4326): partner-API nodes spend Comfy credits. Require
# explicit consent before resolving a credential or submitting. Fires
Expand Down Expand Up @@ -715,7 +732,7 @@ def execute_cloud(
timeout: int = 600,
notify: bool = False,
print_prompt: bool = False,
preloaded: tuple[dict, str, bool] | None = None,
preloaded: tuple[dict, str, bool, bool] | None = None,
allow_spend: bool = False,
):
"""Run a workflow against Comfy Cloud via the stored OAuth session.
Expand All @@ -731,14 +748,22 @@ def execute_cloud(

renderer = get_renderer()
if preloaded is not None:
raw_workflow, workflow_name, is_ui = preloaded
raw_workflow, workflow_name, is_ui, checkpoint_user_set = preloaded
else:
checkpoint_user_set = False
try:
raw_workflow, workflow_name, is_ui = _load_workflow_file(workflow)
except WorkflowLoadError as e:
renderer.error(code=e.code, message=str(e), hint=e.hint)
raise typer.Exit(code=1) from e

# The cloud object_info snapshot is used twice below (UI→API conversion and
# checkpoint resolution/preflight). `_load_from_target` is a live, uncached
# HTTPS fetch, so load it at most once and share it across both. `None`
# means "not fetched yet" — distinct from a fetched-but-empty snapshot,
# which must NOT trigger a second round-trip.
cloud_object_info: dict | None = None

if is_ui:
# Frontend-format workflows (the `nodes`+`links` shape from the canvas
# exporter and `comfy templates fetch`) have to be lowered to the API
Expand All @@ -749,7 +774,7 @@ def execute_cloud(
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 = cloud_object_info = _load_from_target(mode="cloud")
except Exception as e: # noqa: BLE001
renderer.error(
code="cql_no_graph",
Expand Down Expand Up @@ -793,6 +818,25 @@ def execute_cloud(
# its foreach item map to stash on the job state at submit time.
compose_meta = pop_compose_meta(parsed_workflow)

# Cloud path uses cached/bundled object_info (no live server needed). Load
# it up front so checkpoint resolution can run BEFORE the preview/print
# below — the audit trail must advertise the graph we actually submit.
# Already fetched above when the workflow arrived in UI format.
if cloud_object_info is None:
try:
from comfy_cli.cql.engine import _load_from_target

cloud_object_info = _load_from_target(mode="cloud")
except Exception: # noqa: BLE001
cloud_object_info = {}

# Runtime checkpoint resolution for the bundled `--prompt` default (mirrors
# the local path): swap the pinned checkpoint for one Comfy Cloud actually
# has. Guarded to the bundled default and skipped when the user pinned the
# checkpoint explicitly. Cloud fails open on an empty enum (per-job models).
if preloaded is not None and workflow_name == "default_text2img" and not checkpoint_user_set:
_resolve_default_checkpoint_or_exit(renderer, parsed_workflow, cloud_object_info, where="cloud")

if print_prompt:
# Documented dry-run: show the API-format graph that WOULD be sent and
# exit WITHOUT POSTing. Mirrors local execute()'s print_prompt branch.
Expand All @@ -808,14 +852,6 @@ def execute_cloud(
raise typer.Exit(code=0)

# Pre-submit validation via pure-Python CQL engine.
# Cloud path uses cached/bundled object_info (no live server needed).
try:
from comfy_cli.cql.engine import _load_from_target

cloud_object_info = _load_from_target(mode="cloud")
except Exception: # noqa: BLE001
cloud_object_info = {}

_preflight_validate(renderer, parsed_workflow, cloud_object_info, target_label="cloud")

# Spend gate (BE-4326): the cloud also bills partner-API nodes, so apply the
Expand Down
61 changes: 61 additions & 0 deletions comfy_cli/command/run/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,67 @@ def _preflight_validate(renderer, workflow: dict, object_info: dict, *, target_l
)


def _resolve_default_checkpoint_or_exit(renderer, workflow: dict, object_info: dict, *, where: str) -> None:
"""Runtime-resolve the bundled default's pinned checkpoint against the
target, in place, then report the outcome through the renderer.

Call ONLY for the bundled default graph (``workflow_name ==
"default_text2img"``) when the user did NOT explicitly ``--set`` the
checkpoint. Three outcomes:

- pinned present / can't tell (object_info empty or not enumerated) → no-op
(fail open — preflight + the server decide);
- pinned absent but the target has ≥1 checkpoint → substitute the first
available one and emit a ``checkpoint_substituted`` note;
- target positively has zero checkpoints → for ``where="local"`` a hard
``no_checkpoint_available`` error (exit 1) instead of a cryptic
server-side reject; for ``where="cloud"`` a no-op (fail open), since Comfy
Cloud provisions its models per-job and the cached enum can't prove the
run would fail.

``where`` is ``"local"`` or ``"cloud"`` and drives the target label + hint.
"""
from comfy_cli.cql.default_workflow import resolve_default_checkpoint

target_label = "the local server" if where == "local" else "Comfy Cloud"
_, res = resolve_default_checkpoint(workflow, object_info, target=target_label)

# Comfy Cloud provisions its models per-job at runtime, so an empty
# checkpoint enum in the cached/bundled cloud object_info does NOT mean the
# run would fail — hard-erroring there would wrongly block valid default
# cloud submits. Only the local path (where the enum reflects what's
# actually installed) treats a positively-empty enum as a hard stop.
if res.no_checkpoint and where == "cloud":
return

if res.no_checkpoint:
# Only reachable for the local path (cloud returned above).
hint = (
"download a checkpoint, e.g. `comfy model download --url "
"https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/"
"v1-5-pruned-emaonly-fp16.safetensors`, then re-run — or `--set checkpoint=<name>`"
)
renderer.error(
code="no_checkpoint_available",
message=(
f"the bundled default text2img workflow needs a checkpoint, but {target_label} has none installed"
),
hint=hint,
details={"where": where},
)
raise typer.Exit(code=1)

if res.note:
# Event fires in NDJSON/stream mode only; the pretty line covers humans.
renderer.event("checkpoint_substituted", message=res.note, checkpoint=res.substituted_to, where=where)
Comment thread
mattmillerai marked this conversation as resolved.
if renderer.is_pretty():
from rich.markup import escape

# res.note embeds a target-provided checkpoint name; escape it so a
# name containing Rich tags can't inject terminal markup.
pprint(f"[yellow]⚠ {escape(res.note)}[/yellow]")


def _fetch_object_info(host: str, port: int) -> dict:
"""Fetch object_info for partner-node detection + validation. Fail open."""
try:
Expand Down
2 changes: 1 addition & 1 deletion comfy_cli/cql/data/default_text2img.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {
"ckpt_name": "v1-5-pruned-emaonly.ckpt"
"ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors"
},
"_meta": {"title": "Load Checkpoint"}
},
Expand Down
Loading
Loading