Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f795e39
feat(workflow): agent-first workflow editing — CRDT edit primitives, …
skishore23 Jul 14, 2026
baf71ad
fix(workflow): address high/medium review findings on agent-workflow …
skishore23 Jul 14, 2026
e24be64
chore: ruff 0.15.15 format (match CI pin)
skishore23 Jul 14, 2026
d8d76b6
fix(security): derive subgraph fork id with SHA-256, not SHA-1
skishore23 Jul 14, 2026
6513fa2
fix(error-codes): register the normalized_value warning code
skishore23 Jul 14, 2026
0c37bd0
fix(run,workflow): telemetry cloud lifecycle + address CodeRabbit review
skishore23 Jul 14, 2026
1fde508
fix(workflow): reject malformed autogrow connect targets instead of g…
skishore23 Jul 23, 2026
5b3292d
fix(workflow): resolve bare autogrow element names (image1) against t…
skishore23 Jul 24, 2026
9dea33b
fix(workflow,validate): unify subgraph interior id namespaces across …
skishore23 Jul 24, 2026
919420c
feat(layout): deterministic placement primitives for CLI-minted nodes
skishore23 Jul 24, 2026
7289a5a
style: ruff-format layout module
skishore23 Jul 24, 2026
3a6e555
feat(workflow): layout-aware default position + real size estimate fo…
skishore23 Jul 24, 2026
6c3f0f7
feat(workflow): topology-aware batch layout pre-pass in apply_specs
skishore23 Jul 24, 2026
d6977a7
fix(layout): directional anchors + full longest-path relaxation in as…
skishore23 Jul 24, 2026
9f60306
feat(workflow): first-class clear command (single clear op, ids stay …
skishore23 Jul 24, 2026
e5f4b8e
fix(workflow): a failed batch must not advertise ids the rollback dis…
skishore23 Jul 28, 2026
25ee3b9
fix(workflow): accept a connect whose target input declares a type UNION
skishore23 Jul 28, 2026
315249d
fix(workflow): give callers the identifiers they were missing
skishore23 Jul 30, 2026
e4bab89
fix(generate): string-array flags accept a bare value or comma list
skishore23 Jul 31, 2026
39081cd
feat(generate): emit-workflow materializes multiple image files
skishore23 Jul 31, 2026
282559b
fix(generate): unknown-model suggestions include the requested family
skishore23 Jul 31, 2026
752575b
test: isolate unknown-model test from real _ALIASES
skishore23 Jul 31, 2026
330de7b
fix(workflow): autogrow slot names come from the node schema, not a p…
skishore23 Jul 31, 2026
b02316d
feat(workflow): connect understands the kijai inputcount family
skishore23 Jul 31, 2026
8f0d58a
fix(generate): empty string-array value raises a coded error, not Ind…
skishore23 Jul 31, 2026
828ed3c
fix(workflow): tolerate dict-shaped widgets_values instead of KeyError
skishore23 Jul 31, 2026
3764e39
fix(workflow): connect no longer crashes on a never-wired output slot
skishore23 Jul 31, 2026
636e989
fix(workflow): a single-output node's output slot resolves under any …
skishore23 Jul 31, 2026
35c1069
fix(config): make the tmp-dir create idempotent (concurrent load() race)
skishore23 Aug 1, 2026
499cb7f
fix(workflow): connect + nodes show explain the subgraph boundary
skishore23 Aug 6, 2026
803d98f
Merge main into the CRDT branch (127 commits of drift)
skishore23 Aug 6, 2026
b422383
Merge remote-tracking branch 'origin/fix/validate-lowers-ui-to-api' i…
skishore23 Aug 6, 2026
4f102f5
docs(workflow_to_api): point stale comments at the renamed helper
skishore23 Aug 6, 2026
1e8b154
fix(workflow-ops): mint the first FREE autogrow slot, not the Nth
skishore23 Aug 6, 2026
538702a
fix(cql): never enum-validate an upload-backed input port
skishore23 Aug 7, 2026
1a8d93c
fix(cql): treat COMFY_MATCHTYPE ports as wildcards, not type mismatches
skishore23 Aug 9, 2026
42f56e5
fix(cql): make a node that reaches no output visible instead of silen…
skishore23 Aug 9, 2026
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
134 changes: 102 additions & 32 deletions comfy_cli/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import webbrowser
from typing import Annotated

import questionary
import typer
from rich.console import Console

Expand Down Expand Up @@ -958,6 +957,27 @@ 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,
allow_spend: Annotated[
bool,
typer.Option(
Expand All @@ -973,6 +993,9 @@ def run(
_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
Expand All @@ -993,6 +1016,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.
Expand Down Expand Up @@ -1038,44 +1066,48 @@ 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,
verbose=verbose,
timeout=timeout,
notify=effective_notify,
print_prompt=print_prompt,
workflow_id=workflow_id,
preloaded=preloaded,
allow_spend=allow_spend,
)
return
else:
from comfy_cli.host_port import parse_host_port_arg, resolve_host_port

from comfy_cli.host_port import parse_host_port_arg, resolve_host_port
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

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)
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,
)
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)
Expand All @@ -1097,16 +1129,16 @@ def run(

@app.command(
help=(
"Validate a workflow without submitting (UI exports are converted to API format first). "
"Checks class_types, input shapes, enum values, edge wiring, and the dotted sub-inputs a "
"dynamic combo's selected option requires."
"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, edge wiring, and the dotted sub-inputs a dynamic combo's selected option requires."
)
)
@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,
Expand Down Expand Up @@ -1134,6 +1166,7 @@ def validate(
from comfy_cli.command.run import is_ui_workflow
from comfy_cli.command.run.preflight import _detect_partner_nodes
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

renderer = get_renderer()
Expand Down Expand Up @@ -1187,8 +1220,17 @@ def validate(
port = parsed_port
host, port = resolve_host_port(host, port)

# 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, port=port)
# Pass host/port through unchanged: the local branch above already
# resolved them via resolve_host_port, and defaulting here would make a
# cloud run report 127.0.0.1:8188 as the object_info source.
object_info = resilient_load_object_info(mode=mode, input_path=input_path, host=host, port=port)
except LoadError as e:
renderer.error(
code="cql_no_graph",
Expand All @@ -1197,7 +1239,18 @@ 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,
# 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.
# Detect a UI-export (frontend/canvas) workflow and lower it to API format
# before validating — exactly as `comfy run` does. Without this the wrapper
# keys (`nodes`, `links`, `groups`, `config`, …) each emit a `non_node_key`
Expand Down Expand Up @@ -1237,6 +1290,19 @@ def validate(

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 converted_from_ui:
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(":", "/")

# Preview credit spend: partner-API (paid) nodes spend Comfy credits when the
# workflow is run. This is the same detection `comfy run` uses (authoritative
# `api_node: true`, `partner/...` category fallback), surfaced here read-only
Expand Down Expand Up @@ -1985,6 +2051,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()
Expand Down
9 changes: 9 additions & 0 deletions comfy_cli/comfy_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,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
Expand All @@ -351,6 +356,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)
Expand Down
113 changes: 113 additions & 0 deletions comfy_cli/command/assets_library.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""``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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
):
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")
Loading
Loading