From 327b1979c20ebe08a300833b4b5ea495d87d10a9 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 07:26:33 +0000 Subject: [PATCH 01/32] feat(run): add --json NDJSON output mode for agents `comfy run --json` switches the CLI into a machine-readable mode that emits one JSON event per line on stdout, terminated with a `completed` or `failed` event. Designed for LLM agents and automation that need a parseable view of a workflow execution. Wire format (see docs/json-output.md for the full v1 contract): - 8 event types: converted, queued, node_cached, node_executing, node_progress, node_executed, completed, failed - 17 stable error.kind values covering every failure path - schema_version: 1 on every event for forward compatibility - ASCII-safe (ensure_ascii=True), per-line flushed - queued carries a node manifest so piped consumers can render UI immediately without parsing the workflow file - node_progress carries class_type+title so stateless consumers don't need to track a prior node_executing - executed_node_ids is the union of nodes observed via node_executing or node_executed (covers intermediate compute nodes that never fire the server's executed WS message) Implementation: - JsonEmitter class owns state (start_time, prompt_id, client_id, workflow, output aggregation) and writes events - Bounded timeouts on every network operation (pre-flight HTTP probe, ws.connect, /prompt POST, ws.recv) so the contract's terminal-event guarantee holds even when the server hangs - Duck-typed output filter: any dict with a `filename` key surfaces as an Output, regardless of the ComfyUI category bucket - execution_interrupted WS handled as `interrupted` error.kind - --verbose is a no-op in JSON mode (would otherwise corrupt the stream) Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 34 +- comfy_cli/command/run.py | 808 +++++++++++-- comfy_cli/env_checker.py | 7 +- docs/json-output.md | 630 ++++++++++ tests/comfy_cli/command/test_run.py | 6 +- tests/comfy_cli/command/test_run_json.py | 1357 ++++++++++++++++++++++ tests/comfy_cli/test_env_checker.py | 6 +- 7 files changed, 2713 insertions(+), 135 deletions(-) create mode 100644 docs/json-output.md create mode 100644 tests/comfy_cli/command/test_run_json.py diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 38910190..1c486bb5 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -445,7 +445,15 @@ def run( ] = None, timeout: Annotated[ int | None, - typer.Option(help="The timeout in seconds for the workflow execution."), + typer.Option( + help=( + "Per-event timeout in seconds: bails out if the server is silent " + "for this long. Also caps HTTP connect, /prompt POST, and websocket " + "handshake. NOT a wall-clock execution deadline — a workflow that " + "streams progress events faster than the timeout can run " + "indefinitely." + ), + ), ] = 30, api_key: Annotated[ str | None, @@ -460,6 +468,18 @@ def run( ), ), ] = None, + json_output: Annotated[ + bool, + typer.Option( + "--json", + help=( + "Emit NDJSON events to stdout instead of human-readable output. " + "One JSON object per line, terminated by \\n. See docs/json-output.md " + "for the event reference and stability contract. In this mode, " + "--verbose has no effect and Rich progress is suppressed." + ), + ), + ] = False, ): if api_key: api_key = api_key.strip() or None @@ -487,7 +507,17 @@ def run( if not port: port = 8188 - run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout, api_key=api_key) + run_inner.execute( + workflow, + host, + port, + wait, + verbose, + local_paths, + timeout, + api_key=api_key, + json_mode=json_output, + ) def validate_comfyui(_env_checker): diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index f8b46857..8ce50860 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -19,6 +19,9 @@ workspace_manager = WorkspaceManager() +# JSON output schema version. Bumped only for breaking changes per docs/json-output.md. +SCHEMA_VERSION = 1 + def is_ui_workflow(workflow) -> bool: return ( @@ -28,40 +31,262 @@ def is_ui_workflow(workflow) -> bool: ) -def _validate_api_workflow(workflow): - """Return the workflow dict if it has the shape of API format, else None.""" - if not isinstance(workflow, dict) or not workflow: - return None - node = workflow[next(iter(workflow))] +def _classify_api_workflow(workflow): + """Classify a parsed JSON object as API workflow / empty / invalid. + + Returns one of: + ("ok", workflow_dict) — well-formed API workflow with ≥1 node + ("empty", None) — empty dict (caller routes to workflow_empty) + ("invalid", None) — not a dict, or first node lacks class_type + """ + if not isinstance(workflow, dict): + return ("invalid", None) + if not workflow: + return ("empty", None) + first_key = next(iter(workflow)) + node = workflow[first_key] if not isinstance(node, dict) or "class_type" not in node: - return None - return workflow + return ("invalid", None) + return ("ok", workflow) + + +class JsonEmitter: + """NDJSON event emitter for ``comfy run --json``. + + Every ``emit_*`` method is a no-op when ``json_mode=False``, so the + same call sites work for both modes. See ``docs/json-output.md``. + """ + + def __init__(self, json_mode: bool): + self.json_mode = json_mode + self.start_time = time.monotonic() + self.client_id: str | None = None + self.prompt_id: str | None = None + self.workflow: dict | None = None + self.cached_node_ids: list[str] = [] + self.executed_node_ids: list[str] = [] + self.outputs: list[dict] = [] + + def set_workflow(self, workflow): + self.workflow = workflow + + def set_client_id(self, client_id): + self.client_id = client_id + + def _elapsed(self) -> float: + return time.monotonic() - self.start_time + + def get_title(self, node_id): + if not isinstance(self.workflow, dict): + return str(node_id) + node = self.workflow.get(node_id) + if not isinstance(node, dict): + return str(node_id) + meta = node.get("_meta") + if isinstance(meta, dict): + title = meta.get("title") + if isinstance(title, str) and title: + return title + class_type = node.get("class_type") + return class_type if isinstance(class_type, str) and class_type else str(node_id) + + def get_class_type(self, node_id): + if not isinstance(self.workflow, dict): + return "" + node = self.workflow.get(node_id) + if not isinstance(node, dict): + return "" + return node.get("class_type", "") + + def _emit(self, event: dict) -> None: + if not self.json_mode: + return + line = json.dumps(event, ensure_ascii=True) + print(line, flush=True) + + def emit_converted(self, node_count: int) -> None: + self._emit( + { + "event": "converted", + "schema_version": SCHEMA_VERSION, + "node_count": node_count, + } + ) + + def workflow_manifest(self) -> list[dict]: + """Build the `nodes` array for the `queued` event — one entry per + node in the submitted (post-conversion) workflow.""" + if not isinstance(self.workflow, dict): + return [] + manifest: list[dict] = [] + for node_id, node in self.workflow.items(): + if not isinstance(node, dict): + continue + class_type = node.get("class_type", "") + class_type = class_type if isinstance(class_type, str) else "" + manifest.append( + { + "node_id": str(node_id), + "class_type": class_type, + "title": self.get_title(node_id), + } + ) + return manifest + + def emit_queued(self, prompt_id: str, validation_warnings) -> None: + self.prompt_id = prompt_id + self._emit( + { + "event": "queued", + "schema_version": SCHEMA_VERSION, + "prompt_id": prompt_id, + "client_id": self.client_id, + "validation_warnings": validation_warnings, + "nodes": self.workflow_manifest(), + } + ) + + def emit_node_cached(self, node_id) -> None: + node_id = str(node_id) + self.cached_node_ids.append(node_id) + self._emit( + { + "event": "node_cached", + "schema_version": SCHEMA_VERSION, + "node_id": node_id, + "class_type": self.get_class_type(node_id), + "title": self.get_title(node_id), + } + ) + + def emit_node_executing(self, node_id) -> None: + node_id = str(node_id) + # `executed_node_ids` aggregates everything the executor touched — + # including intermediate nodes that never fire a server-side `executed` WS event. + if node_id not in self.executed_node_ids: + self.executed_node_ids.append(node_id) + self._emit( + { + "event": "node_executing", + "schema_version": SCHEMA_VERSION, + "node_id": node_id, + "class_type": self.get_class_type(node_id), + "title": self.get_title(node_id), + } + ) + + def emit_node_progress(self, node_id, value, max_val) -> None: + node_id = str(node_id) + self._emit( + { + "event": "node_progress", + "schema_version": SCHEMA_VERSION, + "node_id": node_id, + "class_type": self.get_class_type(node_id), + "title": self.get_title(node_id), + "value": value, + "max": max_val, + } + ) + + def emit_node_executed(self, node_id, outputs: list[dict]) -> None: + node_id = str(node_id) + if node_id not in self.executed_node_ids: + self.executed_node_ids.append(node_id) + self.outputs.extend(outputs) + self._emit( + { + "event": "node_executed", + "schema_version": SCHEMA_VERSION, + "node_id": node_id, + "class_type": self.get_class_type(node_id), + "title": self.get_title(node_id), + "outputs": outputs, + } + ) + + def emit_completed(self) -> None: + self._emit( + { + "event": "completed", + "schema_version": SCHEMA_VERSION, + "prompt_id": self.prompt_id, + "client_id": self.client_id, + "elapsed_seconds": self._elapsed(), + "outputs": self.outputs, + "cached_node_ids": self.cached_node_ids, + "executed_node_ids": self.executed_node_ids, + } + ) + + def emit_failed(self, kind: str, message: str, **extras) -> None: + error = {"kind": kind, "message": message} + error.update(extras) + self._emit( + { + "event": "failed", + "schema_version": SCHEMA_VERSION, + "prompt_id": self.prompt_id, + "client_id": self.client_id, + "elapsed_seconds": self._elapsed(), + "error": error, + } + ) -def fetch_object_info(host: str, port: int, timeout: int) -> dict: +def fetch_object_info(host, port, timeout, emitter=None): """GET ``/object_info`` from the running ComfyUI server. The response describes every loaded node class's input schema and is what the converter uses to map widget values to input names, fill defaults, etc. + + In JSON mode, failures emit a structured ``failed`` event via ``emitter``. + Either way, a ``typer.Exit(code=1)`` is raised. """ url = f"http://{host}:{port}/object_info" + json_mode = bool(emitter and emitter.json_mode) try: with request.urlopen(url, timeout=timeout) as resp: body = resp.read() except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", errors="replace").strip() - pprint(f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body[:500]}[/bold red]") + body_text = e.read().decode("utf-8", errors="replace").strip() + if json_mode: + emitter.emit_failed( + "object_info_unavailable", + f"Failed to fetch /object_info (HTTP {e.code})", + status_code=e.code, + body=body_text[:500], + ) + else: + pprint(f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body_text[:500]}[/bold red]") raise typer.Exit(code=1) from e except urllib.error.URLError as e: - pprint(f"[bold red]Failed to fetch /object_info: {e.reason}[/bold red]") + if json_mode: + emitter.emit_failed("connection_error", f"Failed to fetch /object_info: {e.reason}") + else: + pprint(f"[bold red]Failed to fetch /object_info: {e.reason}[/bold red]") raise typer.Exit(code=1) from e except TimeoutError as e: - pprint(f"[bold red]Failed to fetch /object_info: timed out after {timeout}s[/bold red]") + if json_mode: + emitter.emit_failed( + "connection_error", + f"Failed to fetch /object_info: timed out after {timeout}s", + ) + else: + pprint(f"[bold red]Failed to fetch /object_info: timed out after {timeout}s[/bold red]") raise typer.Exit(code=1) from e try: return json.loads(body) except json.JSONDecodeError as e: - pprint("[bold red]Failed to fetch /object_info: server returned invalid JSON[/bold red]") + if json_mode: + emitter.emit_failed( + "object_info_unavailable", + "Server returned invalid JSON for /object_info", + status_code=200, + body=body.decode("utf-8", errors="replace")[:500], + ) + else: + pprint("[bold red]Failed to fetch /object_info: server returned invalid JSON[/bold red]") raise typer.Exit(code=1) from e @@ -74,70 +299,134 @@ def execute( local_paths=False, timeout=30, api_key: str | None = None, + json_mode: bool = False, ): + emitter = JsonEmitter(json_mode=json_mode) workflow_name = os.path.abspath(os.path.expanduser(workflow)) + if not os.path.isfile(workflow): - pprint( - f"[bold red]Specified workflow file not found: {workflow}[/bold red]", - file=sys.stderr, - ) + if json_mode: + emitter.emit_failed("workflow_not_found", f"Workflow file not found: {workflow}") + else: + pprint( + f"[bold red]Specified workflow file not found: {workflow}[/bold red]", + file=sys.stderr, + ) raise typer.Exit(code=1) if not check_comfy_server_running(port, host): - pprint(f"[bold red]ComfyUI not running on specified address ({host}:{port})[/bold red]") + if json_mode: + emitter.emit_failed( + "connection_error", + f"ComfyUI not running on specified address ({host}:{port})", + ) + else: + pprint(f"[bold red]ComfyUI not running on specified address ({host}:{port})[/bold red]") raise typer.Exit(code=1) try: with open(workflow_name, encoding="utf-8") as f: raw_workflow = json.load(f) - except OSError as e: - pprint(f"[bold red]Unable to read workflow file: {e}[/bold red]") + except (OSError, UnicodeDecodeError) as e: + if json_mode: + emitter.emit_failed("workflow_read_error", f"Unable to read workflow file: {e}") + else: + pprint(f"[bold red]Unable to read workflow file: {e}[/bold red]") raise typer.Exit(code=1) from e except json.JSONDecodeError as e: - pprint(f"[bold red]Specified workflow file is not valid JSON: {e}[/bold red]") + if json_mode: + emitter.emit_failed( + "workflow_invalid_json", + f"Specified workflow file is not valid JSON: {e}", + ) + else: + pprint(f"[bold red]Specified workflow file is not valid JSON: {e}[/bold red]") raise typer.Exit(code=1) from e if is_ui_workflow(raw_workflow): - pprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]") - object_info = fetch_object_info(host, port, timeout) + if not json_mode: + pprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]") + object_info = fetch_object_info(host, port, timeout, emitter=emitter) try: workflow = convert_ui_to_api(raw_workflow, object_info) except WorkflowConversionError as e: - pprint(f"[bold red]Workflow conversion failed: {e}[/bold red]") + if json_mode: + emitter.emit_failed("conversion_error", f"Workflow conversion failed: {e}") + else: + pprint(f"[bold red]Workflow conversion failed: {e}[/bold red]") raise typer.Exit(code=1) from e except Exception as e: - # The converter is experimental; an unexpected crash here is a bug - # in our code, not user error. Show a clean message and a pointer. - pprint( - f"[bold red]Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}[/bold red]\n" - "[yellow]The UI-to-API converter is experimental. Please report this at[/yellow]\n" - "[yellow] https://github.com/Comfy-Org/comfy-cli/issues[/yellow]\n" - "[yellow]and attach the workflow file if possible.[/yellow]" - ) - if verbose: - import traceback - - traceback.print_exc() + if json_mode: + emitter.emit_failed( + "conversion_crash", + f"Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}", + exception_type=type(e).__name__, + ) + else: + pprint( + f"[bold red]Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}[/bold red]\n" + "[yellow]The UI-to-API converter is experimental. Please report this at[/yellow]\n" + "[yellow] https://github.com/Comfy-Org/comfy-cli/issues[/yellow]\n" + "[yellow]and attach the workflow file if possible.[/yellow]" + ) + if verbose: + import traceback as _tb + + _tb.print_exc() raise typer.Exit(code=1) from e if not workflow: - pprint("[bold red]Workflow conversion produced no executable nodes[/bold red]") + if json_mode: + emitter.emit_failed( + "workflow_empty", + "Workflow conversion produced no executable nodes", + ) + else: + pprint("[bold red]Workflow conversion produced no executable nodes[/bold red]") raise typer.Exit(code=1) + emitter.set_workflow(workflow) + if json_mode: + emitter.emit_converted(len(workflow)) else: - workflow = _validate_api_workflow(raw_workflow) - if not workflow: - pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]") + kind, validated = _classify_api_workflow(raw_workflow) + if kind == "empty": + if json_mode: + emitter.emit_failed("workflow_empty", "API workflow contains no nodes") + else: + pprint("[bold red]Specified API workflow has no nodes[/bold red]") raise typer.Exit(code=1) + if kind == "invalid": + if json_mode: + emitter.emit_failed( + "workflow_format_invalid", + "Workflow file does not appear to be an API workflow JSON", + ) + else: + pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]") + raise typer.Exit(code=1) + workflow = validated + emitter.set_workflow(workflow) progress = None start = time.time() - if wait: + if wait and not json_mode: pprint(f"Executing workflow: {workflow_name}") progress = ExecutionProgress() progress.start() - else: + elif not wait and not json_mode: print(f"Queuing workflow: {workflow_name}") - execution = WorkflowExecution(workflow, host, port, verbose, progress, local_paths, timeout, api_key=api_key) + execution = WorkflowExecution( + workflow, + host, + port, + verbose, + progress, + local_paths, + timeout, + api_key=api_key, + emitter=emitter, + ) + emitter.set_client_id(execution.client_id) try: if wait: @@ -146,30 +435,47 @@ def execute( if wait: execution.watch_execution() end = time.time() - progress.stop() - progress = None - - if len(execution.outputs) > 0: - pprint("[bold green]\nOutputs:[/bold green]") + if progress is not None: + progress.stop() + progress = None - for f in execution.outputs: - pprint(f) - - elapsed = timedelta(seconds=end - start) - pprint(f"[bold green]\nWorkflow execution completed ({elapsed})[/bold green]") + if json_mode: + emitter.emit_completed() + else: + if len(execution.outputs) > 0: + pprint("[bold green]\nOutputs:[/bold green]") + for f in execution.outputs: + pprint(f) + elapsed = timedelta(seconds=end - start) + pprint(f"[bold green]\nWorkflow execution completed ({elapsed})[/bold green]") else: - pprint("[bold green]Workflow queued[/bold green]") + # --no-wait: queued was already emitted by execution.queue(). + if not json_mode: + pprint("[bold green]Workflow queued[/bold green]") except WebSocketTimeoutException: - pprint( - f"[bold red]Error: WebSocket timed out after {timeout}s waiting for server response.[/bold red]\n" - "[yellow]For long-running workflows, increase the timeout: comfy run --workflow --timeout 300[/yellow]" - ) + if json_mode: + emitter.emit_failed( + "timeout", + f"WebSocket timed out after {timeout}s waiting for server response", + timeout_seconds=float(timeout), + ) + else: + pprint( + f"[bold red]Error: WebSocket timed out after {timeout}s waiting for server response.[/bold red]\n" + "[yellow]For long-running workflows, increase the timeout: comfy run --workflow --timeout 300[/yellow]" + ) raise typer.Exit(code=1) except (WebSocketException, ConnectionError, OSError) as e: - pprint(f"[bold red]Error: Lost connection to ComfyUI server: {e}[/bold red]") + if json_mode: + emitter.emit_failed( + "connection_lost", + f"Lost connection to ComfyUI server: {e}", + ) + else: + pprint(f"[bold red]Error: Lost connection to ComfyUI server: {e}[/bold red]") raise typer.Exit(code=1) finally: - if progress: + if progress is not None: progress.stop() @@ -191,18 +497,29 @@ def get_renderables(self): class WorkflowExecution: - def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout=30, api_key: str | None = None): + def __init__( + self, + workflow, + host, + port, + verbose, + progress, + local_paths, + timeout=30, + api_key: str | None = None, + emitter: JsonEmitter | None = None, + ): self.workflow = workflow self.host = host self.port = port self.verbose = verbose self.local_paths = local_paths self.client_id = str(uuid.uuid4()) - self.outputs = [] + self.outputs: list = [] self.progress = progress self.remaining_nodes = set(self.workflow.keys()) self.total_nodes = len(self.remaining_nodes) - if progress: + if progress is not None: self.overall_task = self.progress.add_task("", total=self.total_nodes, progress_type="overall") self.current_node = None self.progress_task = None @@ -211,10 +528,18 @@ def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout self.ws = None self.timeout = timeout self.api_key = api_key + # Default to a no-op emitter so internal call sites don't need to + # branch on whether json mode is active. + self.emitter = emitter if emitter is not None else JsonEmitter(json_mode=False) def connect(self): self.ws = WebSocket() - self.ws.connect(f"ws://{self.host}:{self.port}/ws?clientId={self.client_id}") + # Timeout on the handshake too: a server busy loading a model + # can otherwise leave the CLI hung with no terminal event. + self.ws.connect( + f"ws://{self.host}:{self.port}/ws?clientId={self.client_id}", + timeout=self.timeout, + ) def queue(self): data: dict = {"prompt": self.workflow, "client_id": self.client_id} @@ -225,36 +550,162 @@ def queue(self): json.dumps(data).encode("utf-8"), ) try: - resp = request.urlopen(req) - body = json.loads(resp.read()) - - self.prompt_id = body["prompt_id"] + resp = request.urlopen(req, timeout=self.timeout) + raw_body = resp.read() except urllib.error.HTTPError as e: - message = "An unknown error occurred" - if e.status == 500: - # This is normally just the generic internal server error - message = e.read().decode() - elif e.status == 400: - # Bad Request - workflow failed validation on the server - body = json.loads(e.read()) - if body["node_errors"].keys(): - message = json.dumps(body["node_errors"], indent=2) - - self.progress.stop() - - pprint(f"[bold red]Error running workflow\n{message}[/bold red]") + self._handle_submit_http_error(e) + raise typer.Exit(code=1) from e + except urllib.error.URLError as e: + self._stop_progress() + if self.emitter.json_mode: + self.emitter.emit_failed( + "connection_error", + f"Cannot reach server: {e.reason}", + ) + else: + pprint(f"[bold red]Cannot reach server: {e.reason}[/bold red]") + raise typer.Exit(code=1) from e + except TimeoutError as e: + self._stop_progress() + if self.emitter.json_mode: + self.emitter.emit_failed("connection_error", f"Connection timed out: {e}") + else: + pprint(f"[bold red]Connection timed out: {e}[/bold red]") + raise typer.Exit(code=1) from e + except OSError as e: + self._stop_progress() + if self.emitter.json_mode: + self.emitter.emit_failed("connection_error", f"Network error contacting server: {e}") + else: + pprint(f"[bold red]Network error contacting server: {e}[/bold red]") + raise typer.Exit(code=1) from e + + try: + body = json.loads(raw_body) if raw_body else None + except json.JSONDecodeError as e: + self._stop_progress() + body_str = raw_body.decode("utf-8", errors="replace")[:500] + if self.emitter.json_mode: + self.emitter.emit_failed( + "invalid_response", + "Server returned HTTP 200 with unparseable body", + status_code=200, + body=body_str, + ) + else: + pprint(f"[bold red]Server returned HTTP 200 with unparseable body: {body_str[:200]}[/bold red]") + raise typer.Exit(code=1) from e + + prompt_id = body.get("prompt_id") if isinstance(body, dict) else None + if not isinstance(prompt_id, str) or not prompt_id: + self._stop_progress() + body_str = json.dumps(body)[:500] if body is not None else "" + if self.emitter.json_mode: + self.emitter.emit_failed( + "invalid_response", + "Server returned HTTP 200 without a prompt_id", + status_code=200, + body=body_str, + ) + else: + pprint(f"[bold red]Server returned HTTP 200 without a prompt_id: {body_str[:200]}[/bold red]") raise typer.Exit(code=1) + self.prompt_id = prompt_id + + # 200 may still carry node_errors if some output chains failed + # validation but others passed — surface as warnings, not a failure. + validation_warnings = None + node_errors = body.get("node_errors") if isinstance(body, dict) else None + if isinstance(node_errors, dict) and node_errors: + validation_warnings = node_errors + + if self.emitter.json_mode: + self.emitter.emit_queued(prompt_id, validation_warnings) + + def _handle_submit_http_error(self, e: urllib.error.HTTPError) -> None: + raw = b"" + try: + raw = e.read() + except Exception: + pass + try: + body = json.loads(raw) if raw else None + except json.JSONDecodeError: + body = None + body_str = (raw or b"").decode("utf-8", errors="replace") + self._stop_progress() + + code = e.code + if code == 400 and isinstance(body, dict) and isinstance(body.get("node_errors"), dict) and body["node_errors"]: + self._emit_validation_error(body["node_errors"]) + return + if 400 <= code < 500: + kind = "client_error" + elif 500 <= code < 600: + kind = "server_error" + else: + kind = "client_error" + + if self.emitter.json_mode: + self.emitter.emit_failed( + kind, + f"Server returned HTTP {code}", + status_code=code, + body=body_str[:500], + ) + else: + if code == 500: + pprint(f"[bold red]Error running workflow\n{body_str}[/bold red]") + elif code == 400 and isinstance(body, dict): + pprint(f"[bold red]Error running workflow\n{json.dumps(body, indent=2)}[/bold red]") + else: + pprint(f"[bold red]Error running workflow (HTTP {code})\n{body_str[:500]}[/bold red]") + + def _emit_validation_error(self, node_errors: dict) -> None: + if self.emitter.json_mode: + message = "Workflow failed validation" + try: + first_node = next(iter(node_errors.values())) + errs = first_node.get("errors") if isinstance(first_node, dict) else None + if isinstance(errs, list) and errs: + first = errs[0] + if isinstance(first, dict) and isinstance(first.get("message"), str): + message = first["message"] + except StopIteration: + pass + self.emitter.emit_failed( + "validation_error", + message, + node_errors=node_errors, + ) + else: + pprint(f"[bold red]Error running workflow\n{json.dumps(node_errors, indent=2)}[/bold red]") + + def _stop_progress(self) -> None: + if self.progress is not None: + try: + self.progress.stop() + except Exception: + pass + def watch_execution(self): self.ws.settimeout(self.timeout) while True: message = self.ws.recv() - if isinstance(message, str): - message = json.loads(message) - if not self.on_message(message): - break + if not isinstance(message, str): + continue + try: + parsed = json.loads(message) + except json.JSONDecodeError: + # Tolerate malformed frames from misbehaving proxies. + continue + if not self.on_message(parsed): + break def update_overall_progress(self): + if self.progress is None: + return self.progress.update(self.overall_task, completed=self.total_nodes - len(self.remaining_nodes)) def get_node_title(self, node_id): @@ -268,6 +719,9 @@ def get_node_title(self, node_id): def log_node(self, type, node_id): if not self.verbose: return + if self.emitter.json_mode: + # --verbose is a no-op in JSON mode; Rich output would corrupt the stream. + return node = self.workflow.get(node_id) if node is None: @@ -283,87 +737,185 @@ def log_node(self, type, node_id): pprint(f"{type} : {title}") def format_image_path(self, img): + """Build a single (url|local_path) string for the legacy human output.""" filename = img["filename"] - subfolder = img["subfolder"] if "subfolder" in img else None - output_type = img["type"] or "output" + subfolder = img.get("subfolder") or "" + output_type = img.get("type") or "output" if self.local_paths: - if subfolder: - filename = os.path.join(subfolder, filename) + display_name = os.path.join(subfolder, filename) if subfolder else filename + return os.path.join(workspace_manager.get_workspace_path()[0], output_type, display_name) + + url_params = {"filename": filename, "subfolder": subfolder, "type": output_type} + return f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" + + def _build_output_object(self, node_id, category, item) -> dict: + """Construct a structured Output dict for the JSON contract.""" + filename = item["filename"] + subfolder = item.get("subfolder") or "" + file_type = item.get("type") or "output" - return os.path.join(workspace_manager.get_workspace_path()[0], output_type, filename) + url_params = {"filename": filename, "subfolder": subfolder, "type": file_type} + url = f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" - query = urllib.parse.urlencode(img) - return f"http://{self.host}:{self.port}/view?{query}" + local_path = None + if self.local_paths: + try: + ws_path = workspace_manager.get_workspace_path()[0] + except Exception: + ws_path = None + if ws_path: + parts = [] + if subfolder: + parts.append(subfolder) + parts.append(filename) + local_path = os.path.join(ws_path, file_type, *parts) + + return { + "category": category, + "node_id": node_id, + "class_type": self.emitter.get_class_type(node_id), + "title": self.emitter.get_title(node_id), + "filename": filename, + "subfolder": subfolder, + "type": file_type, + "url": url, + "local_path": local_path, + } def on_message(self, message): data = message["data"] if "data" in message else {} - # Skip any messages that aren't about our prompt if "prompt_id" not in data or data["prompt_id"] != self.prompt_id: return True - if message["type"] == "executing": + msg_type = message.get("type") + if msg_type == "executing": return self.on_executing(data) - elif message["type"] == "execution_cached": + elif msg_type == "execution_cached": self.on_cached(data) - elif message["type"] == "progress": + elif msg_type == "progress": self.on_progress(data) - elif message["type"] == "executed": + elif msg_type == "executed": self.on_executed(data) - elif message["type"] == "execution_error": + elif msg_type == "execution_error": self.on_error(data) + elif msg_type == "execution_interrupted": + self.on_interrupted(data) return True def on_executing(self, data): - if self.progress_task: + if self.progress_task is not None and self.progress is not None: self.progress.remove_task(self.progress_task) self.progress_task = None if data["node"] is None: return False - else: - if self.current_node: - self.remaining_nodes.discard(self.current_node) - self.update_overall_progress() - self.current_node = data["node"] - self.log_node("Executing", data["node"]) + + node_id = data["node"] + if self.current_node: + self.remaining_nodes.discard(self.current_node) + self.update_overall_progress() + self.current_node = node_id + self.log_node("Executing", node_id) + if self.emitter.json_mode: + self.emitter.emit_node_executing(node_id) return True def on_cached(self, data): - nodes = data["nodes"] + nodes = data.get("nodes") or [] for n in nodes: + if n is None: + continue self.remaining_nodes.discard(n) self.log_node("Cached", n) + if self.emitter.json_mode: + self.emitter.emit_node_cached(n) self.update_overall_progress() def on_progress(self, data): - node = data["node"] - if self.progress_node != node: - self.progress_node = node - if self.progress_task: - self.progress.remove_task(self.progress_task) - - self.progress_task = self.progress.add_task( - self.get_node_title(node), total=data["max"], progress_type="node" - ) - self.progress.update(self.progress_task, completed=data["value"]) - - def on_executed(self, data): - self.remaining_nodes.discard(data["node"]) - self.update_overall_progress() - - if "output" not in data: + node = data.get("node") + if node is None: return + value = data.get("value", 0) + max_val = data.get("max", 0) + if self.progress is not None: + if self.progress_node != node: + self.progress_node = node + if self.progress_task is not None: + self.progress.remove_task(self.progress_task) + self.progress_task = self.progress.add_task( + self.get_node_title(node), total=max_val, progress_type="node" + ) + self.progress.update(self.progress_task, completed=value) + if self.emitter.json_mode: + self.emitter.emit_node_progress(node, value, max_val) - output = data["output"] - - if output is None or "images" not in output: + def on_executed(self, data): + node_id = data.get("node") + if node_id is None: return + self.remaining_nodes.discard(node_id) + self.update_overall_progress() - for img in output["images"]: - self.outputs.append(self.format_image_path(img)) + # node_executed fires whenever the server emits `executed`, even + # when there are no file-shaped outputs (outputs=[] in that case). + structured_outputs: list[dict] = [] + output = data.get("output") + if isinstance(output, dict): + for category, items in output.items(): + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict) or "filename" not in item: + continue + obj = self._build_output_object(node_id, category, item) + structured_outputs.append(obj) + if not self.emitter.json_mode: + # Legacy string list, only consumed by the Rich path. + self.outputs.append(self.format_image_path(item)) + + if self.emitter.json_mode: + self.emitter.emit_node_executed(node_id, structured_outputs) def on_error(self, data): - pprint(f"[bold red]Error running workflow\n{json.dumps(data, indent=2)}[/bold red]") + node_id = data.get("node_id", "") + class_type = data.get("node_type") or data.get("class_type") or "" + exception_type = data.get("exception_type", "") + raw_tb = data.get("traceback", "") + if isinstance(raw_tb, list): + traceback_str = "".join(str(x) for x in raw_tb) + elif isinstance(raw_tb, str): + traceback_str = raw_tb + else: + traceback_str = "" + message = data.get("exception_message") or "Workflow execution failed" + + self._stop_progress() + if self.emitter.json_mode: + title = self.emitter.get_title(node_id) if node_id else "" + if not class_type and node_id: + class_type = self.emitter.get_class_type(node_id) + self.emitter.emit_failed( + "execution_error", + message, + node_id=node_id, + class_type=class_type, + title=title, + exception_type=exception_type, + traceback=traceback_str, + ) + else: + pprint(f"[bold red]Error running workflow\n{json.dumps(data, indent=2)}[/bold red]") + raise typer.Exit(code=1) + + def on_interrupted(self, data): + self._stop_progress() + if self.emitter.json_mode: + self.emitter.emit_failed( + "interrupted", + "Workflow execution was interrupted", + ) + else: + pprint("[yellow]Workflow execution was interrupted[/yellow]") raise typer.Exit(code=1) diff --git a/comfy_cli/env_checker.py b/comfy_cli/env_checker.py index d5978b04..8fa51350 100644 --- a/comfy_cli/env_checker.py +++ b/comfy_cli/env_checker.py @@ -32,15 +32,18 @@ def format_python_version(version_info): return f"[bold red]{version_info.major}.{version_info.minor}.{version_info.micro}[/bold red]" -def check_comfy_server_running(port=8188, host="localhost"): +def check_comfy_server_running(port=8188, host="localhost", timeout: float = 5.0): """ Checks if the Comfy server is running by making a GET request to the /history endpoint. + `timeout` bounds the probe so a TCP-reachable but unresponsive server + (e.g., stuck in a CUDA kernel) doesn't hang the caller. + Returns: bool: True if the Comfy server is running, False otherwise. """ try: - response = requests.get(f"http://{host}:{port}/history") + response = requests.get(f"http://{host}:{port}/history", timeout=timeout) return response.status_code == 200 except requests.exceptions.RequestException: return False diff --git a/docs/json-output.md b/docs/json-output.md new file mode 100644 index 00000000..6569f30c --- /dev/null +++ b/docs/json-output.md @@ -0,0 +1,630 @@ +# `comfy run --json`: Machine-Readable Output (NDJSON) + +This document specifies the output contract of `comfy run --json`. The intent +is to give agents and automation a stable, parseable view of a workflow +execution — independent of the human-readable Rich-formatted output that +`comfy run` emits by default. + +## Overview + +When `--json` is passed, `comfy run` switches into a strict +machine-readable mode: + +- **stdout** carries exclusively **NDJSON** (newline-delimited JSON): one + JSON object per line, each terminated by `\n`. No ANSI, no progress bar, + no headings. Each line is written and flushed to stdout as soon as the + underlying event is produced; agents may rely on read-as-emitted timing — + there is no batching. +- The stream is 7-bit ASCII clean. Non-ASCII characters in string fields + are emitted as `\uXXXX` JSON escapes (equivalent to Python's + `json.dumps(..., ensure_ascii=True)`). +- **stderr** is reserved for things the CLI cannot route through the JSON + contract: framework-level Python errors, uncaught exceptions, library + warnings. Agents should not parse stderr; they may discard it or + capture it for diagnostics. +- **Exit code** is `0` on a `completed` terminal event and `1` on a + `failed` terminal event. Error categorisation is carried in the + `error.kind` field of the `failed` event, not in the exit code (see + [Stability](#stability-and-exit-codes)). + +In `--json` mode, `--verbose` has no effect: agents receive the full event +stream regardless. + +All duration fields in this contract are floats representing seconds. +Numeric count fields (e.g., `node_progress.value` / `max`) are JSON +`number` and may be int or float depending on the underlying node. + +This contract targets ComfyUI servers reached via `--host` / `--port` +(typically local). Cloud-specific URL routing (e.g., `/api` prefix, +endpoint renames like `/history` → `/history_v2`) is not exercised in +v1 and may not work without additional flag wiring — agents talking to +Comfy Cloud should expect to use their own client code for now. + +## Stream shape + +Every line on stdout is a JSON object containing a non-empty string +`event` field acting as a discriminator. Agents must dispatch on this +field. The stream ends with a terminal event (`completed` or `failed`) +in wait mode. In `--no-wait` mode the stream ends at `queued`, and the +agent is responsible for polling `/history/{prompt_id}` to observe +completion. See [Process-level termination](#process-level-termination) +for the edge case where the CLI is killed before emitting its terminal +event. + +### Stream archetypes + +The stream takes one of these shapes depending on the workflow format and +outcome: + +| Outcome | Stream | +| --------------------------------- | --------------------------------------------------------- | +| Success | `[converted]? + queued + [node_*]* + completed` | +| `--no-wait` queued | `[converted]? + queued` | +| Failure mid-execution | `[converted]? + queued + [node_*]* + failed` | +| Failure during submission | `[converted]? + failed` | +| Failure pre-flight | `failed` | + +Where `[node_*]*` is zero or more interleaved `node_cached`, +`node_executing`, `node_progress`, and `node_executed` events. `[X]?` +means X may or may not appear. + +### Early-exit rule + +`failed` can replace any non-terminal event, terminating the stream +early. The archetypes table above is the canonical view of what shapes +of stream agents will actually see; the early-exit rule is the universal +quantifier behind it. + +### Schema version + +Every event in v1 streams carries `schema_version: 1`. The field is a +monotonically increasing integer; minor versions are not used. Future +schema versions will emit `schema_version: 2`, etc., on every event. +Agents may read the version from any line. Agents needing +feature-presence detection beyond the integer version should +feature-detect by field presence rather than version comparison. + +## Event reference + +| Event | When | Terminal | +| ----------------- | ------------------------------------------------- | -------- | +| `converted` | UI-format workflow was client-side converted | | +| `queued` | Server accepted the prompt (HTTP 200 on `/prompt`)| | +| `node_cached` | Node hit the execution cache and was skipped | | +| `node_executing` | Node started execution | | +| `node_progress` | In-flight progress update for the running node | | +| `node_executed` | Node finished and reported its outputs | | +| `completed` | Workflow finished successfully | ✓ | +| `failed` | Workflow could not complete | ✓ | + +Agents must ignore events whose `event` value they do not recognise — +new event kinds may be added in a backward-compatible manner. Agents +must ignore unknown fields on known events for the same reason. + +### `converted` + +Emitted once if the input workflow was in UI format and was converted to +API format client-side. Not emitted when the input was already in API +format. + +```json +{"event": "converted", "schema_version": 1, "node_count": 2} +``` + +| Field | Type | Description | +| ---------------- | ---- | ---------------------------------------------- | +| `event` | str | `"converted"` | +| `schema_version` | int | `1` | +| `node_count` | int | Number of nodes in the converted graph | + +### `queued` + +Emitted after `POST /prompt` returns 200. Carries the server's prompt +handle, which can be used to correlate against `/history/{prompt_id}`. + +```json +{ + "event": "queued", + "schema_version": 1, + "prompt_id": "9b1c…", + "client_id": "fe2a…", + "validation_warnings": null, + "nodes": [ + {"node_id": "1", "class_type": "GeminiNanoBanana2", "title": "Nano Banana 2"}, + {"node_id": "2", "class_type": "SaveImage", "title": "Save Image"} + ] +} +``` + +| Field | Type | Description | +| --------------------- | -------------- | ------------------------------------------------------------ | +| `event` | str | `"queued"` | +| `schema_version` | int | `1` | +| `prompt_id` | str | Server-assigned prompt UUID | +| `client_id` | str | Client-generated UUID (sent with `/prompt`) | +| `validation_warnings` | dict \| null | Non-empty when ComfyUI accepted the prompt despite partial validation failures; same shape as `validation_error.node_errors` (see below). `null` in the common case. | +| `nodes` | array of dict | Manifest of every node in the submitted (post-conversion) workflow. Each entry has `node_id` (str), `class_type` (str), and `title` (str — `_meta.title` if present, else `class_type`). Lets piped consumers (who don't have the workflow file at hand) render a per-node UI immediately without waiting for `completed`. | + +The `validation_warnings` field exists specifically for the case where +ComfyUI's `validate_prompt` returns success because at least one output +chain validated, but other nodes failed validation and will not run. +This field is not a general warnings channel; other warning surfaces +would require separate spec decisions. + +### `node_cached` + +Emitted up front as a group, before any `node_executing` in the same +run, listing nodes whose outputs were retrieved from the execution +cache. Comes from ComfyUI's `execution_cached` websocket message. + +Note: for cached nodes that had prior UI output (e.g., a cached +`SaveImage`), ComfyUI emits both `execution_cached` AND a per-node +`executed` payload with the cached UI dict. The CLI surfaces both: a +single node may produce both `node_cached` and `node_executed` events. +See [completed](#completed) for the resulting semantics of +`cached_node_ids` / `executed_node_ids`. + +```json +{"event": "node_cached", "schema_version": 1, "node_id": "1", "class_type": "GeminiNanoBanana2", "title": "Nano Banana 2"} +``` + +| Field | Type | Description | +| ---------------- | ---- | ------------------------------------------- | +| `event` | str | `"node_cached"` | +| `schema_version` | int | `1` | +| `node_id` | str | Node key in the workflow dict | +| `class_type` | str | Node class name | +| `title` | str | `_meta.title` if present, else `class_type` | + +### `node_executing` + +Emitted when a node starts execution. Subsequent `node_progress` events +(if any) refer to this node until either a different `node_executing` +arrives or the node's `node_executed` event is emitted. + +Two consecutive `node_executing` events with different `node_id` values +are normal: the first node finished but didn't surface a result the +server forwarded as `executed` (this happens for intermediate compute +nodes whose outputs aren't published to the client). Agents that track +a "current node" should treat a new `node_executing` as implicitly +closing the previous one. + +```json +{"event": "node_executing", "schema_version": 1, "node_id": "2", "class_type": "SaveImage", "title": "Save Image"} +``` + +Fields are identical to `node_cached`. + +### `node_progress` + +Per-step progress for samplers, video encoders, and any node that calls +`ProgressBar.update_absolute(...)`. Shares the `node_id` of the most +recently-emitted `node_executing` event. + +```json +{"event": "node_progress", "schema_version": 1, "node_id": "1", "class_type": "GeminiNanoBanana2", "title": "Nano Banana 2", "value": 14, "max": 30} +``` + +| Field | Type | Description | +| ---------------- | ------ | ----------------------------------------------------------- | +| `event` | str | `"node_progress"` | +| `schema_version` | int | `1` | +| `node_id` | str | Node currently running | +| `class_type` | str | Node class name (duplicated from the workflow so stateless consumers don't need to track the prior `node_executing`) | +| `title` | str | `_meta.title` if present, else `class_type` | +| `value` | number | Current progress. Typically int (step count); some custom nodes emit float (fractional progress) | +| `max` | number | Total progress; same caveat as `value` | + +Some custom nodes may emit `value > max` near the end of execution. +Agents rendering a progress bar should clamp `value` to `max`. + +### `node_executed` + +Emitted when the server reports node completion via its `executed` +websocket message. **Not guaranteed for every executed node** — +intermediate compute nodes that don't surface output to the client may +finish without emitting this event. Output nodes (like `SaveImage`) and +some custom partner nodes that publish previews reliably emit it. A +cached output-bearing node also emits `node_executed` (in addition to +`node_cached`). + +```json +{ + "event": "node_executed", + "schema_version": 1, + "node_id": "2", + "class_type": "SaveImage", + "title": "Save Image", + "outputs": [ + { + "category": "images", + "node_id": "2", + "class_type": "SaveImage", + "title": "Save Image", + "filename": "banana_test_00001_.png", + "subfolder": "", + "type": "output", + "url": "http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output", + "local_path": "/home/user/comfy/ComfyUI/output/banana_test_00001_.png" + } + ] +} +``` + +| Field | Type | Description | +| ---------------- | ---------------- | ------------------------------------------- | +| `event` | str | `"node_executed"` | +| `schema_version` | int | `1` | +| `node_id` | str | Node key | +| `class_type` | str | Node class name | +| `title` | str | `_meta.title` if present, else `class_type` | +| `outputs` | array of Output | File-like outputs (empty if none) | + +`outputs` is populated by iterating each key in ComfyUI's +`executed.output` dict and emitting any item that matches the +file-record shape (a dict containing a `filename` key). Items that are +not file-record-shaped (strings, booleans, mixed lists from nodes that +publish non-file data like text predictions or animation flags) are +silently skipped. See [Output object](#output-object) for the entry +shape. + +### `completed` + +Terminal event on success. Carries identifiers, timing, the aggregated +output list, and node-execution metadata. + +```json +{ + "event": "completed", + "schema_version": 1, + "prompt_id": "9b1c…", + "client_id": "fe2a…", + "elapsed_seconds": 8.342, + "outputs": [], + "cached_node_ids": ["1"], + "executed_node_ids": ["2"] +} +``` + +| Field | Type | Description | +| ------------------- | --------------- | ------------------------------------------------------------ | +| `event` | str | `"completed"` | +| `schema_version` | int | `1` | +| `prompt_id` | str | Server-assigned prompt UUID | +| `client_id` | str | Client-generated UUID | +| `elapsed_seconds` | float | Wall-clock duration from prompt POST | +| `outputs` | array of Output | All file-like outputs across all nodes (empty if none) | +| `cached_node_ids` | array of str | Node IDs the server reported as cached (via `execution_cached`) | +| `executed_node_ids` | array of str | Node IDs the server executor touched in this run — the union of every `node_id` that appeared in a `node_executing` or `node_executed` event. Includes intermediate compute nodes (CheckpointLoaderSimple, KSampler, etc.) that don't surface output to the client and don't get a dedicated `node_executed` event, in addition to leaf/output nodes that do. | + +`cached_node_ids` and `executed_node_ids` are independent signals about +what the server reported. **They may overlap**: a cached output-bearing +node emits both `execution_cached` and `executed`, so it appears in +both lists. Agents wanting "ran fresh, not from cache" should compute +`set(executed_node_ids) - set(cached_node_ids)`. + +Note: `executed_node_ids` is named for what the executor *did* (run a +node), not for which event was emitted. The leaf-only `node_executed` +event remains the per-node signal for "this node produced a visible +artifact"; the aggregate field here is the broader "this node ran". + +### `failed` + +Terminal event on any failure. The `error.kind` discriminator is the +documented stable enum (see [Error object](#error-object) and +[Error kinds](#error-kinds)). + +```json +{ + "event": "failed", + "schema_version": 1, + "prompt_id": "9b1c…", + "client_id": "fe2a…", + "elapsed_seconds": 1.23, + "error": { + "kind": "execution_error", + "message": "API key invalid", + "node_id": "1", + "class_type": "GeminiNanoBanana2", + "title": "Nano Banana 2", + "exception_type": "RuntimeError", + "traceback": " File \"/path/to/node.py\", line 42, in execute\n raise RuntimeError(\"API key invalid\")\n" + } +} +``` + +| Field | Type | Description | +| ----------------- | ----------- | -------------------------------------------------------------------------- | +| `event` | str | `"failed"` | +| `schema_version` | int | `1` | +| `prompt_id` | str \| null | `null` when failure occurred before `/prompt` was accepted | +| `client_id` | str \| null | `null` when failure occurred before `WorkflowExecution` was constructed | +| `elapsed_seconds` | float | Wall-clock duration from start of `comfy run` | +| `error` | Error | See [Error object](#error-object) | + +If `prompt_id` is non-null, `client_id` is also non-null (a `prompt_id` +cannot be assigned without a `client_id`). + +## Output object + +```json +{ + "category": "images", + "node_id": "2", + "class_type": "SaveImage", + "title": "Save Image", + "filename": "banana_test_00001_.png", + "subfolder": "", + "type": "output", + "url": "http://127.0.0.1:8188/view?filename=...", + "local_path": "/home/user/comfy/ComfyUI/output/banana_test_00001_.png" +} +``` + +| Field | Type | Description | +| ------------ | ----------- | -------------------------------------------------------------------------------------------------------- | +| `category` | str | Output category as keyed by ComfyUI's `executed.output` dict. **Open set.** Current ComfyUI versions emit values like `images`, `audio`, `3d`, `latents`; agents must accept and pass through unknown values. | +| `node_id` | str | Node that produced the output | +| `class_type` | str | Node class name | +| `title` | str | `_meta.title` if present, else `class_type` | +| `filename` | str | Raw filename as reported by the server | +| `subfolder` | str | Subfolder within the output folder's root (`""` if none) | +| `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. | +| `url` | str | `http(s)://:/view?...` URL — always present | +| `local_path` | str \| null | Filesystem path on the same machine, or `null` when the CLI can't verify local access (see below) | + +### Local paths + +`local_path` is non-null only when **all** of: +- `comfy launch --background` recorded a workspace path the CLI knows about, and +- the user did not override `--host` or `--port`. + +If the user passes `--host remote.example.com` or talks to a port the CLI +didn't launch, `local_path` is `null` — the file likely exists, but not at a +path the CLI can name with confidence. The `url` field remains valid and +should be used to fetch the bytes. + +This is intentional: a non-null `local_path` is a stronger promise than +"the file is somewhere accessible." Agents that need the bytes can +unconditionally fetch `url`; agents that prefer direct filesystem access +can branch on `local_path is not null`. + +## Error object + +Every `failed` event carries an `error` object with these universal +fields, plus per-kind extras documented in [Error kinds](#error-kinds). + +| Field | Type | Description | +| --------- | ---- | -------------------------------------------------------------------------------------------- | +| `kind` | str | Discriminator; one of the values in [Error kinds](#error-kinds) | +| `message` | str | Human-readable summary. For display only — agents should dispatch on `kind`, not on `message`| + +The set of per-kind extra fields is the documented minimum. New +optional extra fields may be added in non-breaking releases; existing +fields will not be removed or renamed without a `schema_version` bump. + +## Error kinds + +Per-kind extra fields. Universal fields (`kind`, `message`) are +documented in [Error object](#error-object). + +Each `error.kind` is a stable string. New kinds may be added in +backward-compatible releases; existing kinds will not be renamed or +removed without a schema version bump. + +| `kind` | Triggered when | Extra fields | +| ------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `workflow_not_found` | `--workflow` path does not exist | — | +| `workflow_invalid_json` | Workflow file is not valid JSON | — | +| `workflow_read_error` | Workflow file exists but isn't readable as text (`OSError`, `UnicodeDecodeError`) | — | +| `workflow_format_invalid` | File parses but is neither UI nor API format | — | +| `workflow_empty` | Workflow has no executable nodes (UI conversion produced `{}`, or API workflow is `{}`) | — | +| `conversion_error` | UI→API converter raised `WorkflowConversionError` | — | +| `conversion_crash` | UI→API converter raised an unexpected exception | `exception_type` (str) | +| `object_info_unavailable` | `/object_info` returned an HTTP error (needed for UI→API conversion) | `status_code` (int), `body` (str) | +| `connection_error` | ComfyUI server not reachable (URLError / pre-queue socket.timeout, including on `/object_info`)| — | +| `validation_error` | Server returned HTTP 400 with `node_errors` | `node_errors` (dict; passthrough; see below) | +| `client_error` | Server returned an HTTP 4xx response (not validation) | `status_code` (int, 4xx), `body` (str) | +| `server_error` | Server returned an HTTP 5xx response | `status_code` (int, 5xx), `body` (str) | +| `invalid_response` | Server returned HTTP 2xx but body was unparseable or lacked `prompt_id` | `status_code` (int, 2xx), `body` (str) | +| `timeout` | WebSocket `recv` timed out | `timeout_seconds` (float) | +| `connection_lost` | WebSocket connection dropped mid-execution | — | +| `interrupted` | Server signaled the workflow was interrupted (`execution_interrupted` WS, e.g., via `/interrupt`) | — | +| `execution_error` | A node raised during execution (server emitted `execution_error`) | `node_id` (str), `class_type` (str), `title` (str), `exception_type` (str), `traceback` (str) | + +### `exception_type` field + +`exception_type` is provided for diagnostic and observability purposes +(e.g., metrics bucketing). The format is whatever ComfyUI sends — +typically the bare class name for builtins (`RuntimeError`, +`ValueError`) and a dotted module path for non-builtins +(`comfy.model_management.InterruptProcessingException`). Agents should +not key retry or routing logic on `exception_type`; use `error.kind` +for coarse dispatch and `error.message` for human display. + +### `traceback` field + +`traceback` is a single multi-line string carrying the formatted stack +frames as reported by ComfyUI (joined from the server's +`traceback.format_tb()` output). It does NOT include the +`"Traceback (most recent call last):"` header or the final +`"ExceptionType: message"` summary line — agents reconstructing a +Python-style display can do so themselves from `exception_type`, +`error.message`, and `traceback`. May be empty (`""`) when the server's +formatted stack is empty. + +After `json.loads()`, the `traceback` string contains real newline +characters (the JSON wire-format `\n` escapes are decoded). + +### `validation_error.node_errors` shape + +The same shape is used for `queued.validation_warnings`. The value is +the raw dict from ComfyUI keyed by node ID. Keys are the same string +node IDs that appear as `node_id` in `node_*` events. Example shape: + +```json +"node_errors": { + "1": { + "errors": [ + { + "type": "value_not_in_list", + "message": "Value not in list", + "details": "resolution: '5K' not in ['1K','2K','4K']", + "extra_info": { + "input_name": "resolution", + "received_value": "5K" + } + } + ], + "dependent_outputs": ["2"], + "class_type": "GeminiNanoBanana2" + } +} +``` + +The inner structure is defined by ComfyUI's `validate_prompt()` in +[`server.py`](https://github.com/comfyanonymous/ComfyUI/blob/master/server.py) +and may evolve with ComfyUI versions. **Agents should ignore unknown +fields.** The CLI guarantees only: +- the outer value is a dict keyed by node ID, and +- under current ComfyUI versions, each node entry carries `errors`, + `dependent_outputs`, and `class_type`. + +## Process-level termination + +The CLI may be terminated by the operating system or a parent process +(SIGKILL, SIGTERM, SIGINT, OOM-kill, segmentation fault). In these +cases, no terminal event is emitted and the stream may be truncated. + +Agents should treat the run as failed when **both**: +- the process exit code is non-zero, and +- the last line on stdout is not a `completed` or `failed` event (or + stdout is empty). + +Stderr may contain a Python traceback in these cases. + +## Examples + +Class type names in these examples (`SaveImage`, `GeminiNanoBanana2`, +etc.) are illustrative — they reflect specific ComfyUI/partner nodes. +Agents should not hardcode behavior on specific `class_type` strings; +the contract guarantees the *shape* of these fields, not their content. + +Every line, including the terminal `completed`/`failed`, ends with +`\n`. Agents using line iteration (`for line in stdout`) are fine; +agents using `splitlines()` or `split("\n")` should filter empty +trailing entries. + +### Successful run (UI-format input) + +``` +{"event":"converted","schema_version":1,"node_count":2} +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} +{"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":1,"max":4} +{"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":4,"max":4} +{"event":"node_executing","schema_version":1,"node_id":"2","class_type":"SaveImage","title":"Save Image"} +{"event":"node_executed","schema_version":1,"node_id":"2","class_type":"SaveImage","title":"Save Image","outputs":[{"category":"images","node_id":"2","class_type":"SaveImage","title":"Save Image","filename":"banana_test_00001_.png","subfolder":"","type":"output","url":"http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output","local_path":"/home/user/comfy/ComfyUI/output/banana_test_00001_.png"}]} +{"event":"completed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":8.342,"outputs":[{"category":"images","node_id":"2","class_type":"SaveImage","title":"Save Image","filename":"banana_test_00001_.png","subfolder":"","type":"output","url":"http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output","local_path":"/home/user/comfy/ComfyUI/output/banana_test_00001_.png"}],"cached_node_ids":[],"executed_node_ids":["1","2"]} +``` + +Exit code: `0`. + +Note: node 1 (`GeminiNanoBanana2`) does not emit a `node_executed` +event in this example — it's an intermediate compute node whose result +is forwarded via tensors rather than surfaced as a file output, so the +server doesn't send an `executed` ws message for it. + +### `--no-wait` (API-format input) + +``` +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +``` + +Exit code: `0`. The agent is responsible for polling +`/history/{prompt_id}` to observe completion. + +### Failure: workflow file missing + +``` +{"event":"failed","schema_version":1,"prompt_id":null,"client_id":null,"elapsed_seconds":0.001,"error":{"kind":"workflow_not_found","message":"Workflow file not found: /tmp/missing.json"}} +``` + +Exit code: `1`. + +### Failure: server returned validation errors + +``` +{"event":"converted","schema_version":1,"node_count":2} +{"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Workflow failed validation","node_errors":{"1":{"errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}}}} +``` + +Exit code: `1`. + +### Failure: node raised during execution + +``` +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} +{"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":2.1,"error":{"kind":"execution_error","message":"API key invalid","node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","exception_type":"RuntimeError","traceback":" File \"/path/to/node.py\", line 42, in execute\n raise RuntimeError(\"API key invalid\")\n"}} +``` + +Exit code: `1`. + +### Failure: websocket timeout + +``` +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} +{"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":30.0,"error":{"kind":"timeout","message":"WebSocket timed out after 30s waiting for server response","timeout_seconds":30.0}} +``` + +Exit code: `1`. + +### Failure: workflow interrupted + +``` +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} +{"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":3.2,"error":{"kind":"interrupted","message":"Workflow execution was interrupted"}} +``` + +Exit code: `1`. + +## Stability and exit codes + +### What is stable + +For the v1 contract documented here: +- The set of event names listed above and the field names within them. +- The set of `error.kind` values listed above and the per-kind extra + fields documented for each. +- The exit code mapping (`0` on `completed`, `1` on `failed`). +- The stdout/stderr separation (NDJSON on stdout, no progress on stderr). +- The `schema_version: 1` field on every event of v1 streams. + +### What may change in a non-breaking way + +- New event types being added (agents must ignore unknown `event` values). +- New `error.kind` values being added (agents must default-handle unknown + kinds). +- New optional fields being added to existing events (agents must ignore + unknown fields). + +New events that would alter the meaning of existing events when +ignored (for example, a per-node skip event whose absence would make +`executed_node_ids` incomplete) require a `schema_version` bump rather +than being treated as an additive change. + +### Why exit codes are not granular + +In `--json` mode, exit code is always `0` (completed) or `1` (failed). +This is part of the v1 JSON contract and will not change without a +`schema_version` bump. Non-`--json` exit codes are documented elsewhere +and not governed by this contract. + +The `error.kind` string namespace is more expressive than granular exit +codes — no number bikeshedding, easy to add sub-categories — and it is +co-located with the rich error context. Granular exit codes can be +introduced later for non-`--json` callers in a separate, +evidence-driven change without breaking the JSON contract. diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index 3b07db97..226100cf 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -507,7 +507,8 @@ def test_ui_workflow_is_converted_then_executed(self, ui_workflow_file): execute(ui_workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30) - mock_fetch.assert_called_once_with("127.0.0.1", 8188, 30) + mock_fetch.assert_called_once() + assert mock_fetch.call_args.args == ("127.0.0.1", 8188, 30) api_workflow = MockExec.call_args.args[0] assert set(api_workflow) == {"1", "2"} assert api_workflow["1"]["class_type"] == "EmptyLatentImage" @@ -555,7 +556,8 @@ def test_ui_workflow_plumbs_api_key_through_to_execution(self, ui_workflow_file) execute(ui_workflow_file, host="127.0.0.1", port=8188, wait=True, timeout=30, api_key="sk-test") - mock_fetch.assert_called_once_with("127.0.0.1", 8188, 30) + mock_fetch.assert_called_once() + assert mock_fetch.call_args.args == ("127.0.0.1", 8188, 30) assert MockExec.call_args.kwargs["api_key"] == "sk-test" def test_ui_workflow_exits_when_conversion_yields_nothing(self): diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py new file mode 100644 index 00000000..cf556267 --- /dev/null +++ b/tests/comfy_cli/command/test_run_json.py @@ -0,0 +1,1357 @@ +"""Unit tests for `comfy run --json` (NDJSON output mode). + +See `docs/json-output.md` for the contract these tests pin in place. +The tests cover: + - every event type emitted at the right time and shape + - every error.kind for each documented failure path + - schema_version: 1 on every line + - stream archetypes from the spec table + - the duck-typed output filter rule + - the cached/executed overlap semantics + - per-line stdout flushing (so live consumers see events as they happen) +""" + +from __future__ import annotations + +import io +import json +import os +import tempfile +import urllib.error +from unittest.mock import MagicMock, patch + +import pytest +import typer +from websocket import WebSocketException, WebSocketTimeoutException + +from comfy_cli.command.run import ( + JsonEmitter, + WorkflowExecution, + _classify_api_workflow, + execute, +) + + +@pytest.fixture +def simple_workflow(): + return { + "1": { + "class_type": "EmptyLatentImage", + "inputs": {"width": 64, "height": 64, "batch_size": 1}, + "_meta": {"title": "Latent"}, + }, + "2": { + "class_type": "SaveImage", + "inputs": {"filename_prefix": "x", "images": ["1", 0]}, + "_meta": {"title": "Save"}, + }, + } + + +@pytest.fixture +def workflow_file(simple_workflow): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(simple_workflow, f) + f.flush() + path = f.name + yield path + os.unlink(path) + + +def _run_execute_capture(workflow_path, capsys, **overrides): + """Run execute() and return the parsed JSON events from stdout.""" + kwargs = dict( + host="127.0.0.1", + port=8188, + wait=True, + verbose=False, + local_paths=False, + timeout=30, + json_mode=True, + ) + kwargs.update(overrides) + try: + execute(workflow_path, **kwargs) + except typer.Exit: + pass + out, _err = capsys.readouterr() + events = [] + for line in out.splitlines(): + line = line.strip() + if not line: + continue + events.append(json.loads(line)) + return events + + +def _make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="http://127.0.0.1:8188/prompt", + code=code, + msg=f"HTTP {code}", + hdrs=None, + fp=io.BytesIO(body), + ) + + +class TestJsonEmitter: + """Direct emitter tests — verify event shape, schema_version, no-op in non-JSON mode.""" + + def test_noop_in_human_mode(self, capsys): + e = JsonEmitter(json_mode=False) + e.set_client_id("cid") + e.emit_queued("pid", None) + e.emit_completed() + e.emit_failed("workflow_not_found", "x") + out, _ = capsys.readouterr() + assert out == "" + + def test_every_event_has_schema_version_1(self, capsys, simple_workflow): + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + e.set_client_id("cid") + e.emit_converted(2) + e.emit_queued("pid", None) + e.emit_node_cached("1") + e.emit_node_executing("2") + e.emit_node_progress("2", 5, 10) + e.emit_node_executed("2", []) + e.emit_completed() + e.emit_failed("execution_error", "x", node_id="2") + out, _ = capsys.readouterr() + lines = [line for line in out.splitlines() if line.strip()] + assert len(lines) == 8 + for line in lines: + event = json.loads(line) + assert event.get("schema_version") == 1, f"Missing schema_version on: {line}" + + def test_queued_includes_validation_warnings_null(self, capsys): + e = JsonEmitter(json_mode=True) + e.set_client_id("c") + e.emit_queued("p", None) + out, _ = capsys.readouterr() + event = json.loads(out.strip()) + assert event["event"] == "queued" + assert event["validation_warnings"] is None + assert event["prompt_id"] == "p" + assert event["client_id"] == "c" + # nodes manifest is always present (empty when no workflow set) + assert event["nodes"] == [] + + def test_queued_includes_validation_warnings_dict(self, capsys): + e = JsonEmitter(json_mode=True) + e.set_client_id("c") + warnings = {"5": {"errors": [{"type": "x", "message": "y"}]}} + e.emit_queued("p", warnings) + out, _ = capsys.readouterr() + event = json.loads(out.strip()) + assert event["validation_warnings"] == warnings + + def test_queued_nodes_manifest_from_workflow(self, capsys, simple_workflow): + """`nodes` should list one entry per workflow node with node_id, class_type, title.""" + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + e.set_client_id("c") + e.emit_queued("p", None) + event = json.loads(capsys.readouterr().out.strip()) + nodes = event["nodes"] + assert len(nodes) == 2 + by_id = {n["node_id"]: n for n in nodes} + assert by_id["1"]["class_type"] == "EmptyLatentImage" + assert by_id["1"]["title"] == "Latent" # _meta.title wins + assert by_id["2"]["class_type"] == "SaveImage" + assert by_id["2"]["title"] == "Save" + + def test_node_progress_includes_class_type_and_title(self, capsys, simple_workflow): + """node_progress carries class_type+title so stateless consumers can + render the running node without buffering a prior node_executing event.""" + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + e.set_client_id("c") + e.emit_node_progress("1", 5, 10) + event = json.loads(capsys.readouterr().out.strip()) + assert event["event"] == "node_progress" + assert event["class_type"] == "EmptyLatentImage" + assert event["title"] == "Latent" + assert event["value"] == 5 + assert event["max"] == 10 + + def test_emit_node_handlers_coerce_node_id_to_str(self, capsys, simple_workflow): + """If the server ever sends an int node_id, emit_* must coerce to str.""" + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + e.set_client_id("c") + e.emit_node_executing(2) + e.emit_node_progress(2, 1, 10) + e.emit_node_cached(2) + e.emit_node_executed(2, []) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + for ev in events: + assert isinstance(ev["node_id"], str), f"{ev['event']} node_id is {type(ev['node_id']).__name__}" + assert ev["node_id"] == "2" + e.emit_completed() + completed = json.loads(capsys.readouterr().out.strip()) + assert all(isinstance(nid, str) for nid in completed["cached_node_ids"]) + assert all(isinstance(nid, str) for nid in completed["executed_node_ids"]) + + def test_completed_aggregates_outputs_and_node_ids(self, capsys, simple_workflow): + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + e.set_client_id("c") + e.emit_node_cached("1") + out1 = { + "category": "images", + "node_id": "2", + "class_type": "SaveImage", + "title": "Save", + "filename": "x.png", + "subfolder": "", + "type": "output", + "url": "http://x", + "local_path": None, + } + e.emit_node_executed("2", [out1]) + e.emit_completed() + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + completed = events[-1] + assert completed["event"] == "completed" + assert completed["cached_node_ids"] == ["1"] + assert completed["executed_node_ids"] == ["2"] + assert completed["outputs"] == [out1] + assert isinstance(completed["elapsed_seconds"], float) + assert completed["elapsed_seconds"] >= 0 + + def test_cached_and_executed_can_overlap(self, capsys, simple_workflow): + """Cached output-bearing nodes emit both execution_cached and executed.""" + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + e.set_client_id("c") + e.emit_node_cached("2") + e.emit_node_executed("2", []) + e.emit_completed() + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + completed = events[-1] + assert "2" in completed["cached_node_ids"] + assert "2" in completed["executed_node_ids"] + + def test_failed_event_carries_universal_and_extras(self, capsys): + e = JsonEmitter(json_mode=True) + e.set_client_id("c") + e.emit_failed("client_error", "Bad request", status_code=401, body="unauthorized") + event = json.loads(capsys.readouterr().out.strip()) + assert event["event"] == "failed" + assert event["error"]["kind"] == "client_error" + assert event["error"]["message"] == "Bad request" + assert event["error"]["status_code"] == 401 + assert event["error"]["body"] == "unauthorized" + assert event["client_id"] == "c" + assert event["prompt_id"] is None # never set + assert isinstance(event["elapsed_seconds"], float) + + def test_title_falls_back_to_class_type(self, simple_workflow): + e = JsonEmitter(json_mode=True) + # Drop _meta.title from node 1 + wf = {"1": {"class_type": "EmptyLatentImage", "inputs": {}}} + e.set_workflow(wf) + assert e.get_title("1") == "EmptyLatentImage" + + def test_title_falls_back_to_node_id_for_unknown(self): + e = JsonEmitter(json_mode=True) + e.set_workflow({}) + assert e.get_title("unknown") == "unknown" + + def test_ascii_safe_emission(self, capsys): + """ensure_ascii=True: non-ASCII becomes \\u escapes.""" + e = JsonEmitter(json_mode=True) + e.set_client_id("c") + e.emit_failed("workflow_not_found", "found: 猫_00001_.png") + out, _ = capsys.readouterr() + # The wire must contain \u escapes, not raw UTF-8 bytes. + assert "\\u732b" in out + assert "猫" not in out + + +class TestClassifyApiWorkflow: + def test_well_formed(self): + assert _classify_api_workflow({"1": {"class_type": "X", "inputs": {}}})[0] == "ok" + + def test_empty_dict(self): + assert _classify_api_workflow({})[0] == "empty" + + def test_invalid_first_node(self): + assert _classify_api_workflow({"foo": "bar"})[0] == "invalid" + + def test_invalid_not_a_dict(self): + assert _classify_api_workflow([])[0] == "invalid" + + +class TestPreFlightFailures: + """Single `failed` event, prompt_id=null, client_id=null.""" + + def test_workflow_not_found(self, capsys): + events = _run_execute_capture("/nonexistent.json", capsys) + assert len(events) == 1 + assert events[0]["event"] == "failed" + assert events[0]["error"]["kind"] == "workflow_not_found" + assert events[0]["prompt_id"] is None + assert events[0]["client_id"] is None + assert events[0]["schema_version"] == 1 + + def test_workflow_invalid_json(self, capsys): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{ this is not json") + path = f.name + try: + with patch("comfy_cli.command.run.check_comfy_server_running", return_value=True): + events = _run_execute_capture(path, capsys) + assert len(events) == 1 + assert events[0]["error"]["kind"] == "workflow_invalid_json" + finally: + os.unlink(path) + + def test_workflow_read_error_unicode(self, capsys): + with tempfile.NamedTemporaryFile(mode="wb", suffix=".json", delete=False) as f: + f.write(b"\xff\xfe\xfa\x00") # invalid UTF-8 + path = f.name + try: + with patch("comfy_cli.command.run.check_comfy_server_running", return_value=True): + events = _run_execute_capture(path, capsys) + assert len(events) == 1 + assert events[0]["error"]["kind"] == "workflow_read_error" + finally: + os.unlink(path) + + def test_workflow_empty_api(self, capsys): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({}, f) + path = f.name + try: + with patch("comfy_cli.command.run.check_comfy_server_running", return_value=True): + events = _run_execute_capture(path, capsys) + assert len(events) == 1 + assert events[0]["error"]["kind"] == "workflow_empty" + finally: + os.unlink(path) + + def test_workflow_format_invalid(self, capsys): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"foo": "bar"}, f) + path = f.name + try: + with patch("comfy_cli.command.run.check_comfy_server_running", return_value=True): + events = _run_execute_capture(path, capsys) + assert len(events) == 1 + assert events[0]["error"]["kind"] == "workflow_format_invalid" + finally: + os.unlink(path) + + def test_connection_error_server_down(self, workflow_file, capsys): + with patch("comfy_cli.command.run.check_comfy_server_running", return_value=False): + events = _run_execute_capture(workflow_file, capsys) + assert len(events) == 1 + assert events[0]["error"]["kind"] == "connection_error" + + +class TestSuccessfulRun: + def test_no_wait_emits_queued_only(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p123"}).encode() + events = _run_execute_capture(workflow_file, capsys, wait=False) + assert len(events) == 1 + assert events[0]["event"] == "queued" + assert events[0]["prompt_id"] == "p123" + assert events[0]["validation_warnings"] is None + + def test_completed_event_after_success(self, workflow_file, capsys): + """Mocked WS flow → expect queued + node_* + completed.""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + + def msg(t, **d): + return json.dumps({"type": t, "data": {"prompt_id": "p", **d}}) + + ws_instance.recv.side_effect = [ + msg("executing", node="1"), + msg( + "executed", node="1", output={"images": [{"filename": "x.png", "subfolder": "", "type": "output"}]} + ), + msg("executing", node=None), + ] + events = _run_execute_capture(workflow_file, capsys, wait=True) + + terminal = events[-1] + assert terminal["event"] == "completed" + assert terminal["prompt_id"] == "p" + assert len(terminal["outputs"]) == 1 + assert terminal["outputs"][0]["filename"] == "x.png" + assert terminal["outputs"][0]["category"] == "images" + assert terminal["executed_node_ids"] == ["1"] + + +class TestQueueHttpErrors: + """Verify the 5-way HTTP error mapping for /prompt failures.""" + + def _setup_and_run(self, workflow_file, http_response, capsys, status=None, body=b""): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket"), + ): + if status is None: + # Success path mock + mock_open.return_value.read.return_value = http_response + else: + mock_open.side_effect = _make_http_error(status, body) + return _run_execute_capture(workflow_file, capsys) + + def test_400_with_node_errors_routes_to_validation_error(self, workflow_file, capsys): + body = json.dumps( + { + "error": {"type": "x", "message": "y"}, + "node_errors": {"1": {"errors": [{"type": "z", "message": "bad"}], "class_type": "X"}}, + } + ).encode() + events = self._setup_and_run(workflow_file, None, capsys, status=400, body=body) + terminal = events[-1] + assert terminal["error"]["kind"] == "validation_error" + assert "1" in terminal["error"]["node_errors"] + + def test_401_routes_to_client_error(self, workflow_file, capsys): + events = self._setup_and_run(workflow_file, None, capsys, status=401, body=b"unauthorized") + terminal = events[-1] + assert terminal["error"]["kind"] == "client_error" + assert terminal["error"]["status_code"] == 401 + + def test_403_routes_to_client_error(self, workflow_file, capsys): + events = self._setup_and_run(workflow_file, None, capsys, status=403, body=b"forbidden") + assert events[-1]["error"]["kind"] == "client_error" + assert events[-1]["error"]["status_code"] == 403 + + def test_429_routes_to_client_error(self, workflow_file, capsys): + events = self._setup_and_run(workflow_file, None, capsys, status=429, body=b"too many") + assert events[-1]["error"]["kind"] == "client_error" + + def test_500_routes_to_server_error(self, workflow_file, capsys): + events = self._setup_and_run(workflow_file, None, capsys, status=500, body=b"oops") + terminal = events[-1] + assert terminal["error"]["kind"] == "server_error" + assert terminal["error"]["status_code"] == 500 + assert terminal["error"]["body"] == "oops" + + def test_503_routes_to_server_error(self, workflow_file, capsys): + events = self._setup_and_run(workflow_file, None, capsys, status=503, body=b"down") + assert events[-1]["error"]["kind"] == "server_error" + + def test_200_with_non_json_body_routes_to_invalid_response(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket"), + ): + mock_open.return_value.read.return_value = b"garbage" + events = _run_execute_capture(workflow_file, capsys) + terminal = events[-1] + assert terminal["error"]["kind"] == "invalid_response" + assert terminal["error"]["status_code"] == 200 + + def test_200_without_prompt_id_routes_to_invalid_response(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket"), + ): + mock_open.return_value.read.return_value = json.dumps({"other": "x"}).encode() + events = _run_execute_capture(workflow_file, capsys) + terminal = events[-1] + assert terminal["error"]["kind"] == "invalid_response" + + def test_url_error_routes_to_connection_error(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket"), + ): + mock_open.side_effect = urllib.error.URLError("refused") + events = _run_execute_capture(workflow_file, capsys) + terminal = events[-1] + assert terminal["error"]["kind"] == "connection_error" + + def test_validation_warnings_on_200_with_partial_node_errors(self, workflow_file, capsys): + """200 + non-empty node_errors → emit `queued` with validation_warnings populated.""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + body = json.dumps( + { + "prompt_id": "p", + "node_errors": {"3": {"errors": [{"type": "x", "message": "skipped"}], "class_type": "X"}}, + } + ).encode() + mock_open.return_value.read.return_value = body + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + events = _run_execute_capture(workflow_file, capsys) + queued = next(e for e in events if e["event"] == "queued") + assert queued["validation_warnings"] is not None + assert "3" in queued["validation_warnings"] + + +class TestWebSocketEvents: + def _run_with_ws_messages(self, workflow_file, recv_side_effect, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = recv_side_effect + return _run_execute_capture(workflow_file, capsys) + + def test_websocket_timeout(self, workflow_file, capsys): + events = self._run_with_ws_messages( + workflow_file, + WebSocketTimeoutException("timed out"), + capsys, + ) + terminal = events[-1] + assert terminal["error"]["kind"] == "timeout" + assert isinstance(terminal["error"]["timeout_seconds"], float) + + def test_connection_lost_websocket(self, workflow_file, capsys): + events = self._run_with_ws_messages( + workflow_file, + WebSocketException("dropped"), + capsys, + ) + terminal = events[-1] + assert terminal["error"]["kind"] == "connection_lost" + + def test_connection_lost_on_malformed_frame(self, workflow_file, capsys): + """We silently skip malformed frames; if ALL frames are malformed the + stream just hangs forever — so the realistic test is: WS raises during + recv after a malformed frame. Verify the bad frame doesn't crash.""" + events = self._run_with_ws_messages( + workflow_file, + ["{not json", json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}})], + capsys, + ) + # No crash, normal completion path reached. + terminal = events[-1] + assert terminal["event"] == "completed" + + def test_execution_error(self, workflow_file, capsys): + messages = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": "1"}}), + json.dumps( + { + "type": "execution_error", + "data": { + "prompt_id": "p", + "node_id": "1", + "node_type": "EmptyLatentImage", + "exception_type": "RuntimeError", + "exception_message": "boom", + "traceback": [' File "x.py"\n', " raise RuntimeError\n"], + }, + } + ), + ] + events = self._run_with_ws_messages(workflow_file, messages, capsys) + terminal = events[-1] + assert terminal["error"]["kind"] == "execution_error" + assert terminal["error"]["node_id"] == "1" + assert terminal["error"]["class_type"] == "EmptyLatentImage" + assert terminal["error"]["exception_type"] == "RuntimeError" + assert terminal["error"]["title"] == "Latent" # from _meta.title + assert isinstance(terminal["error"]["traceback"], str) + assert "raise RuntimeError" in terminal["error"]["traceback"] + + def test_execution_interrupted(self, workflow_file, capsys): + messages = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": "1"}}), + json.dumps({"type": "execution_interrupted", "data": {"prompt_id": "p"}}), + ] + events = self._run_with_ws_messages(workflow_file, messages, capsys) + terminal = events[-1] + assert terminal["error"]["kind"] == "interrupted" + + +class TestOutputObject: + def _exec(self, simple_workflow): + progress = MagicMock() + progress.add_task.return_value = 0 + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + return WorkflowExecution( + workflow=simple_workflow, + host="127.0.0.1", + port=8188, + verbose=False, + progress=progress, + local_paths=False, + timeout=30, + emitter=e, + ) + + def test_duck_typed_filter_skips_strings(self, simple_workflow, capsys): + """ComfyUI's `text` output key emits a list of strings; the filter must skip non-file shapes.""" + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed( + { + "node": "2", + "output": { + "text": ["hello"], + "images": [{"filename": "x.png", "subfolder": "", "type": "output"}], + }, + } + ) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + executed = next(e for e in events if e["event"] == "node_executed") + assert len(executed["outputs"]) == 1 + assert executed["outputs"][0]["category"] == "images" + + def test_duck_typed_filter_skips_booleans(self, simple_workflow, capsys): + """`animated` key emits list of bool — must be skipped.""" + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed( + { + "node": "2", + "output": { + "animated": [True], + "images": [{"filename": "x.png", "subfolder": "", "type": "output"}], + }, + } + ) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + executed = next(e for e in events if e["event"] == "node_executed") + assert len(executed["outputs"]) == 1 + + def test_audio_category_recognized(self, simple_workflow, capsys): + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed( + { + "node": "2", + "output": { + "audio": [{"filename": "a.wav", "subfolder": "sf", "type": "output"}], + }, + } + ) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + executed = next(e for e in events if e["event"] == "node_executed") + assert executed["outputs"][0]["category"] == "audio" + assert executed["outputs"][0]["filename"] == "a.wav" + assert executed["outputs"][0]["subfolder"] == "sf" + + def test_output_url_has_correct_format(self, simple_workflow, capsys): + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "x.png", "subfolder": "", "type": "output"}], + }, + } + ) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + url = events[-1]["outputs"][0]["url"] + assert url.startswith("http://127.0.0.1:8188/view?") + assert "filename=x.png" in url + assert "type=output" in url + + def test_local_path_null_when_local_paths_false(self, simple_workflow, capsys): + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "x.png", "subfolder": "", "type": "output"}], + }, + } + ) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + assert events[-1]["outputs"][0]["local_path"] is None + + def test_missing_subfolder_defaults_to_empty_string(self, simple_workflow, capsys): + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "x.png", "type": "output"}], + }, + } + ) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + assert events[-1]["outputs"][0]["subfolder"] == "" + + +UI_WORKFLOW = { + "nodes": [ + { + "id": 1, + "type": "EmptyLatentImage", + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": [10]}], + "widgets_values": [64, 64, 1], + "mode": 0, + }, + { + "id": 2, + "type": "PreviewImage", + "inputs": [{"name": "images", "link": 10}], + "outputs": [], + "mode": 0, + }, + ], + "links": [[10, 1, 0, 2, 0, "IMAGE"]], +} + +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_node": False, + "display_name": "Empty Latent Image", + }, + "PreviewImage": { + "input": {"required": {"images": ["IMAGE"]}}, + "input_order": {"required": ["images"]}, + "output_node": True, + "display_name": "Preview Image", + }, +} + + +@pytest.fixture +def ui_workflow_file(): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(UI_WORKFLOW, f) + f.flush() + path = f.name + yield path + os.unlink(path) + + +class TestConvertedAndConversionErrors: + """UI-input event path and the conversion_error / conversion_crash kinds.""" + + def test_converted_event_for_ui_input(self, ui_workflow_file, capsys): + """Spec lines 84-98: `converted` is the first event when input is UI format.""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.fetch_object_info", return_value=OBJECT_INFO), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + events = _run_execute_capture(ui_workflow_file, capsys) + + assert events[0]["event"] == "converted" + assert events[0]["schema_version"] == 1 + assert events[0]["node_count"] == 2 # the UI workflow has 2 nodes + + def test_conversion_error_kind(self, ui_workflow_file, capsys): + """WorkflowConversionError → kind=conversion_error, no extras.""" + from comfy_cli.workflow_to_api import WorkflowConversionError + + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.fetch_object_info", return_value=OBJECT_INFO), + patch( + "comfy_cli.command.run.convert_ui_to_api", + side_effect=WorkflowConversionError("broken graph"), + ), + ): + events = _run_execute_capture(ui_workflow_file, capsys) + + terminal = events[-1] + assert terminal["error"]["kind"] == "conversion_error" + assert terminal["client_id"] is None # before WorkflowExecution + assert terminal["prompt_id"] is None + + def test_conversion_crash_kind_with_exception_type(self, ui_workflow_file, capsys): + """Unexpected converter crash → kind=conversion_crash with exception_type extra.""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.fetch_object_info", return_value=OBJECT_INFO), + patch( + "comfy_cli.command.run.convert_ui_to_api", + side_effect=KeyError("missing field"), + ), + ): + events = _run_execute_capture(ui_workflow_file, capsys) + + terminal = events[-1] + assert terminal["error"]["kind"] == "conversion_crash" + assert terminal["error"]["exception_type"] == "KeyError" + assert terminal["client_id"] is None + assert terminal["prompt_id"] is None + + def test_workflow_empty_after_conversion(self, capsys): + """UI conversion producing {} → workflow_empty.""" + empty_ui = {"nodes": [], "links": []} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(empty_ui, f) + f.flush() + path = f.name + try: + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.fetch_object_info", return_value=OBJECT_INFO), + patch("comfy_cli.command.run.convert_ui_to_api", return_value={}), + ): + events = _run_execute_capture(path, capsys) + assert events[-1]["error"]["kind"] == "workflow_empty" + finally: + os.unlink(path) + + +class TestObjectInfoFailures: + """HTTP and network errors on /object_info.""" + + def test_object_info_unavailable_on_http_error(self, ui_workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + ): + mock_open.side_effect = _make_http_error(500, b"internal error") + # _make_http_error builds a /prompt URL by default; switch to object_info + mock_open.side_effect = urllib.error.HTTPError( + url="http://127.0.0.1:8188/object_info", + code=503, + msg="HTTP 503", + hdrs=None, + fp=io.BytesIO(b"service unavailable"), + ) + events = _run_execute_capture(ui_workflow_file, capsys) + + terminal = events[-1] + assert terminal["error"]["kind"] == "object_info_unavailable" + assert terminal["error"]["status_code"] == 503 + assert "service unavailable" in terminal["error"]["body"] + assert terminal["client_id"] is None # pre-WorkflowExecution + + def test_object_info_connection_error_on_urlerror(self, ui_workflow_file, capsys): + """URLError on /object_info → connection_error (NOT object_info_unavailable).""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + ): + mock_open.side_effect = urllib.error.URLError("connection refused") + events = _run_execute_capture(ui_workflow_file, capsys) + + terminal = events[-1] + assert terminal["error"]["kind"] == "connection_error" + + +class TestNodeCachedIntegration: + """`execution_cached` WS message → node_cached events with class_type / title.""" + + def test_node_cached_event_shape(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "execution_cached", "data": {"prompt_id": "p", "nodes": ["1", "2"]}}), + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + events = _run_execute_capture(workflow_file, capsys) + + cached_events = [e for e in events if e["event"] == "node_cached"] + assert len(cached_events) == 2 + # Node 1 has _meta.title="Latent"; class_type=EmptyLatentImage + n1 = next(e for e in cached_events if e["node_id"] == "1") + assert n1["class_type"] == "EmptyLatentImage" + assert n1["title"] == "Latent" + # Node 2 has _meta.title="Save"; class_type=SaveImage + n2 = next(e for e in cached_events if e["node_id"] == "2") + assert n2["class_type"] == "SaveImage" + assert n2["title"] == "Save" + + # All cached nodes also appear in completed.cached_node_ids + completed = events[-1] + assert completed["event"] == "completed" + assert set(completed["cached_node_ids"]) == {"1", "2"} + + +class TestNodeExecutedFiresEvenWithoutOutputs: + """`node_executed` must fire whenever the server emits `executed` for our + prompt, even when there's no `output` dict or it's empty (outputs=[]).""" + + def _exec(self, simple_workflow): + progress = MagicMock() + progress.add_task.return_value = 0 + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + return WorkflowExecution( + workflow=simple_workflow, + host="127.0.0.1", + port=8188, + verbose=False, + progress=progress, + local_paths=False, + timeout=30, + emitter=e, + ) + + def test_executed_with_missing_output(self, simple_workflow, capsys): + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed({"node": "2"}) # no `output` key at all + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + executed = [e for e in events if e["event"] == "node_executed"] + assert len(executed) == 1 + assert executed[0]["outputs"] == [] + assert executed[0]["node_id"] == "2" + + def test_executed_with_non_dict_output(self, simple_workflow, capsys): + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed({"node": "2", "output": []}) # list instead of dict + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + executed = [e for e in events if e["event"] == "node_executed"] + assert len(executed) == 1 + assert executed[0]["outputs"] == [] + + def test_executed_with_empty_dict_output(self, simple_workflow, capsys): + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed({"node": "2", "output": {}}) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + executed = [e for e in events if e["event"] == "node_executed"] + assert len(executed) == 1 + assert executed[0]["outputs"] == [] + + +class TestFormatImagePathDefensive: + """`format_image_path` must be defensive against missing `type` / `subfolder` + keys — the duck-type filter only requires `filename`.""" + + def _exec(self, simple_workflow): + progress = MagicMock() + progress.add_task.return_value = 0 + e = JsonEmitter(json_mode=True) + e.set_workflow(simple_workflow) + return WorkflowExecution( + workflow=simple_workflow, + host="127.0.0.1", + port=8188, + verbose=False, + progress=progress, + local_paths=False, + timeout=30, + emitter=e, + ) + + def test_no_keyerror_on_missing_type(self, simple_workflow): + ex = self._exec(simple_workflow) + # Should not raise — `type` missing, should default to "output" + url = ex.format_image_path({"filename": "x.png", "subfolder": ""}) + assert "filename=x.png" in url + assert "type=output" in url + + def test_no_keyerror_on_missing_subfolder(self, simple_workflow): + ex = self._exec(simple_workflow) + url = ex.format_image_path({"filename": "x.png", "type": "output"}) + assert "filename=x.png" in url + + +class TestVerboseNoOpInJsonMode: + """Spec lines 24-25: `--verbose` has no effect in JSON mode. Regression + against a bug where `log_node()` printed Rich-formatted lines to stdout + when verbose=True, corrupting the NDJSON stream.""" + + def test_verbose_does_not_corrupt_json_stream(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "execution_cached", "data": {"prompt_id": "p", "nodes": ["1"]}}), + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": "2"}}), + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + try: + execute( + workflow_file, + host="127.0.0.1", + port=8188, + wait=True, + verbose=True, + local_paths=False, + timeout=30, + json_mode=True, + ) + except typer.Exit: + pass + out, _err = capsys.readouterr() + for line in out.splitlines(): + line = line.strip() + if not line: + continue + # Any Rich-formatted leak would make json.loads raise on a bare + # "Cached : ..." line. + json.loads(line) + + +class TestErrorPathCoverage: + """Less-trodden paths: positive local_path, /object_info timeout/non-JSON, + queue() TimeoutError/OSError, on_executed/on_progress None guards, + on_cached None entries, two consecutive node_executing pattern.""" + + def _make_workflow(self): + return { + "1": { + "class_type": "EmptyLatentImage", + "inputs": {"width": 64, "height": 64, "batch_size": 1}, + "_meta": {"title": "Latent"}, + }, + "2": { + "class_type": "SaveImage", + "inputs": {"filename_prefix": "x", "images": ["1", 0]}, + }, + } + + def _make_exec(self, workflow, local_paths=False): + e = JsonEmitter(json_mode=True) + e.set_workflow(workflow) + return WorkflowExecution( + workflow=workflow, + host="127.0.0.1", + port=8188, + verbose=False, + progress=None, + local_paths=local_paths, + timeout=30, + emitter=e, + ) + + def test_local_path_populated_when_local_paths_true(self, capsys): + """Positive case: local_paths=True + valid workspace path → local_path filled.""" + wf = self._make_workflow() + ex = self._make_exec(wf, local_paths=True) + ex.prompt_id = "p" + with patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ): + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "out.png", "subfolder": "sf", "type": "output"}], + }, + } + ) + ev = json.loads(capsys.readouterr().out.strip()) + out0 = ev["outputs"][0] + assert out0["local_path"] == "/fake/workspace/output/sf/out.png" + + def test_local_path_null_when_workspace_unavailable(self, capsys): + """local_paths=True but workspace_manager raises → local_path is null (defensive).""" + wf = self._make_workflow() + ex = self._make_exec(wf, local_paths=True) + ex.prompt_id = "p" + with patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + side_effect=RuntimeError("no workspace configured"), + ): + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], + }, + } + ) + ev = json.loads(capsys.readouterr().out.strip()) + assert ev["outputs"][0]["local_path"] is None + + def test_object_info_timeout_routes_to_connection_error(self, capsys): + """fetch_object_info(timeout → connection_error). Previously untested.""" + ui_wf = { + "nodes": [ + { + "id": 1, + "type": "EmptyLatentImage", + "inputs": [], + "outputs": [], + "widgets_values": [64, 64, 1], + "mode": 0, + } + ], + "links": [], + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(ui_wf, f) + path = f.name + try: + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen", side_effect=TimeoutError("timed out")), + ): + events = _run_execute_capture(path, capsys) + assert events[-1]["error"]["kind"] == "connection_error" + finally: + os.unlink(path) + + def test_object_info_non_json_body_routes_to_object_info_unavailable(self, capsys): + """fetch_object_info(200 + non-JSON body → object_info_unavailable status_code=200).""" + ui_wf = { + "nodes": [ + { + "id": 1, + "type": "EmptyLatentImage", + "inputs": [], + "outputs": [], + "widgets_values": [64, 64, 1], + "mode": 0, + } + ], + "links": [], + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(ui_wf, f) + path = f.name + try: + mock_resp = MagicMock() + mock_resp.read.return_value = b"not json" + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen", return_value=mock_resp), + ): + events = _run_execute_capture(path, capsys) + terminal = events[-1] + assert terminal["error"]["kind"] == "object_info_unavailable" + assert terminal["error"]["status_code"] == 200 + finally: + os.unlink(path) + + def test_queue_timeout_error_routes_to_connection_error(self, workflow_file, capsys): + """queue()'s urlopen TimeoutError → connection_error.""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen", side_effect=TimeoutError("post timed out")), + patch("comfy_cli.command.run.WebSocket"), + ): + events = _run_execute_capture(workflow_file, capsys) + assert events[-1]["error"]["kind"] == "connection_error" + + def test_queue_oserror_routes_to_connection_error(self, workflow_file, capsys): + """queue()'s urlopen OSError → connection_error.""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen", side_effect=OSError("network unreachable")), + patch("comfy_cli.command.run.WebSocket"), + ): + events = _run_execute_capture(workflow_file, capsys) + assert events[-1]["error"]["kind"] == "connection_error" + + def test_on_executed_none_node_id_does_not_emit(self, capsys): + """If server emits `executed` without `node`, skip rather than emit + a malformed event with node_id=null.""" + wf = self._make_workflow() + ex = self._make_exec(wf) + ex.prompt_id = "p" + # Missing "node" key entirely + ex.on_executed({"output": {"images": [{"filename": "x.png", "subfolder": "", "type": "output"}]}}) + # Explicit None + ex.on_executed({"node": None}) + out, _ = capsys.readouterr() + # No events emitted because we skipped pathological frames + assert out.strip() == "", f"unexpected output for None node: {out!r}" + + def test_on_progress_none_node_id_does_not_emit(self, capsys): + wf = self._make_workflow() + ex = self._make_exec(wf) + ex.prompt_id = "p" + ex.on_progress({"value": 1, "max": 10}) # missing node + ex.on_progress({"node": None, "value": 2, "max": 10}) + out, _ = capsys.readouterr() + assert out.strip() == "" + + def test_on_cached_skips_none_entries(self, capsys): + wf = self._make_workflow() + ex = self._make_exec(wf) + ex.prompt_id = "p" + ex.on_cached({"nodes": ["1", None, "2"]}) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + assert len(events) == 2 + assert {ev["node_id"] for ev in events} == {"1", "2"} + + def test_two_consecutive_node_executing_includes_intermediate(self, workflow_file, capsys): + """`executed_node_ids` is the union of nodes that emitted `node_executing` + OR `node_executed` — intermediate compute nodes that only fire `executing` + are still included so consumers see the complete 'what ran' picture.""" + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": "1"}}), + # node 2 starts without a node_executed for 1 — intermediate compute node + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": "2"}}), + json.dumps( + { + "type": "executed", + "data": { + "prompt_id": "p", + "node": "2", + "output": {"images": [{"filename": "x.png", "subfolder": "", "type": "output"}]}, + }, + } + ), + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + events = _run_execute_capture(workflow_file, capsys) + completed = events[-1] + assert completed["event"] == "completed" + # Both nodes ran; both should appear in executed_node_ids + # (1 via node_executing only, 2 via both events with dedup) + assert set(completed["executed_node_ids"]) == {"1", "2"} + # And node 2 should only appear once (dedup verified) + assert completed["executed_node_ids"].count("2") == 1 + + +class TestTimeoutAppliesToConnectAndPost: + """`--timeout` must bound every blocking network call (ws.connect, /prompt + POST, ws.recv) so the terminal-event guarantee holds under server hangs.""" + + def test_queue_passes_timeout_to_urlopen(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + # Single executing(node=None) → on_executing returns False → loop exits + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + try: + execute( + workflow_file, + host="127.0.0.1", + port=8188, + wait=True, + verbose=False, + local_paths=False, + timeout=42, + json_mode=True, + ) + except typer.Exit: + pass + _ = capsys.readouterr() + # Verify urlopen was called with timeout=42 + assert mock_open.called + call = mock_open.call_args + timeout_arg = call.kwargs.get("timeout") + if timeout_arg is None and len(call.args) >= 2: + timeout_arg = call.args[1] + assert timeout_arg == 42, f"urlopen not called with timeout=42, got {timeout_arg!r}" + + def test_connect_passes_timeout_to_ws_connect(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + try: + execute( + workflow_file, + host="127.0.0.1", + port=8188, + wait=True, + verbose=False, + local_paths=False, + timeout=37, + json_mode=True, + ) + except typer.Exit: + pass + _ = capsys.readouterr() + # Verify ws.connect was called with timeout=37 + assert ws_instance.connect.called + connect_call = ws_instance.connect.call_args + timeout_arg = connect_call.kwargs.get("timeout") + if timeout_arg is None and len(connect_call.args) >= 2: + timeout_arg = connect_call.args[1] + assert timeout_arg == 37, f"ws.connect not called with timeout=37, got {timeout_arg!r}" + + +class TestNoWaitQueueErrorRegression: + """--no-wait + queue HTTPError must not crash on the progress-stop path + (progress is None in --no-wait mode).""" + + def test_no_wait_with_400_emits_validation_error(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + ): + body = json.dumps( + { + "error": {"type": "x", "message": "y"}, + "node_errors": {"1": {"errors": [{"type": "z", "message": "bad"}], "class_type": "X"}}, + } + ).encode() + mock_open.side_effect = _make_http_error(400, body) + events = _run_execute_capture(workflow_file, capsys, wait=False) + terminal = events[-1] + assert terminal["error"]["kind"] == "validation_error" + # The big invariant: it didn't crash with AttributeError on `progress.stop()` diff --git a/tests/comfy_cli/test_env_checker.py b/tests/comfy_cli/test_env_checker.py index 00677c6d..c4ec7f57 100644 --- a/tests/comfy_cli/test_env_checker.py +++ b/tests/comfy_cli/test_env_checker.py @@ -52,7 +52,11 @@ def test_non_200_status(self, mock_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") - mock_get.assert_called_with("http://0.0.0.0:9999/history") + # `timeout` is passed defensively so a hung server doesn't block the + # caller indefinitely; default value is unimportant here. + mock_get.assert_called_once() + assert mock_get.call_args.args == ("http://0.0.0.0:9999/history",) + assert "timeout" in mock_get.call_args.kwargs class TestEnvChecker: From 21efd7bfc39f44c7cf656a407d666875fe51cff3 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 10:38:38 +0000 Subject: [PATCH 02/32] fix(tracking): enable session-only telemetry when non-interactive The first-run consent prompt blocks subprocess pipes (corrupts --json output) and CI runs. Skipping the prompt entirely would also skip the event, leaving a permanent blind spot for agentic usage. When stdin or stdout is not a TTY and consent has not yet been recorded, enable tracking for the current process and persist a stable anonymous user_id so repeat agentic usage from the same machine attributes to one identity. The consent flag itself stays unset so a later interactive run can still prompt the human; if they consent, init_tracking reuses the existing user_id. Signed-off-by: Alexander Piskun --- comfy_cli/tracking.py | 36 +++++++++-- tests/comfy_cli/test_tracking.py | 104 +++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/comfy_cli/tracking.py b/comfy_cli/tracking.py index 570dee46..17f86313 100644 --- a/comfy_cli/tracking.py +++ b/comfy_cli/tracking.py @@ -1,5 +1,6 @@ import functools import logging as logginglib +import sys import uuid import typer @@ -29,6 +30,13 @@ tracing_id = str(uuid.uuid4()) workspace_manager = WorkspaceManager() +# Process-scoped opt-in used when running non-interactively before the +# user has ever recorded a consent choice. Captures agentic usage without +# persisting the consent flag, so a later interactive run can still +# prompt the human. The anonymous user_id is persisted separately for +# stable agent identity in analytics. +_session_only_tracking = False + app = typer.Typer() @@ -50,7 +58,7 @@ def track_event(event_name: str, properties: any = None): properties = {} logging.debug(f"tracking event called with event_name: {event_name} and properties: {properties}") enable_tracking = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) - if not enable_tracking: + if not enable_tracking and not _session_only_tracking: return try: @@ -90,15 +98,35 @@ def wrapper(*args, **kwargs): def prompt_tracking_consent(skip_prompt: bool = False, default_value: bool = False): + global _session_only_tracking, user_id + + if _session_only_tracking: + return + tracking_enabled = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) if tracking_enabled is not None: return if skip_prompt: init_tracking(default_value) - else: - enable_tracking = ui.prompt_confirm_action("Do you agree to enable tracking to improve the application?", False) - init_tracking(enable_tracking) + return + + # When stdin or stdout is not a TTY (subprocess pipe, redirect, CI), + # blocking on the consent prompt would either hang the caller forever + # or corrupt their output stream. Enable tracking for this process and + # persist a stable anonymous user_id so repeat agentic usage from the + # same machine attributes to one identity. The consent flag itself + # stays unset so a later interactive run can still ask the human; if + # they consent, init_tracking will reuse this user_id. + if not sys.stdin.isatty() or not sys.stdout.isatty(): + _session_only_tracking = True + if user_id is None: + user_id = str(uuid.uuid4()) + config_manager.set(constants.CONFIG_KEY_USER_ID, user_id) + return + + enable_tracking = ui.prompt_confirm_action("Do you agree to enable tracking to improve the application?", False) + init_tracking(enable_tracking) def init_tracking(enable_tracking: bool): diff --git a/tests/comfy_cli/test_tracking.py b/tests/comfy_cli/test_tracking.py index 5cb7b020..3b5bede2 100644 --- a/tests/comfy_cli/test_tracking.py +++ b/tests/comfy_cli/test_tracking.py @@ -25,6 +25,7 @@ def tracking_module(tmp_path): patch.object(tracking_mod, "cli_version", "test-cli-version"), patch.object(tracking_mod, "tracing_id", "test-tracing-id"), patch.object(tracking_mod, "mp", MagicMock()), + patch.object(tracking_mod, "_session_only_tracking", False), ): yield tracking_mod @@ -138,3 +139,106 @@ def test_install_event_fires_once_across_calls(self, tracking_module): assert tracking_module.mp.track.call_count == 1 tracking_module.init_tracking(True) assert tracking_module.mp.track.call_count == 1 + + +class TestPromptTrackingConsent: + def test_enables_session_only_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, + ): + tracking_module.prompt_tracking_consent() + mock_prompt.assert_not_called() + assert tracking_module.config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) is None + assert tracking_module._session_only_tracking is True + assert tracking_module.user_id is not None + + def test_enables_session_only_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, + ): + tracking_module.prompt_tracking_consent() + mock_prompt.assert_not_called() + assert tracking_module.config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) is None + assert tracking_module._session_only_tracking is True + + def test_session_only_tracking_fires_track_event(self, tracking_module): + with ( + patch.object(tracking_module.sys.stdin, "isatty", return_value=False), + patch.object(tracking_module.sys.stdout, "isatty", return_value=False), + ): + tracking_module.prompt_tracking_consent() + tracking_module.track_event("some_event", {"k": "v"}) + tracking_module.mp.track.assert_called_once() + _, kwargs = tracking_module.mp.track.call_args + assert kwargs["event_name"] == "some_event" + assert kwargs["distinct_id"] is not None + + def test_session_only_persists_user_id(self, tracking_module): + with ( + patch.object(tracking_module.sys.stdin, "isatty", return_value=False), + patch.object(tracking_module.sys.stdout, "isatty", return_value=False), + ): + tracking_module.prompt_tracking_consent() + persisted = tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID) + assert persisted is not None + assert persisted == tracking_module.user_id + + def test_session_only_reuses_existing_user_id(self, tracking_module): + existing_id = "existing-uuid-from-prior-run" + tracking_module.config_manager.set(constants.CONFIG_KEY_USER_ID, existing_id) + with ( + patch.object(tracking_module, "user_id", existing_id), + patch.object(tracking_module.sys.stdin, "isatty", return_value=False), + patch.object(tracking_module.sys.stdout, "isatty", return_value=False), + ): + tracking_module.prompt_tracking_consent() + assert tracking_module.user_id == existing_id + assert tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID) == existing_id + + 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, + ): + tracking_module.prompt_tracking_consent() + mock_prompt.assert_called_once() + assert tracking_module.config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) is False + assert tracking_module._session_only_tracking is False + + 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, + ): + tracking_module.prompt_tracking_consent(skip_prompt=True, default_value=False) + mock_prompt.assert_not_called() + assert tracking_module.config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) is False + assert tracking_module._session_only_tracking is False + + def test_no_op_when_already_configured(self, tracking_module): + tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, "True") + 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, + ): + tracking_module.prompt_tracking_consent() + mock_prompt.assert_not_called() + assert tracking_module.config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) is True + assert tracking_module._session_only_tracking is False + + def test_session_only_is_idempotent(self, tracking_module): + with ( + patch.object(tracking_module.sys.stdin, "isatty", return_value=False), + patch.object(tracking_module.sys.stdout, "isatty", return_value=False), + ): + tracking_module.prompt_tracking_consent() + first_user_id = tracking_module.user_id + tracking_module.prompt_tracking_consent() + assert tracking_module.user_id == first_user_id From 18ada4179e1b9ec9293c99b654f479abbeaa6c09 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 14:39:32 +0000 Subject: [PATCH 03/32] feat(run): emit node_errors / validation_warnings as a list of records ComfyUI's dict-keyed-by-node-id shape forced consumers to iterate via `.items()` and made `for w in event["validation_warnings"]` (the intuitive pattern) yield bare node-id strings. The empty case also had to be `null` to distinguish "no warnings" from an empty container, which crashes any consumer that doesn't guard the access. Switch both `queued.validation_warnings` and `validation_error.node_errors` to an array of self-contained records where each entry embeds `node_id` as a field. The empty case is now an empty array, so iteration is always safe and the shape matches the convention used by LSP diagnostics, GraphQL errors, and similar event-stream protocols. Update the doc, examples, and tests to match. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 24 ++++++++++--- docs/json-output.md | 44 ++++++++++++++---------- tests/comfy_cli/command/test_run_json.py | 24 ++++++++----- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 8ce50860..9bedd5f7 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -23,6 +23,22 @@ SCHEMA_VERSION = 1 +def _node_errors_to_list(node_errors) -> list[dict]: + """Transform ComfyUI's dict-keyed `node_errors` payload into a list of self-contained records. + Each record carries `node_id` as a field, so agents can iterate the result + directly without indirecting through dict keys.""" + if not isinstance(node_errors, dict): + return [] + result = [] + for node_id, record in node_errors.items(): + if not isinstance(record, dict): + continue + entry = {"node_id": str(node_id)} + entry.update(record) + result.append(entry) + return result + + def is_ui_workflow(workflow) -> bool: return ( isinstance(workflow, dict) @@ -133,7 +149,7 @@ def workflow_manifest(self) -> list[dict]: ) return manifest - def emit_queued(self, prompt_id: str, validation_warnings) -> None: + def emit_queued(self, prompt_id: str, validation_warnings: list[dict]) -> None: self.prompt_id = prompt_id self._emit( { @@ -615,10 +631,8 @@ def queue(self): # 200 may still carry node_errors if some output chains failed # validation but others passed — surface as warnings, not a failure. - validation_warnings = None node_errors = body.get("node_errors") if isinstance(body, dict) else None - if isinstance(node_errors, dict) and node_errors: - validation_warnings = node_errors + validation_warnings = _node_errors_to_list(node_errors) if self.emitter.json_mode: self.emitter.emit_queued(prompt_id, validation_warnings) @@ -677,7 +691,7 @@ def _emit_validation_error(self, node_errors: dict) -> None: self.emitter.emit_failed( "validation_error", message, - node_errors=node_errors, + node_errors=_node_errors_to_list(node_errors), ) else: pprint(f"[bold red]Error running workflow\n{json.dumps(node_errors, indent=2)}[/bold red]") diff --git a/docs/json-output.md b/docs/json-output.md index 6569f30c..7d8e5f15 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -128,7 +128,7 @@ handle, which can be used to correlate against `/history/{prompt_id}`. "schema_version": 1, "prompt_id": "9b1c…", "client_id": "fe2a…", - "validation_warnings": null, + "validation_warnings": [], "nodes": [ {"node_id": "1", "class_type": "GeminiNanoBanana2", "title": "Nano Banana 2"}, {"node_id": "2", "class_type": "SaveImage", "title": "Save Image"} @@ -142,7 +142,7 @@ handle, which can be used to correlate against `/history/{prompt_id}`. | `schema_version` | int | `1` | | `prompt_id` | str | Server-assigned prompt UUID | | `client_id` | str | Client-generated UUID (sent with `/prompt`) | -| `validation_warnings` | dict \| null | Non-empty when ComfyUI accepted the prompt despite partial validation failures; same shape as `validation_error.node_errors` (see below). `null` in the common case. | +| `validation_warnings` | array of dict | List of per-node validation issues that ComfyUI reported alongside a successful queue (some output chains validated, others didn't). Same record shape as `validation_error.node_errors` (see below). Empty (`[]`) in the common case. | | `nodes` | array of dict | Manifest of every node in the submitted (post-conversion) workflow. Each entry has `node_id` (str), `class_type` (str), and `title` (str — `_meta.title` if present, else `class_type`). Lets piped consumers (who don't have the workflow file at hand) render a per-node UI immediately without waiting for `completed`. | The `validation_warnings` field exists specifically for the case where @@ -423,7 +423,7 @@ removed without a schema version bump. | `conversion_crash` | UI→API converter raised an unexpected exception | `exception_type` (str) | | `object_info_unavailable` | `/object_info` returned an HTTP error (needed for UI→API conversion) | `status_code` (int), `body` (str) | | `connection_error` | ComfyUI server not reachable (URLError / pre-queue socket.timeout, including on `/object_info`)| — | -| `validation_error` | Server returned HTTP 400 with `node_errors` | `node_errors` (dict; passthrough; see below) | +| `validation_error` | Server returned HTTP 400 with `node_errors` | `node_errors` (array of dict; see below) | | `client_error` | Server returned an HTTP 4xx response (not validation) | `status_code` (int, 4xx), `body` (str) | | `server_error` | Server returned an HTTP 5xx response | `status_code` (int, 5xx), `body` (str) | | `invalid_response` | Server returned HTTP 2xx but body was unparseable or lacked `prompt_id` | `status_code` (int, 2xx), `body` (str) | @@ -459,12 +459,14 @@ characters (the JSON wire-format `\n` escapes are decoded). ### `validation_error.node_errors` shape The same shape is used for `queued.validation_warnings`. The value is -the raw dict from ComfyUI keyed by node ID. Keys are the same string -node IDs that appear as `node_id` in `node_*` events. Example shape: +an array of self-contained records, one per affected node. Each record +carries `node_id` (str — same identifier as appears in `node_*` events) +plus the per-node fields ComfyUI emits. Example shape: ```json -"node_errors": { - "1": { +"node_errors": [ + { + "node_id": "1", "errors": [ { "type": "value_not_in_list", @@ -479,16 +481,20 @@ node IDs that appear as `node_id` in `node_*` events. Example shape: "dependent_outputs": ["2"], "class_type": "GeminiNanoBanana2" } -} +] ``` -The inner structure is defined by ComfyUI's `validate_prompt()` in -[`server.py`](https://github.com/comfyanonymous/ComfyUI/blob/master/server.py) +The inner per-node fields are defined by ComfyUI's `validate_prompt()` +in [`server.py`](https://github.com/comfyanonymous/ComfyUI/blob/master/server.py) and may evolve with ComfyUI versions. **Agents should ignore unknown fields.** The CLI guarantees only: -- the outer value is a dict keyed by node ID, and -- under current ComfyUI versions, each node entry carries `errors`, - `dependent_outputs`, and `class_type`. +- the outer value is an array of dicts, each carrying a `node_id` (str), and +- under current ComfyUI versions, each record additionally carries + `errors`, `dependent_outputs`, and `class_type`. + +The record order matches ComfyUI's response order and is not guaranteed +to be sorted; consumers that need a specific order should sort +themselves. ## Process-level termination @@ -519,7 +525,7 @@ trailing entries. ``` {"event":"converted","schema_version":1,"node_count":2} -{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":1,"max":4} {"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":4,"max":4} @@ -538,7 +544,7 @@ server doesn't send an `executed` ws message for it. ### `--no-wait` (API-format input) ``` -{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} ``` Exit code: `0`. The agent is responsible for polling @@ -556,7 +562,7 @@ Exit code: `1`. ``` {"event":"converted","schema_version":1,"node_count":2} -{"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Workflow failed validation","node_errors":{"1":{"errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}}}} +{"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Workflow failed validation","node_errors":[{"node_id":"1","errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}]}} ``` Exit code: `1`. @@ -564,7 +570,7 @@ Exit code: `1`. ### Failure: node raised during execution ``` -{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":2.1,"error":{"kind":"execution_error","message":"API key invalid","node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","exception_type":"RuntimeError","traceback":" File \"/path/to/node.py\", line 42, in execute\n raise RuntimeError(\"API key invalid\")\n"}} ``` @@ -574,7 +580,7 @@ Exit code: `1`. ### Failure: websocket timeout ``` -{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":30.0,"error":{"kind":"timeout","message":"WebSocket timed out after 30s waiting for server response","timeout_seconds":30.0}} ``` @@ -584,7 +590,7 @@ Exit code: `1`. ### Failure: workflow interrupted ``` -{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":null,"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} +{"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":3.2,"error":{"kind":"interrupted","message":"Workflow execution was interrupted"}} ``` diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index cf556267..9776b4be 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -125,23 +125,23 @@ def test_every_event_has_schema_version_1(self, capsys, simple_workflow): event = json.loads(line) assert event.get("schema_version") == 1, f"Missing schema_version on: {line}" - def test_queued_includes_validation_warnings_null(self, capsys): + def test_queued_includes_validation_warnings_empty(self, capsys): e = JsonEmitter(json_mode=True) e.set_client_id("c") - e.emit_queued("p", None) + e.emit_queued("p", []) out, _ = capsys.readouterr() event = json.loads(out.strip()) assert event["event"] == "queued" - assert event["validation_warnings"] is None + assert event["validation_warnings"] == [] assert event["prompt_id"] == "p" assert event["client_id"] == "c" # nodes manifest is always present (empty when no workflow set) assert event["nodes"] == [] - def test_queued_includes_validation_warnings_dict(self, capsys): + def test_queued_includes_validation_warnings_list(self, capsys): e = JsonEmitter(json_mode=True) e.set_client_id("c") - warnings = {"5": {"errors": [{"type": "x", "message": "y"}]}} + warnings = [{"node_id": "5", "errors": [{"type": "x", "message": "y"}]}] e.emit_queued("p", warnings) out, _ = capsys.readouterr() event = json.loads(out.strip()) @@ -363,7 +363,7 @@ def test_no_wait_emits_queued_only(self, workflow_file, capsys): assert len(events) == 1 assert events[0]["event"] == "queued" assert events[0]["prompt_id"] == "p123" - assert events[0]["validation_warnings"] is None + assert events[0]["validation_warnings"] == [] def test_completed_event_after_success(self, workflow_file, capsys): """Mocked WS flow → expect queued + node_* + completed.""" @@ -423,7 +423,9 @@ def test_400_with_node_errors_routes_to_validation_error(self, workflow_file, ca events = self._setup_and_run(workflow_file, None, capsys, status=400, body=body) terminal = events[-1] assert terminal["error"]["kind"] == "validation_error" - assert "1" in terminal["error"]["node_errors"] + node_errors = terminal["error"]["node_errors"] + assert isinstance(node_errors, list) + assert any(rec["node_id"] == "1" for rec in node_errors) def test_401_routes_to_client_error(self, workflow_file, capsys): events = self._setup_and_run(workflow_file, None, capsys, status=401, body=b"unauthorized") @@ -506,8 +508,12 @@ def test_validation_warnings_on_200_with_partial_node_errors(self, workflow_file ] events = _run_execute_capture(workflow_file, capsys) queued = next(e for e in events if e["event"] == "queued") - assert queued["validation_warnings"] is not None - assert "3" in queued["validation_warnings"] + warnings = queued["validation_warnings"] + assert isinstance(warnings, list) + assert any(rec["node_id"] == "3" for rec in warnings) + rec = next(rec for rec in warnings if rec["node_id"] == "3") + assert rec["class_type"] == "X" + assert rec["errors"][0]["message"] == "skipped" class TestWebSocketEvents: From 1cf0cfcb88839e7425aee248dc0d9fc11e86c793 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 14:48:26 +0000 Subject: [PATCH 04/32] docs(run): surface UI-workflow support in --help and json-output.md `comfy run` accepts both ComfyUI API format and exported UI format JSON, but the help text and the --json contract doc both read as if API format were the only input. An agent reading `comfy run --help` had no reason to believe UI exports would work and could waste effort converting them upstream. Update: - the command-level help, --workflow help, and --json help to mention both formats and the client-side conversion; - the json-output.md Overview with a dedicated "Workflow input format" paragraph that points at the existing `converted` event; - the user-facing `workflow_format_invalid` message to name both expected formats instead of just "API workflow JSON". Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 23 ++++++++++++++++++++--- comfy_cli/command/run.py | 7 +++++-- docs/json-output.md | 9 +++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 1c486bb5..1078ce91 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -423,10 +423,25 @@ def update( rprint(f"[yellow]Failed to update node id cache: {e}[/yellow]") -@app.command(help="Run API workflow file using the ComfyUI launched by `comfy launch --background`") +@app.command( + help=( + "Run a workflow on the ComfyUI launched by `comfy launch --background`. " + "Accepts both ComfyUI API format and exported UI workflow JSON; " + "UI workflows are converted to API format client-side via /object_info." + ) +) @tracking.track_command() def run( - workflow: Annotated[str, typer.Option(help="Path to the workflow API json file.")], + workflow: Annotated[ + str, + typer.Option( + help=( + "Path to the workflow JSON file. Both ComfyUI API format and " + "exported UI format are accepted; UI workflows are converted " + "to API format client-side." + ) + ), + ], wait: Annotated[ bool, typer.Option(help="If the command should wait until execution completes."), @@ -476,7 +491,9 @@ def run( "Emit NDJSON events to stdout instead of human-readable output. " "One JSON object per line, terminated by \\n. See docs/json-output.md " "for the event reference and stability contract. In this mode, " - "--verbose has no effect and Rich progress is suppressed." + "--verbose has no effect and Rich progress is suppressed. " + "Workflow input accepts both API and UI format JSON (UI input " + "triggers a `converted` event before `queued`)." ), ), ] = False, diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 9bedd5f7..d3d90f55 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -414,10 +414,13 @@ def execute( if json_mode: emitter.emit_failed( "workflow_format_invalid", - "Workflow file does not appear to be an API workflow JSON", + "Workflow file is neither a ComfyUI API workflow nor an exported UI workflow", ) else: - pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]") + pprint( + "[bold red]Specified workflow is neither a ComfyUI API workflow " + "nor an exported UI workflow[/bold red]" + ) raise typer.Exit(code=1) workflow = validated emitter.set_workflow(workflow) diff --git a/docs/json-output.md b/docs/json-output.md index 7d8e5f15..9f84464f 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -30,6 +30,15 @@ machine-readable mode: In `--json` mode, `--verbose` has no effect: agents receive the full event stream regardless. +**Workflow input format.** `--workflow` accepts both the ComfyUI **API +format** (the canonical `{node_id: {class_type, inputs, ...}}` graph +produced by "Save (API Format)") and the **exported UI format** (the +`{nodes: [...], links: [...]}` shape produced by "Save"). UI workflows +are converted to API format client-side via `/object_info` before +queuing; conversion is signalled by a [`converted`](#converted) event +emitted before [`queued`](#queued). API-format input does not produce a +`converted` event. + All duration fields in this contract are floats representing seconds. Numeric count fields (e.g., `node_progress.value` / `max`) are JSON `number` and may be int or float depending on the underlying node. From 1cc19bdd5a7b820a61090d2a2647a63e3ab57c93 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 15:28:33 +0000 Subject: [PATCH 05/32] feat(run): broaden local_path coverage with locality check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that together make `local_path` populated more often when the agent is on the same machine, while guaranteeing it stays null when it shouldn't: * `is_local_host` recognises `127.0.0.1`, `localhost`, `::1`, `[::1]`, and `0.0.0.0` (case-insensitive, string-match only — no DNS). * `_resolve_run_endpoint` no longer punishes overrides that match the recorded background. Passing `--host 127.0.0.1` (or `localhost`, via loopback aliasing) or `--port ` that matches the background still produces `local_paths=True`. Mismatched overrides still disqualify, since we don't know which workspace the other endpoint serves. * `_build_output_object` and `format_image_path` both re-check `is_local_host(self.host)` before filling `local_path`, so a future Cloud code path that accidentally inherits `local_paths=True` can never surface a phantom filesystem path. Update `docs/json-output.md` Local paths section to spell out the loopback aliasing, the case-insensitive match, the workspace-lookup prerequisite, and to drop the "loopback" misnomer (the set includes `0.0.0.0`, which is a wildcard). Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 39 +++++-- comfy_cli/command/run.py | 17 ++- docs/json-output.md | 37 ++++-- tests/comfy_cli/command/test_run.py | 140 +++++++++++++++++++++++ tests/comfy_cli/command/test_run_json.py | 36 ++++++ 5 files changed, 246 insertions(+), 23 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 1078ce91..c02d6f41 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -423,6 +423,35 @@ def update( rprint(f"[yellow]Failed to update node id cache: {e}[/yellow]") +def _hosts_equivalent(a, b) -> bool: + """True iff `a` and `b` name the same endpoint after loopback aliasing + (e.g., `localhost` and `127.0.0.1`).""" + if a == b: + return True + return run_inner.is_local_host(a) and run_inner.is_local_host(b) + + +def _resolve_run_endpoint(host, port, background): + """Combine user-supplied --host/--port with the endpoint recorded by + `comfy launch --background`, returning (host, port, local_paths). + + `local_paths` is True only when the resolved endpoint matches the + recorded background (with loopback aliasing — `localhost` matches + `127.0.0.1`) AND the host is a known loopback. An override that + diverges (different host or port) disqualifies local paths, because + we don't know which workspace the other endpoint serves. + """ + if not background: + return host, port, False + bg_host, bg_port = background[0], background[1] + host_matches = host is None or _hosts_equivalent(host, bg_host) + port_matches = port is None or port == bg_port + effective_host = host or bg_host + effective_port = port or bg_port + local_paths = host_matches and port_matches and run_inner.is_local_host(effective_host) + return effective_host, effective_port, local_paths + + @app.command( help=( "Run a workflow on the ComfyUI launched by `comfy launch --background`. " @@ -509,15 +538,7 @@ def run( if not port and len(s) == 2: port = int(s[1]) - local_paths = False - if config.background: - if not host: - host = config.background[0] - local_paths = True - if port: - local_paths = False - else: - port = config.background[1] + host, port, local_paths = _resolve_run_endpoint(host, port, config.background) if not host: host = "127.0.0.1" diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index d3d90f55..534d5f64 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -23,6 +23,19 @@ SCHEMA_VERSION = 1 +_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0"}) + + +def is_local_host(host) -> bool: + """True iff `host` points at this machine via a well-known loopback + or wildcard address. String-match only — no DNS resolution, since + we'd rather under-report locality than falsely claim it on a slow + or hostile resolver.""" + if not isinstance(host, str): + return False + return host.lower() in _LOCAL_HOSTS + + def _node_errors_to_list(node_errors) -> list[dict]: """Transform ComfyUI's dict-keyed `node_errors` payload into a list of self-contained records. Each record carries `node_id` as a field, so agents can iterate the result @@ -759,7 +772,7 @@ def format_image_path(self, img): subfolder = img.get("subfolder") or "" output_type = img.get("type") or "output" - if self.local_paths: + if self.local_paths and is_local_host(self.host): display_name = os.path.join(subfolder, filename) if subfolder else filename return os.path.join(workspace_manager.get_workspace_path()[0], output_type, display_name) @@ -776,7 +789,7 @@ def _build_output_object(self, node_id, category, item) -> dict: url = f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" local_path = None - if self.local_paths: + if self.local_paths and is_local_host(self.host): try: ws_path = workspace_manager.get_workspace_path()[0] except Exception: diff --git a/docs/json-output.md b/docs/json-output.md index 9f84464f..26ed2d9c 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -385,18 +385,31 @@ cannot be assigned without a `client_id`). ### Local paths `local_path` is non-null only when **all** of: -- `comfy launch --background` recorded a workspace path the CLI knows about, and -- the user did not override `--host` or `--port`. - -If the user passes `--host remote.example.com` or talks to a port the CLI -didn't launch, `local_path` is `null` — the file likely exists, but not at a -path the CLI can name with confidence. The `url` field remains valid and -should be used to fetch the bytes. - -This is intentional: a non-null `local_path` is a stronger promise than -"the file is somewhere accessible." Agents that need the bytes can -unconditionally fetch `url`; agents that prefer direct filesystem access -can branch on `local_path is not null`. +- `comfy launch --background` registered a running background instance, and +- the resolved endpoint (after applying any `--host` / `--port` + overrides) matches that recorded background entry, with the loopback + aliases `127.0.0.1` and `localhost` (and `::1` / `[::1]`) treated as + equivalent for the match, and +- the resolved host is one of the known same-machine addresses + (case-insensitive): `127.0.0.1`, `localhost`, `::1`, `[::1]`, or + `0.0.0.0`, and +- the CLI can resolve the running workspace's filesystem path (the + workspace lookup did not fail). + +Equivalently: passing `--host 127.0.0.1`, `--host localhost`, or +`--port ` that match the recorded background is OK — +`local_path` is still filled in. Passing a `--host` or `--port` that +diverges from the background disqualifies `local_path`, because that +other endpoint may be serving a different workspace the CLI can't name. +Pointing at a non-same-machine host (`--host remote.example.com`, cloud +endpoints, etc.) also yields `null`, since the file lives on a machine +the CLI can't reach by path. + +The `url` field is always populated and is the only universally-safe +way to fetch the bytes. A non-null `local_path` is a stronger promise +("this exact path on this filesystem"); agents that prefer direct +filesystem access can branch on `local_path is not null` for the bytes +and fall through to `url` otherwise. ## Error object diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index 226100cf..c5db1a0b 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -587,3 +587,143 @@ def test_ui_workflow_exits_when_conversion_yields_nothing(self): MockExec.assert_not_called() finally: os.unlink(path) + + +class TestIsLocalHost: + @pytest.mark.parametrize( + "host", + ["127.0.0.1", "localhost", "LocalHost", "LOCALHOST", "::1", "[::1]", "0.0.0.0"], + ) + def test_loopback_hosts_are_local(self, host): + from comfy_cli.command.run import is_local_host + + assert is_local_host(host) is True + + @pytest.mark.parametrize( + "host", + ["192.168.1.5", "remote.example.com", "api.comfy.org", "10.0.0.1", "::ffff:192.168.1.5", ""], + ) + def test_non_loopback_hosts_are_not_local(self, host): + from comfy_cli.command.run import is_local_host + + assert is_local_host(host) is False + + @pytest.mark.parametrize("host", [None, 123, ["127.0.0.1"], object()]) + def test_non_string_inputs_return_false(self, host): + from comfy_cli.command.run import is_local_host + + assert is_local_host(host) is False + + +class TestResolveRunEndpoint: + """Locality decision in cmdline._resolve_run_endpoint covers six cases: + no background, exact match, host override matches, port override matches, + both override and match, divergent override, non-local background.""" + + def _resolve(self, host, port, background): + from comfy_cli.cmdline import _resolve_run_endpoint + + return _resolve_run_endpoint(host, port, background) + + def test_no_background_means_no_local_paths(self): + h, p, lp = self._resolve(None, None, None) + assert (h, p, lp) == (None, None, False) + + def test_default_args_with_local_background_yields_local_paths(self): + h, p, lp = self._resolve(None, None, ("127.0.0.1", 8188, 1234)) + assert (h, p, lp) == ("127.0.0.1", 8188, True) + + def test_explicit_host_matching_background_still_local(self): + h, p, lp = self._resolve("127.0.0.1", None, ("127.0.0.1", 8188, 1234)) + assert (h, p, lp) == ("127.0.0.1", 8188, True) + + def test_explicit_port_matching_background_still_local(self): + h, p, lp = self._resolve(None, 8188, ("127.0.0.1", 8188, 1234)) + assert (h, p, lp) == ("127.0.0.1", 8188, True) + + def test_explicit_host_and_port_matching_background_still_local(self): + h, p, lp = self._resolve("127.0.0.1", 8188, ("127.0.0.1", 8188, 1234)) + assert (h, p, lp) == ("127.0.0.1", 8188, True) + + def test_divergent_host_disables_local_paths(self): + h, p, lp = self._resolve("remote.example.com", None, ("127.0.0.1", 8188, 1234)) + assert (h, p, lp) == ("remote.example.com", 8188, False) + + def test_divergent_port_disables_local_paths(self): + h, p, lp = self._resolve(None, 9999, ("127.0.0.1", 8188, 1234)) + assert (h, p, lp) == ("127.0.0.1", 9999, False) + + def test_non_local_background_yields_no_local_paths_even_on_match(self): + # Hypothetical: --background recorded a non-loopback host (unusual, + # but possible if launched with --listen=public.ip). We refuse to + # mark it local since the address class isn't safely on this box. + h, p, lp = self._resolve(None, None, ("192.168.1.5", 8188, 1234)) + assert (h, p, lp) == ("192.168.1.5", 8188, False) + + @pytest.mark.parametrize( + "user_host,bg_host", + [ + ("localhost", "127.0.0.1"), + ("127.0.0.1", "localhost"), + ("LOCALHOST", "127.0.0.1"), + ("::1", "127.0.0.1"), + ("[::1]", "::1"), + ], + ) + def test_loopback_aliases_match_background(self, user_host, bg_host): + h, p, lp = self._resolve(user_host, None, (bg_host, 8188, 1234)) + assert lp is True + assert h == user_host + + def test_non_loopback_user_host_does_not_alias_to_loopback_bg(self): + h, p, lp = self._resolve("192.168.1.5", None, ("127.0.0.1", 8188, 1234)) + assert lp is False + + +class TestRunCommandWiring: + """End-to-end: --host / --port passed to `comfy run` reach + WorkflowExecution(local_paths=...) via the cmdline → run_inner.execute + seam. Regression guard for the helper-to-execute wiring.""" + + def _invoke_run(self, host_arg, port_arg, background): + from typer.testing import CliRunner + + from comfy_cli.cmdline import app + from comfy_cli.config_manager import ConfigManager + + runner = CliRunner() + cfg = ConfigManager() + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"1": {"class_type": "X", "inputs": {}}}, f) + workflow_path = f.name + try: + with ( + patch.object(cfg, "background", background), + patch("comfy_cli.cmdline.run_inner.execute") as mock_execute, + ): + args = ["run", "--workflow", workflow_path] + if host_arg is not None: + args.extend(["--host", host_arg]) + if port_arg is not None: + args.extend(["--port", str(port_arg)]) + result = runner.invoke(app, args) + assert result.exit_code == 0, result.output + return mock_execute.call_args + finally: + os.unlink(workflow_path) + + def test_explicit_local_host_matching_background_sets_local_paths_true(self): + call = self._invoke_run("127.0.0.1", None, ("127.0.0.1", 8188, 1234)) + assert call.args[5] is True + + def test_explicit_remote_host_sets_local_paths_false(self): + call = self._invoke_run("api.comfy.org", 443, ("127.0.0.1", 8188, 1234)) + assert call.args[5] is False + + def test_loopback_alias_against_background_sets_local_paths_true(self): + call = self._invoke_run("localhost", None, ("127.0.0.1", 8188, 1234)) + assert call.args[5] is True + + def test_no_background_no_overrides_sets_local_paths_false(self): + call = self._invoke_run(None, None, None) + assert call.args[5] is False diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 9776b4be..f7bcdf80 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -1115,6 +1115,42 @@ def test_local_path_null_when_workspace_unavailable(self, capsys): ev = json.loads(capsys.readouterr().out.strip()) assert ev["outputs"][0]["local_path"] is None + def test_local_path_null_when_host_not_local_despite_local_paths_true(self, capsys): + """Defense-in-depth: local_paths=True but host is non-loopback → local_path null. + + Guards against the case where Cloud (or any remote-host wiring) + accidentally inherits local_paths=True — we must not surface a + filesystem path the agent can't actually open. + """ + wf = self._make_workflow() + e = JsonEmitter(json_mode=True) + e.set_workflow(wf) + ex = WorkflowExecution( + workflow=wf, + host="api.comfy.org", + port=443, + verbose=False, + progress=None, + local_paths=True, + timeout=30, + emitter=e, + ) + ex.prompt_id = "p" + with patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ): + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], + }, + } + ) + ev = json.loads(capsys.readouterr().out.strip()) + assert ev["outputs"][0]["local_path"] is None + def test_object_info_timeout_routes_to_connection_error(self, capsys): """fetch_object_info(timeout → connection_error). Previously untested.""" ui_wf = { From 717571ea3dbb193e1278b8de81cef8578356b242 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 16:00:40 +0000 Subject: [PATCH 06/32] feat(run): populate local_path whenever the file actually exists on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent feedback: `local_path` was effectively always null in practice because the previous logic required `comfy launch --background` to have been used. Agents launching ComfyUI manually (or against an already-running instance) never saw the field populated even though the file was sitting right there on the same machine. Replace the `--background` prerequisite with an empirical check. We still require the host to be a known same-machine address (Cloud and remote stay null), and we still rely on the workspace manager to resolve a candidate path, but the decisive gate is now whether a regular file actually exists at the computed path. This makes `local_path` an honest promise: when it's non-null, the file is there; when the file isn't, the field is null instead of a phantom path. As a side effect the `local_paths` constructor argument loses its load-bearing role; it remains accepted for API stability but the value is no longer consulted. Also surface the attempted endpoint and an override hint in the pre-flight `connection_error` messages — agents that take defaults silently used to get a bare "cannot connect" without knowing the CLI had picked 127.0.0.1:8188 for them. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 76 +++++++++++++----------- docs/json-output.md | 31 +++++----- tests/comfy_cli/command/test_run_json.py | 64 ++++++++++++++------ 3 files changed, 101 insertions(+), 70 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 534d5f64..3e6423e5 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -290,19 +290,18 @@ def fetch_object_info(host, port, timeout, emitter=None): pprint(f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body_text[:500]}[/bold red]") raise typer.Exit(code=1) from e except urllib.error.URLError as e: + msg = f"Failed to fetch /object_info from {host}:{port}: {e.reason} (override with --host / --port)" if json_mode: - emitter.emit_failed("connection_error", f"Failed to fetch /object_info: {e.reason}") + emitter.emit_failed("connection_error", msg) else: - pprint(f"[bold red]Failed to fetch /object_info: {e.reason}[/bold red]") + pprint(f"[bold red]{msg}[/bold red]") raise typer.Exit(code=1) from e except TimeoutError as e: + msg = f"Failed to fetch /object_info from {host}:{port}: timed out after {timeout}s (override with --host / --port)" if json_mode: - emitter.emit_failed( - "connection_error", - f"Failed to fetch /object_info: timed out after {timeout}s", - ) + emitter.emit_failed("connection_error", msg) else: - pprint(f"[bold red]Failed to fetch /object_info: timed out after {timeout}s[/bold red]") + pprint(f"[bold red]{msg}[/bold red]") raise typer.Exit(code=1) from e try: return json.loads(body) @@ -344,13 +343,11 @@ def execute( raise typer.Exit(code=1) if not check_comfy_server_running(port, host): + msg = f"ComfyUI not running at {host}:{port} (override with --host / --port)" if json_mode: - emitter.emit_failed( - "connection_error", - f"ComfyUI not running on specified address ({host}:{port})", - ) + emitter.emit_failed("connection_error", msg) else: - pprint(f"[bold red]ComfyUI not running on specified address ({host}:{port})[/bold red]") + pprint(f"[bold red]{msg}[/bold red]") raise typer.Exit(code=1) try: @@ -589,27 +586,27 @@ def queue(self): raise typer.Exit(code=1) from e except urllib.error.URLError as e: self._stop_progress() + msg = f"Cannot reach server at {self.host}:{self.port}: {e.reason}" if self.emitter.json_mode: - self.emitter.emit_failed( - "connection_error", - f"Cannot reach server: {e.reason}", - ) + self.emitter.emit_failed("connection_error", msg) else: - pprint(f"[bold red]Cannot reach server: {e.reason}[/bold red]") + pprint(f"[bold red]{msg}[/bold red]") raise typer.Exit(code=1) from e except TimeoutError as e: self._stop_progress() + msg = f"Connection to {self.host}:{self.port} timed out: {e}" if self.emitter.json_mode: - self.emitter.emit_failed("connection_error", f"Connection timed out: {e}") + self.emitter.emit_failed("connection_error", msg) else: - pprint(f"[bold red]Connection timed out: {e}[/bold red]") + pprint(f"[bold red]{msg}[/bold red]") raise typer.Exit(code=1) from e except OSError as e: self._stop_progress() + msg = f"Network error contacting {self.host}:{self.port}: {e}" if self.emitter.json_mode: - self.emitter.emit_failed("connection_error", f"Network error contacting server: {e}") + self.emitter.emit_failed("connection_error", msg) else: - pprint(f"[bold red]Network error contacting server: {e}[/bold red]") + pprint(f"[bold red]{msg}[/bold red]") raise typer.Exit(code=1) from e try: @@ -772,13 +769,31 @@ def format_image_path(self, img): subfolder = img.get("subfolder") or "" output_type = img.get("type") or "output" - if self.local_paths and is_local_host(self.host): - display_name = os.path.join(subfolder, filename) if subfolder else filename - return os.path.join(workspace_manager.get_workspace_path()[0], output_type, display_name) + candidate = self._candidate_local_path(filename, subfolder, output_type) + if candidate is not None: + return candidate url_params = {"filename": filename, "subfolder": subfolder, "type": output_type} return f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" + def _candidate_local_path(self, filename: str, subfolder: str, file_type: str) -> str | None: + """Best-effort `local_path` for an output. Returns the path only when + the host is a known same-machine address, the CLI can resolve a + workspace, and the file actually exists where we expect it. The + existence check guards against multi-install setups where the + running ComfyUI uses a different workspace than the CLI resolves.""" + if not is_local_host(self.host): + return None + try: + ws_path = workspace_manager.get_workspace_path()[0] + except Exception: + return None + if not ws_path: + return None + parts = [subfolder, filename] if subfolder else [filename] + candidate = os.path.join(ws_path, file_type, *parts) + return candidate if os.path.isfile(candidate) else None + def _build_output_object(self, node_id, category, item) -> dict: """Construct a structured Output dict for the JSON contract.""" filename = item["filename"] @@ -788,18 +803,7 @@ def _build_output_object(self, node_id, category, item) -> dict: url_params = {"filename": filename, "subfolder": subfolder, "type": file_type} url = f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" - local_path = None - if self.local_paths and is_local_host(self.host): - try: - ws_path = workspace_manager.get_workspace_path()[0] - except Exception: - ws_path = None - if ws_path: - parts = [] - if subfolder: - parts.append(subfolder) - parts.append(filename) - local_path = os.path.join(ws_path, file_type, *parts) + local_path = self._candidate_local_path(filename, subfolder, file_type) return { "category": category, diff --git a/docs/json-output.md b/docs/json-output.md index 26ed2d9c..e0000a84 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -385,25 +385,24 @@ cannot be assigned without a `client_id`). ### Local paths `local_path` is non-null only when **all** of: -- `comfy launch --background` registered a running background instance, and -- the resolved endpoint (after applying any `--host` / `--port` - overrides) matches that recorded background entry, with the loopback - aliases `127.0.0.1` and `localhost` (and `::1` / `[::1]`) treated as - equivalent for the match, and - the resolved host is one of the known same-machine addresses (case-insensitive): `127.0.0.1`, `localhost`, `::1`, `[::1]`, or `0.0.0.0`, and -- the CLI can resolve the running workspace's filesystem path (the - workspace lookup did not fail). - -Equivalently: passing `--host 127.0.0.1`, `--host localhost`, or -`--port ` that match the recorded background is OK — -`local_path` is still filled in. Passing a `--host` or `--port` that -diverges from the background disqualifies `local_path`, because that -other endpoint may be serving a different workspace the CLI can't name. -Pointing at a non-same-machine host (`--host remote.example.com`, cloud -endpoints, etc.) also yields `null`, since the file lives on a machine -the CLI can't reach by path. +- the CLI can resolve a workspace filesystem path (via `comfy launch`, + `--workspace`, the most-recent workspace, or the default), and +- a regular file actually exists at the computed path + (`///`) at emit time. + +The existence check is what makes `local_path` a strong promise rather +than a structural guess. If the running ComfyUI is using a different +workspace than the one the CLI resolves (e.g., two installs on the +same machine), the computed path won't resolve to a real file and the +field is `null`. If you cleared the output dir between runs, same +thing — `null` rather than a dangling path. + +Pointing at a non-same-machine host (`--host remote.example.com`, +cloud endpoints, etc.) always yields `null`, since the file lives on a +machine the CLI can't reach by path. The `url` field is always populated and is the only universally-safe way to fetch the bytes. A non-null `local_path` is a stronger promise diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index f7bcdf80..412fed34 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -1074,14 +1074,17 @@ def _make_exec(self, workflow, local_paths=False): emitter=e, ) - def test_local_path_populated_when_local_paths_true(self, capsys): - """Positive case: local_paths=True + valid workspace path → local_path filled.""" + def test_local_path_populated_when_file_exists_at_workspace(self, capsys): + """Positive case: local host + workspace resolvable + file present on disk.""" wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=True) + ex = self._make_exec(wf, local_paths=False) ex.prompt_id = "p" - with patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), + with ( + patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ), + patch("comfy_cli.command.run.os.path.isfile", return_value=True), ): ex.on_executed( { @@ -1095,10 +1098,35 @@ def test_local_path_populated_when_local_paths_true(self, capsys): out0 = ev["outputs"][0] assert out0["local_path"] == "/fake/workspace/output/sf/out.png" + def test_local_path_null_when_file_missing_on_disk(self, capsys): + """Workspace resolvable but file doesn't exist at the computed path → null + (guards against multi-install setups where the running ComfyUI uses a + different workspace than the CLI resolves).""" + wf = self._make_workflow() + ex = self._make_exec(wf, local_paths=False) + ex.prompt_id = "p" + with ( + patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ), + patch("comfy_cli.command.run.os.path.isfile", return_value=False), + ): + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], + }, + } + ) + ev = json.loads(capsys.readouterr().out.strip()) + assert ev["outputs"][0]["local_path"] is None + def test_local_path_null_when_workspace_unavailable(self, capsys): - """local_paths=True but workspace_manager raises → local_path is null (defensive).""" + """workspace_manager raises → local_path is null (defensive).""" wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=True) + ex = self._make_exec(wf, local_paths=False) ex.prompt_id = "p" with patch( "comfy_cli.command.run.workspace_manager.get_workspace_path", @@ -1115,13 +1143,10 @@ def test_local_path_null_when_workspace_unavailable(self, capsys): ev = json.loads(capsys.readouterr().out.strip()) assert ev["outputs"][0]["local_path"] is None - def test_local_path_null_when_host_not_local_despite_local_paths_true(self, capsys): - """Defense-in-depth: local_paths=True but host is non-loopback → local_path null. - - Guards against the case where Cloud (or any remote-host wiring) - accidentally inherits local_paths=True — we must not surface a - filesystem path the agent can't actually open. - """ + def test_local_path_null_when_host_not_local(self, capsys): + """Defense for Cloud: non-loopback host yields null even when the + workspace lookup would succeed and a file with the same name exists + on the local box.""" wf = self._make_workflow() e = JsonEmitter(json_mode=True) e.set_workflow(wf) @@ -1136,9 +1161,12 @@ def test_local_path_null_when_host_not_local_despite_local_paths_true(self, caps emitter=e, ) ex.prompt_id = "p" - with patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), + with ( + patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ), + patch("comfy_cli.command.run.os.path.isfile", return_value=True), ): ex.on_executed( { From fe26e9ec3c965b7b51ca895282393f4df19f94bd Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 16:10:59 +0000 Subject: [PATCH 07/32] fix(run): harden local_path and substitute 0.0.0.0 wildcard host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to "populate local_path whenever the file actually exists", addressing three review findings: * workspace_manager.get_workspace_path() has side effects on the stale-recent code path — it prints a warning and writes the config. In JSON mode the rich-formatted warning would land mid-stream and corrupt NDJSON, and the config rewrite would happen once per output event. Cache the resolved path on WorkflowExecution so the call happens at most once per run. * Server-controlled subfolder / filename could escape the workspace via `..` segments or a leading `/`, because os.path.join collapses or rebases on those. Confine the candidate path to // before the existence check. * `0.0.0.0` is a wildcard bind, not a connect address. Linux quietly resolves it to a loopback, but macOS and Windows can't reach http://0.0.0.0:port. Substitute the canonical loopback at execute() entry so server probe, /prompt POST, and emitted /view URLs are portable. Also tighten the doc: the local_path schema row no longer claims "can't verify local access" (the check is now an on-disk existence test), and a new lifetime caveat warns that `type == "temp"` paths are read-immediately-only since ComfyUI may clean them up. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 40 +++++++++--- docs/json-output.md | 9 ++- tests/comfy_cli/command/test_run.py | 30 +++++++++ tests/comfy_cli/command/test_run_json.py | 80 ++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 9 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 3e6423e5..3e340bc2 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -329,6 +329,13 @@ def execute( api_key: str | None = None, json_mode: bool = False, ): + # `0.0.0.0` is a wildcard bind, not a connect address. macOS / Windows + # clients can't reach it; on Linux it happens to resolve to a loopback. + # Substitute the canonical loopback so every downstream use (server + # probe, /prompt POST, emitted /view URLs) is portable. + if host == "0.0.0.0": + host = "127.0.0.1" + emitter = JsonEmitter(json_mode=json_mode) workflow_name = os.path.abspath(os.path.expanduser(workflow)) @@ -560,6 +567,11 @@ def __init__( # Default to a no-op emitter so internal call sites don't need to # branch on whether json mode is active. self.emitter = emitter if emitter is not None else JsonEmitter(json_mode=False) + # Lazy-resolved once per run; workspace_manager.get_workspace_path() + # can have side effects (printing warnings, writing config) on the + # stale-recent path, so we call it at most once. + self._cached_workspace_path: str | None = None + self._workspace_resolved = False def connect(self): self.ws = WebSocket() @@ -776,22 +788,34 @@ def format_image_path(self, img): url_params = {"filename": filename, "subfolder": subfolder, "type": output_type} return f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" + def _resolve_workspace_path(self) -> str | None: + if not self._workspace_resolved: + try: + self._cached_workspace_path = workspace_manager.get_workspace_path()[0] + except Exception: + self._cached_workspace_path = None + self._workspace_resolved = True + return self._cached_workspace_path + def _candidate_local_path(self, filename: str, subfolder: str, file_type: str) -> str | None: """Best-effort `local_path` for an output. Returns the path only when the host is a known same-machine address, the CLI can resolve a - workspace, and the file actually exists where we expect it. The - existence check guards against multi-install setups where the - running ComfyUI uses a different workspace than the CLI resolves.""" + workspace, the candidate path is confined to that workspace's + per-type output dir (server-controlled `subfolder`/`filename` + cannot escape), and the file actually exists where we expect it.""" if not is_local_host(self.host): return None - try: - ws_path = workspace_manager.get_workspace_path()[0] - except Exception: - return None + ws_path = self._resolve_workspace_path() if not ws_path: return None parts = [subfolder, filename] if subfolder else [filename] - candidate = os.path.join(ws_path, file_type, *parts) + type_root = os.path.normpath(os.path.join(ws_path, file_type)) + candidate = os.path.normpath(os.path.join(type_root, *parts)) + # Confine to //. `os.path.join` resets to absolute + # on a `/`-leading arg and `..` segments collapse under normpath — + # both can hand us a path outside the workspace. + if not (candidate == type_root or candidate.startswith(type_root + os.sep)): + return None return candidate if os.path.isfile(candidate) else None def _build_output_object(self, node_id, category, item) -> dict: diff --git a/docs/json-output.md b/docs/json-output.md index e0000a84..35226d94 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -380,7 +380,7 @@ cannot be assigned without a `client_id`). | `subfolder` | str | Subfolder within the output folder's root (`""` if none) | | `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. | | `url` | str | `http(s)://:/view?...` URL — always present | -| `local_path` | str \| null | Filesystem path on the same machine, or `null` when the CLI can't verify local access (see below) | +| `local_path` | str \| null | Filesystem path on the same machine, or `null` when the CLI can't verify the file exists at that path (see below) | ### Local paths @@ -404,6 +404,13 @@ Pointing at a non-same-machine host (`--host remote.example.com`, cloud endpoints, etc.) always yields `null`, since the file lives on a machine the CLI can't reach by path. +**Lifetime caveat.** `local_path` is verified at emit time. For +`type == "output"` the file is durable until you delete it. For +`type == "temp"` ComfyUI may clean it up on its next launch (and a +few workflow patterns mutate temp files within a single run), so +agents should treat a `temp` `local_path` as read-immediately-only — +don't store it and read it minutes later. + The `url` field is always populated and is the only universally-safe way to fetch the bytes. A non-null `local_path` is a stronger promise ("this exact path on this filesystem"); agents that prefer direct diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index c5db1a0b..071b886a 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -727,3 +727,33 @@ def test_loopback_alias_against_background_sets_local_paths_true(self): def test_no_background_no_overrides_sets_local_paths_false(self): call = self._invoke_run(None, None, None) assert call.args[5] is False + + +class TestWildcardHostSubstitution: + """0.0.0.0 is a wildcard bind that macOS/Windows clients can't connect to; + execute() substitutes it with the canonical loopback so downstream uses + (server probe, /prompt POST, emitted URLs) are portable.""" + + def test_zero_zero_zero_zero_substituted_at_entry(self, workflow_file): + captured = {} + + def fake_check(port, host, *args, **kwargs): + captured["check_host"] = host + return False # short-circuits execute() with a clean exit + + with patch("comfy_cli.command.run.check_comfy_server_running", side_effect=fake_check): + with pytest.raises(typer.Exit): + execute(workflow_file, host="0.0.0.0", port=8188, json_mode=True) + assert captured["check_host"] == "127.0.0.1" + + def test_other_local_hosts_not_substituted(self, workflow_file): + captured = {} + + def fake_check(port, host, *args, **kwargs): + captured["check_host"] = host + return False + + with patch("comfy_cli.command.run.check_comfy_server_running", side_effect=fake_check): + with pytest.raises(typer.Exit): + execute(workflow_file, host="localhost", port=8188, json_mode=True) + assert captured["check_host"] == "localhost" diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 412fed34..520bc0c4 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -1179,6 +1179,86 @@ def test_local_path_null_when_host_not_local(self, capsys): ev = json.loads(capsys.readouterr().out.strip()) assert ev["outputs"][0]["local_path"] is None + def test_workspace_path_resolved_once_per_execution(self, capsys): + """workspace_manager.get_workspace_path can have side effects on the + stale-recent path; we must call it at most once per run, not per + output event.""" + wf = self._make_workflow() + ex = self._make_exec(wf, local_paths=False) + ex.prompt_id = "p" + with ( + patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ) as mock_ws, + patch("comfy_cli.command.run.os.path.isfile", return_value=True), + ): + for i in range(3): + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": f"out{i}.png", "subfolder": "", "type": "output"}], + }, + } + ) + assert mock_ws.call_count == 1 + + @pytest.mark.parametrize( + "subfolder,filename", + [ + ("..", "evil.png"), + ("../../etc", "passwd"), + ("/etc", "passwd"), + ("foo/../..", "escape.png"), + ], + ) + def test_local_path_rejects_subfolder_escape(self, capsys, subfolder, filename): + """Server-controlled subfolder/filename must not produce a local_path + outside //.""" + wf = self._make_workflow() + ex = self._make_exec(wf, local_paths=False) + ex.prompt_id = "p" + with ( + patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ), + patch("comfy_cli.command.run.os.path.isfile", return_value=True), + ): + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": filename, "subfolder": subfolder, "type": "output"}], + }, + } + ) + ev = json.loads(capsys.readouterr().out.strip()) + assert ev["outputs"][0]["local_path"] is None + + def test_local_path_accepts_legitimate_subfolder(self, capsys): + wf = self._make_workflow() + ex = self._make_exec(wf, local_paths=False) + ex.prompt_id = "p" + with ( + patch( + "comfy_cli.command.run.workspace_manager.get_workspace_path", + return_value=("/fake/workspace", "ok"), + ), + patch("comfy_cli.command.run.os.path.isfile", return_value=True), + ): + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "out.png", "subfolder": "my/nested/dir", "type": "output"}], + }, + } + ) + ev = json.loads(capsys.readouterr().out.strip()) + assert ev["outputs"][0]["local_path"] == "/fake/workspace/output/my/nested/dir/out.png" + def test_object_info_timeout_routes_to_connection_error(self, capsys): """fetch_object_info(timeout → connection_error). Previously untested.""" ui_wf = { From a46f62dd1784168ae93c07f35591e86bb7e10633 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 17:00:30 +0000 Subject: [PATCH 08/32] feat(run)!: drop local_path from the JSON output contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent testing kept turning up the same complaint: `local_path` was null on every output, even with ComfyUI running locally, because the heuristic for resolving ComfyUI's output directory (workspace_manager + existence check) doesn't survive real-world deployments — manual launches, alternate install paths, multi-install machines, containers with bind-mounted volumes. The field promised something we couldn't reliably deliver. ComfyUI exposes no API for its output directory, so the heuristic was the best we could do. Make the contract honest: every output object now carries only `url`, and agents always go through it. That's how Replicate, Modal, Horde, Cargo and friends already work, and it's how a future Cloud target will have to work anyway — so agents write one URL-fetch path that's universal instead of two paths that drift. Removed: `is_local_host`, `_LOCAL_HOSTS`, the `local_paths` parameter on `execute()` / `WorkflowExecution`, `_resolve_workspace_path`, `_candidate_local_path`, `_resolve_run_endpoint`, `_hosts_equivalent`, and ~120 LOC of tests pinning their behaviour. Kept: a minimal local-path resolver inside `format_image_path` so plain `comfy run` (non-JSON, human terminal output) still prints a clickable path when ComfyUI is on a loopback and the file resolves. The text-mode helper is contained and doesn't feed the JSON contract. `schema_version` stays at 1 — v1 has not shipped to users yet, so this is field removal during development, not a breaking change. Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 37 +--- comfy_cli/command/run.py | 75 ++------ docs/json-output.md | 63 ++----- tests/comfy_cli/command/test_run.py | 145 +-------------- tests/comfy_cli/command/test_run_json.py | 225 ++--------------------- 5 files changed, 61 insertions(+), 484 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index c02d6f41..02cc81ea 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -423,35 +423,6 @@ def update( rprint(f"[yellow]Failed to update node id cache: {e}[/yellow]") -def _hosts_equivalent(a, b) -> bool: - """True iff `a` and `b` name the same endpoint after loopback aliasing - (e.g., `localhost` and `127.0.0.1`).""" - if a == b: - return True - return run_inner.is_local_host(a) and run_inner.is_local_host(b) - - -def _resolve_run_endpoint(host, port, background): - """Combine user-supplied --host/--port with the endpoint recorded by - `comfy launch --background`, returning (host, port, local_paths). - - `local_paths` is True only when the resolved endpoint matches the - recorded background (with loopback aliasing — `localhost` matches - `127.0.0.1`) AND the host is a known loopback. An override that - diverges (different host or port) disqualifies local paths, because - we don't know which workspace the other endpoint serves. - """ - if not background: - return host, port, False - bg_host, bg_port = background[0], background[1] - host_matches = host is None or _hosts_equivalent(host, bg_host) - port_matches = port is None or port == bg_port - effective_host = host or bg_host - effective_port = port or bg_port - local_paths = host_matches and port_matches and run_inner.is_local_host(effective_host) - return effective_host, effective_port, local_paths - - @app.command( help=( "Run a workflow on the ComfyUI launched by `comfy launch --background`. " @@ -538,7 +509,12 @@ def run( if not port and len(s) == 2: port = int(s[1]) - host, port, local_paths = _resolve_run_endpoint(host, port, config.background) + if config.background: + bg_host, bg_port = config.background[0], config.background[1] + if not host: + host = bg_host + if not port: + port = bg_port if not host: host = "127.0.0.1" @@ -551,7 +527,6 @@ def run( port, wait, verbose, - local_paths, timeout, api_key=api_key, json_mode=json_output, diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 3e340bc2..b8d70dce 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -23,19 +23,6 @@ SCHEMA_VERSION = 1 -_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0"}) - - -def is_local_host(host) -> bool: - """True iff `host` points at this machine via a well-known loopback - or wildcard address. String-match only — no DNS resolution, since - we'd rather under-report locality than falsely claim it on a slow - or hostile resolver.""" - if not isinstance(host, str): - return False - return host.lower() in _LOCAL_HOSTS - - def _node_errors_to_list(node_errors) -> list[dict]: """Transform ComfyUI's dict-keyed `node_errors` payload into a list of self-contained records. Each record carries `node_id` as a field, so agents can iterate the result @@ -324,7 +311,6 @@ def execute( port, wait=True, verbose=False, - local_paths=False, timeout=30, api_key: str | None = None, json_mode: bool = False, @@ -457,7 +443,6 @@ def execute( port, verbose, progress, - local_paths, timeout, api_key=api_key, emitter=emitter, @@ -540,7 +525,6 @@ def __init__( port, verbose, progress, - local_paths, timeout=30, api_key: str | None = None, emitter: JsonEmitter | None = None, @@ -549,7 +533,6 @@ def __init__( self.host = host self.port = port self.verbose = verbose - self.local_paths = local_paths self.client_id = str(uuid.uuid4()) self.outputs: list = [] self.progress = progress @@ -567,11 +550,6 @@ def __init__( # Default to a no-op emitter so internal call sites don't need to # branch on whether json mode is active. self.emitter = emitter if emitter is not None else JsonEmitter(json_mode=False) - # Lazy-resolved once per run; workspace_manager.get_workspace_path() - # can have side effects (printing warnings, writing config) on the - # stale-recent path, so we call it at most once. - self._cached_workspace_path: str | None = None - self._workspace_resolved = False def connect(self): self.ws = WebSocket() @@ -776,48 +754,30 @@ def log_node(self, type, node_id): pprint(f"{type} : {title}") def format_image_path(self, img): - """Build a single (url|local_path) string for the legacy human output.""" + """Build a single (local_path|url) string for the legacy human + output. Prefers a clickable local path when the host is a known + loopback, the workspace resolves, the path stays inside the + workspace's per-type output dir, and the file exists on disk. + Otherwise falls back to a /view URL.""" filename = img["filename"] subfolder = img.get("subfolder") or "" output_type = img.get("type") or "output" - candidate = self._candidate_local_path(filename, subfolder, output_type) - if candidate is not None: - return candidate + if self.host in ("127.0.0.1", "localhost", "::1", "[::1]"): + try: + ws_path = workspace_manager.get_workspace_path()[0] + except Exception: + ws_path = None + if ws_path: + parts = [subfolder, filename] if subfolder else [filename] + type_root = os.path.normpath(os.path.join(ws_path, output_type)) + candidate = os.path.normpath(os.path.join(type_root, *parts)) + if (candidate == type_root or candidate.startswith(type_root + os.sep)) and os.path.isfile(candidate): + return candidate url_params = {"filename": filename, "subfolder": subfolder, "type": output_type} return f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" - def _resolve_workspace_path(self) -> str | None: - if not self._workspace_resolved: - try: - self._cached_workspace_path = workspace_manager.get_workspace_path()[0] - except Exception: - self._cached_workspace_path = None - self._workspace_resolved = True - return self._cached_workspace_path - - def _candidate_local_path(self, filename: str, subfolder: str, file_type: str) -> str | None: - """Best-effort `local_path` for an output. Returns the path only when - the host is a known same-machine address, the CLI can resolve a - workspace, the candidate path is confined to that workspace's - per-type output dir (server-controlled `subfolder`/`filename` - cannot escape), and the file actually exists where we expect it.""" - if not is_local_host(self.host): - return None - ws_path = self._resolve_workspace_path() - if not ws_path: - return None - parts = [subfolder, filename] if subfolder else [filename] - type_root = os.path.normpath(os.path.join(ws_path, file_type)) - candidate = os.path.normpath(os.path.join(type_root, *parts)) - # Confine to //. `os.path.join` resets to absolute - # on a `/`-leading arg and `..` segments collapse under normpath — - # both can hand us a path outside the workspace. - if not (candidate == type_root or candidate.startswith(type_root + os.sep)): - return None - return candidate if os.path.isfile(candidate) else None - def _build_output_object(self, node_id, category, item) -> dict: """Construct a structured Output dict for the JSON contract.""" filename = item["filename"] @@ -827,8 +787,6 @@ def _build_output_object(self, node_id, category, item) -> dict: url_params = {"filename": filename, "subfolder": subfolder, "type": file_type} url = f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" - local_path = self._candidate_local_path(filename, subfolder, file_type) - return { "category": category, "node_id": node_id, @@ -838,7 +796,6 @@ def _build_output_object(self, node_id, category, item) -> dict: "subfolder": subfolder, "type": file_type, "url": url, - "local_path": local_path, } def on_message(self, message): diff --git a/docs/json-output.md b/docs/json-output.md index 35226d94..c1b33d44 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -253,8 +253,7 @@ cached output-bearing node also emits `node_executed` (in addition to "filename": "banana_test_00001_.png", "subfolder": "", "type": "output", - "url": "http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output", - "local_path": "/home/user/comfy/ComfyUI/output/banana_test_00001_.png" + "url": "http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output" } ] } @@ -365,8 +364,7 @@ cannot be assigned without a `client_id`). "filename": "banana_test_00001_.png", "subfolder": "", "type": "output", - "url": "http://127.0.0.1:8188/view?filename=...", - "local_path": "/home/user/comfy/ComfyUI/output/banana_test_00001_.png" + "url": "http://127.0.0.1:8188/view?filename=..." } ``` @@ -379,43 +377,22 @@ cannot be assigned without a `client_id`). | `filename` | str | Raw filename as reported by the server | | `subfolder` | str | Subfolder within the output folder's root (`""` if none) | | `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. | -| `url` | str | `http(s)://:/view?...` URL — always present | -| `local_path` | str \| null | Filesystem path on the same machine, or `null` when the CLI can't verify the file exists at that path (see below) | - -### Local paths - -`local_path` is non-null only when **all** of: -- the resolved host is one of the known same-machine addresses - (case-insensitive): `127.0.0.1`, `localhost`, `::1`, `[::1]`, or - `0.0.0.0`, and -- the CLI can resolve a workspace filesystem path (via `comfy launch`, - `--workspace`, the most-recent workspace, or the default), and -- a regular file actually exists at the computed path - (`///`) at emit time. - -The existence check is what makes `local_path` a strong promise rather -than a structural guess. If the running ComfyUI is using a different -workspace than the one the CLI resolves (e.g., two installs on the -same machine), the computed path won't resolve to a real file and the -field is `null`. If you cleared the output dir between runs, same -thing — `null` rather than a dangling path. - -Pointing at a non-same-machine host (`--host remote.example.com`, -cloud endpoints, etc.) always yields `null`, since the file lives on a -machine the CLI can't reach by path. - -**Lifetime caveat.** `local_path` is verified at emit time. For -`type == "output"` the file is durable until you delete it. For -`type == "temp"` ComfyUI may clean it up on its next launch (and a -few workflow patterns mutate temp files within a single run), so -agents should treat a `temp` `local_path` as read-immediately-only — -don't store it and read it minutes later. - -The `url` field is always populated and is the only universally-safe -way to fetch the bytes. A non-null `local_path` is a stronger promise -("this exact path on this filesystem"); agents that prefer direct -filesystem access can branch on `local_path is not null` for the bytes -and fall through to `url` otherwise. +| `url` | str | `http(s)://:/view?...` URL — always present, fetch this to get the bytes | + +### Fetching output bytes + +The `url` field is the only contractual way to retrieve an output's +bytes. It points at ComfyUI's `/view` endpoint and works whether the +agent is on the same machine as ComfyUI, on a different host, or +talking to Cloud. For the local case, a loopback HTTP fetch from a +ComfyUI on the same box is cheap — the agent's HTTP client reads +through the kernel loopback in the same way it'd read a local file. + +The CLI used to also emit a `local_path` field for the same-machine +case, but the heuristic for resolving ComfyUI's output directory was +unreliable in real setups (manual launches, alternate install paths, +multi-install machines, containers with bind-mounted volumes). Agents +should rely on `url` exclusively. ## Error object @@ -558,8 +535,8 @@ trailing entries. {"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":1,"max":4} {"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":4,"max":4} {"event":"node_executing","schema_version":1,"node_id":"2","class_type":"SaveImage","title":"Save Image"} -{"event":"node_executed","schema_version":1,"node_id":"2","class_type":"SaveImage","title":"Save Image","outputs":[{"category":"images","node_id":"2","class_type":"SaveImage","title":"Save Image","filename":"banana_test_00001_.png","subfolder":"","type":"output","url":"http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output","local_path":"/home/user/comfy/ComfyUI/output/banana_test_00001_.png"}]} -{"event":"completed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":8.342,"outputs":[{"category":"images","node_id":"2","class_type":"SaveImage","title":"Save Image","filename":"banana_test_00001_.png","subfolder":"","type":"output","url":"http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output","local_path":"/home/user/comfy/ComfyUI/output/banana_test_00001_.png"}],"cached_node_ids":[],"executed_node_ids":["1","2"]} +{"event":"node_executed","schema_version":1,"node_id":"2","class_type":"SaveImage","title":"Save Image","outputs":[{"category":"images","node_id":"2","class_type":"SaveImage","title":"Save Image","filename":"banana_test_00001_.png","subfolder":"","type":"output","url":"http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output"}]} +{"event":"completed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":8.342,"outputs":[{"category":"images","node_id":"2","class_type":"SaveImage","title":"Save Image","filename":"banana_test_00001_.png","subfolder":"","type":"output","url":"http://127.0.0.1:8188/view?filename=banana_test_00001_.png&subfolder=&type=output"}],"cached_node_ids":[],"executed_node_ids":["1","2"]} ``` Exit code: `0`. diff --git a/tests/comfy_cli/command/test_run.py b/tests/comfy_cli/command/test_run.py index 071b886a..a09327ad 100644 --- a/tests/comfy_cli/command/test_run.py +++ b/tests/comfy_cli/command/test_run.py @@ -52,7 +52,6 @@ def mock_execution(workflow): port=8188, verbose=False, progress=progress, - local_paths=False, timeout=30, ) @@ -165,7 +164,6 @@ def _make_exec(self, workflow, api_key=None): port=8188, verbose=False, progress=progress, - local_paths=False, timeout=30, api_key=api_key, ) @@ -262,7 +260,6 @@ def test_unknown_node_ids_verbose(self, workflow): port=8188, verbose=True, progress=progress, - local_paths=False, timeout=30, ) execution.prompt_id = prompt_id @@ -310,7 +307,7 @@ def test_collects_image_outputs(self, mock_execution): class TestExecuteErrorHandling: def _run_execute_expect_exit(self, workflow_file, **overrides): - kwargs = dict(host="127.0.0.1", port=8188, wait=True, verbose=False, local_paths=False, timeout=30) + kwargs = dict(host="127.0.0.1", port=8188, wait=True, verbose=False, timeout=30) kwargs.update(overrides) with pytest.raises(typer.Exit) as exc_info: execute(workflow_file, **kwargs) @@ -589,146 +586,6 @@ def test_ui_workflow_exits_when_conversion_yields_nothing(self): os.unlink(path) -class TestIsLocalHost: - @pytest.mark.parametrize( - "host", - ["127.0.0.1", "localhost", "LocalHost", "LOCALHOST", "::1", "[::1]", "0.0.0.0"], - ) - def test_loopback_hosts_are_local(self, host): - from comfy_cli.command.run import is_local_host - - assert is_local_host(host) is True - - @pytest.mark.parametrize( - "host", - ["192.168.1.5", "remote.example.com", "api.comfy.org", "10.0.0.1", "::ffff:192.168.1.5", ""], - ) - def test_non_loopback_hosts_are_not_local(self, host): - from comfy_cli.command.run import is_local_host - - assert is_local_host(host) is False - - @pytest.mark.parametrize("host", [None, 123, ["127.0.0.1"], object()]) - def test_non_string_inputs_return_false(self, host): - from comfy_cli.command.run import is_local_host - - assert is_local_host(host) is False - - -class TestResolveRunEndpoint: - """Locality decision in cmdline._resolve_run_endpoint covers six cases: - no background, exact match, host override matches, port override matches, - both override and match, divergent override, non-local background.""" - - def _resolve(self, host, port, background): - from comfy_cli.cmdline import _resolve_run_endpoint - - return _resolve_run_endpoint(host, port, background) - - def test_no_background_means_no_local_paths(self): - h, p, lp = self._resolve(None, None, None) - assert (h, p, lp) == (None, None, False) - - def test_default_args_with_local_background_yields_local_paths(self): - h, p, lp = self._resolve(None, None, ("127.0.0.1", 8188, 1234)) - assert (h, p, lp) == ("127.0.0.1", 8188, True) - - def test_explicit_host_matching_background_still_local(self): - h, p, lp = self._resolve("127.0.0.1", None, ("127.0.0.1", 8188, 1234)) - assert (h, p, lp) == ("127.0.0.1", 8188, True) - - def test_explicit_port_matching_background_still_local(self): - h, p, lp = self._resolve(None, 8188, ("127.0.0.1", 8188, 1234)) - assert (h, p, lp) == ("127.0.0.1", 8188, True) - - def test_explicit_host_and_port_matching_background_still_local(self): - h, p, lp = self._resolve("127.0.0.1", 8188, ("127.0.0.1", 8188, 1234)) - assert (h, p, lp) == ("127.0.0.1", 8188, True) - - def test_divergent_host_disables_local_paths(self): - h, p, lp = self._resolve("remote.example.com", None, ("127.0.0.1", 8188, 1234)) - assert (h, p, lp) == ("remote.example.com", 8188, False) - - def test_divergent_port_disables_local_paths(self): - h, p, lp = self._resolve(None, 9999, ("127.0.0.1", 8188, 1234)) - assert (h, p, lp) == ("127.0.0.1", 9999, False) - - def test_non_local_background_yields_no_local_paths_even_on_match(self): - # Hypothetical: --background recorded a non-loopback host (unusual, - # but possible if launched with --listen=public.ip). We refuse to - # mark it local since the address class isn't safely on this box. - h, p, lp = self._resolve(None, None, ("192.168.1.5", 8188, 1234)) - assert (h, p, lp) == ("192.168.1.5", 8188, False) - - @pytest.mark.parametrize( - "user_host,bg_host", - [ - ("localhost", "127.0.0.1"), - ("127.0.0.1", "localhost"), - ("LOCALHOST", "127.0.0.1"), - ("::1", "127.0.0.1"), - ("[::1]", "::1"), - ], - ) - def test_loopback_aliases_match_background(self, user_host, bg_host): - h, p, lp = self._resolve(user_host, None, (bg_host, 8188, 1234)) - assert lp is True - assert h == user_host - - def test_non_loopback_user_host_does_not_alias_to_loopback_bg(self): - h, p, lp = self._resolve("192.168.1.5", None, ("127.0.0.1", 8188, 1234)) - assert lp is False - - -class TestRunCommandWiring: - """End-to-end: --host / --port passed to `comfy run` reach - WorkflowExecution(local_paths=...) via the cmdline → run_inner.execute - seam. Regression guard for the helper-to-execute wiring.""" - - def _invoke_run(self, host_arg, port_arg, background): - from typer.testing import CliRunner - - from comfy_cli.cmdline import app - from comfy_cli.config_manager import ConfigManager - - runner = CliRunner() - cfg = ConfigManager() - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump({"1": {"class_type": "X", "inputs": {}}}, f) - workflow_path = f.name - try: - with ( - patch.object(cfg, "background", background), - patch("comfy_cli.cmdline.run_inner.execute") as mock_execute, - ): - args = ["run", "--workflow", workflow_path] - if host_arg is not None: - args.extend(["--host", host_arg]) - if port_arg is not None: - args.extend(["--port", str(port_arg)]) - result = runner.invoke(app, args) - assert result.exit_code == 0, result.output - return mock_execute.call_args - finally: - os.unlink(workflow_path) - - def test_explicit_local_host_matching_background_sets_local_paths_true(self): - call = self._invoke_run("127.0.0.1", None, ("127.0.0.1", 8188, 1234)) - assert call.args[5] is True - - def test_explicit_remote_host_sets_local_paths_false(self): - call = self._invoke_run("api.comfy.org", 443, ("127.0.0.1", 8188, 1234)) - assert call.args[5] is False - - def test_loopback_alias_against_background_sets_local_paths_true(self): - call = self._invoke_run("localhost", None, ("127.0.0.1", 8188, 1234)) - assert call.args[5] is True - - def test_no_background_no_overrides_sets_local_paths_false(self): - call = self._invoke_run(None, None, None) - assert call.args[5] is False - - class TestWildcardHostSubstitution: """0.0.0.0 is a wildcard bind that macOS/Windows clients can't connect to; execute() substitutes it with the canonical loopback so downstream uses diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 520bc0c4..ff36df01 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -65,7 +65,6 @@ def _run_execute_capture(workflow_path, capsys, **overrides): port=8188, wait=True, verbose=False, - local_paths=False, timeout=30, json_mode=True, ) @@ -208,7 +207,6 @@ def test_completed_aggregates_outputs_and_node_ids(self, capsys, simple_workflow "subfolder": "", "type": "output", "url": "http://x", - "local_path": None, } e.emit_node_executed("2", [out1]) e.emit_completed() @@ -610,7 +608,6 @@ def _exec(self, simple_workflow): port=8188, verbose=False, progress=progress, - local_paths=False, timeout=30, emitter=e, ) @@ -684,20 +681,6 @@ def test_output_url_has_correct_format(self, simple_workflow, capsys): assert "filename=x.png" in url assert "type=output" in url - def test_local_path_null_when_local_paths_false(self, simple_workflow, capsys): - ex = self._exec(simple_workflow) - ex.prompt_id = "p" - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": "x.png", "subfolder": "", "type": "output"}], - }, - } - ) - events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] - assert events[-1]["outputs"][0]["local_path"] is None - def test_missing_subfolder_defaults_to_empty_string(self, simple_workflow, capsys): ex = self._exec(simple_workflow) ex.prompt_id = "p" @@ -933,7 +916,6 @@ def _exec(self, simple_workflow): port=8188, verbose=False, progress=progress, - local_paths=False, timeout=30, emitter=e, ) @@ -982,7 +964,6 @@ def _exec(self, simple_workflow): port=8188, verbose=False, progress=progress, - local_paths=False, timeout=30, emitter=e, ) @@ -1026,7 +1007,6 @@ def test_verbose_does_not_corrupt_json_stream(self, workflow_file, capsys): port=8188, wait=True, verbose=True, - local_paths=False, timeout=30, json_mode=True, ) @@ -1043,9 +1023,9 @@ def test_verbose_does_not_corrupt_json_stream(self, workflow_file, capsys): class TestErrorPathCoverage: - """Less-trodden paths: positive local_path, /object_info timeout/non-JSON, - queue() TimeoutError/OSError, on_executed/on_progress None guards, - on_cached None entries, two consecutive node_executing pattern.""" + """Less-trodden paths: /object_info timeout/non-JSON, queue() + TimeoutError/OSError, on_executed/on_progress None guards, on_cached + None entries, two consecutive node_executing pattern.""" def _make_workflow(self): return { @@ -1060,7 +1040,7 @@ def _make_workflow(self): }, } - def _make_exec(self, workflow, local_paths=False): + def _make_exec(self, workflow): e = JsonEmitter(json_mode=True) e.set_workflow(workflow) return WorkflowExecution( @@ -1069,195 +1049,28 @@ def _make_exec(self, workflow, local_paths=False): port=8188, verbose=False, progress=None, - local_paths=local_paths, timeout=30, emitter=e, ) - def test_local_path_populated_when_file_exists_at_workspace(self, capsys): - """Positive case: local host + workspace resolvable + file present on disk.""" - wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=False) - ex.prompt_id = "p" - with ( - patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), - ), - patch("comfy_cli.command.run.os.path.isfile", return_value=True), - ): - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": "out.png", "subfolder": "sf", "type": "output"}], - }, - } - ) - ev = json.loads(capsys.readouterr().out.strip()) - out0 = ev["outputs"][0] - assert out0["local_path"] == "/fake/workspace/output/sf/out.png" - - def test_local_path_null_when_file_missing_on_disk(self, capsys): - """Workspace resolvable but file doesn't exist at the computed path → null - (guards against multi-install setups where the running ComfyUI uses a - different workspace than the CLI resolves).""" + def test_output_object_has_no_local_path_field(self, capsys): + """local_path was removed from the JSON contract — `outputs[i]` + carries only `url`, never a filesystem path.""" wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=False) - ex.prompt_id = "p" - with ( - patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), - ), - patch("comfy_cli.command.run.os.path.isfile", return_value=False), - ): - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], - }, - } - ) - ev = json.loads(capsys.readouterr().out.strip()) - assert ev["outputs"][0]["local_path"] is None - - def test_local_path_null_when_workspace_unavailable(self, capsys): - """workspace_manager raises → local_path is null (defensive).""" - wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=False) + ex = self._make_exec(wf) ex.prompt_id = "p" - with patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - side_effect=RuntimeError("no workspace configured"), - ): - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], - }, - } - ) - ev = json.loads(capsys.readouterr().out.strip()) - assert ev["outputs"][0]["local_path"] is None - - def test_local_path_null_when_host_not_local(self, capsys): - """Defense for Cloud: non-loopback host yields null even when the - workspace lookup would succeed and a file with the same name exists - on the local box.""" - wf = self._make_workflow() - e = JsonEmitter(json_mode=True) - e.set_workflow(wf) - ex = WorkflowExecution( - workflow=wf, - host="api.comfy.org", - port=443, - verbose=False, - progress=None, - local_paths=True, - timeout=30, - emitter=e, + ex.on_executed( + { + "node": "2", + "output": { + "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], + }, + } ) - ex.prompt_id = "p" - with ( - patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), - ), - patch("comfy_cli.command.run.os.path.isfile", return_value=True), - ): - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], - }, - } - ) ev = json.loads(capsys.readouterr().out.strip()) - assert ev["outputs"][0]["local_path"] is None - - def test_workspace_path_resolved_once_per_execution(self, capsys): - """workspace_manager.get_workspace_path can have side effects on the - stale-recent path; we must call it at most once per run, not per - output event.""" - wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=False) - ex.prompt_id = "p" - with ( - patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), - ) as mock_ws, - patch("comfy_cli.command.run.os.path.isfile", return_value=True), - ): - for i in range(3): - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": f"out{i}.png", "subfolder": "", "type": "output"}], - }, - } - ) - assert mock_ws.call_count == 1 - - @pytest.mark.parametrize( - "subfolder,filename", - [ - ("..", "evil.png"), - ("../../etc", "passwd"), - ("/etc", "passwd"), - ("foo/../..", "escape.png"), - ], - ) - def test_local_path_rejects_subfolder_escape(self, capsys, subfolder, filename): - """Server-controlled subfolder/filename must not produce a local_path - outside //.""" - wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=False) - ex.prompt_id = "p" - with ( - patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), - ), - patch("comfy_cli.command.run.os.path.isfile", return_value=True), - ): - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": filename, "subfolder": subfolder, "type": "output"}], - }, - } - ) - ev = json.loads(capsys.readouterr().out.strip()) - assert ev["outputs"][0]["local_path"] is None - - def test_local_path_accepts_legitimate_subfolder(self, capsys): - wf = self._make_workflow() - ex = self._make_exec(wf, local_paths=False) - ex.prompt_id = "p" - with ( - patch( - "comfy_cli.command.run.workspace_manager.get_workspace_path", - return_value=("/fake/workspace", "ok"), - ), - patch("comfy_cli.command.run.os.path.isfile", return_value=True), - ): - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": "out.png", "subfolder": "my/nested/dir", "type": "output"}], - }, - } - ) - ev = json.loads(capsys.readouterr().out.strip()) - assert ev["outputs"][0]["local_path"] == "/fake/workspace/output/my/nested/dir/out.png" + out0 = ev["outputs"][0] + assert "local_path" not in out0 + assert out0["url"].startswith("http://") def test_object_info_timeout_routes_to_connection_error(self, capsys): """fetch_object_info(timeout → connection_error). Previously untested.""" @@ -1435,7 +1248,6 @@ def test_queue_passes_timeout_to_urlopen(self, workflow_file, capsys): port=8188, wait=True, verbose=False, - local_paths=False, timeout=42, json_mode=True, ) @@ -1469,7 +1281,6 @@ def test_connect_passes_timeout_to_ws_connect(self, workflow_file, capsys): port=8188, wait=True, verbose=False, - local_paths=False, timeout=37, json_mode=True, ) From 60c8b4a6329d3479ee6d8c101253937c7aca3c8f Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 17:12:27 +0000 Subject: [PATCH 09/32] chore(run): memoize text-mode workspace lookup and refresh docstring Two small follow-ups to the local_path removal: * `format_image_path` calls `workspace_manager.get_workspace_path()`, which can print a warning and rewrite the config on the stale-recent path. Memoize per WorkflowExecution so a workflow with N outputs doesn't fire those side effects N times. In text mode this is only a stdout noise / disk churn nit (not a JSON stream corruption risk like in the previous JSON contract), but the fix is one tiny helper. * The docstring still leaned on the phrase "local_path" which now reads as if it refers to the JSON contract field we just removed. Reword to "absolute filesystem path" so the helper's purpose is clear without referencing the deprecated name. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index b8d70dce..42aa86ba 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -754,20 +754,17 @@ def log_node(self, type, node_id): pprint(f"{type} : {title}") def format_image_path(self, img): - """Build a single (local_path|url) string for the legacy human - output. Prefers a clickable local path when the host is a known - loopback, the workspace resolves, the path stays inside the - workspace's per-type output dir, and the file exists on disk. - Otherwise falls back to a /view URL.""" + """Build a single human-readable path string for the legacy text + output. Prefers a clickable absolute filesystem path when the + host is a known loopback, the workspace resolves, the path stays + inside the workspace's per-type output dir, and the file exists + on disk. Otherwise falls back to a /view URL.""" filename = img["filename"] subfolder = img.get("subfolder") or "" output_type = img.get("type") or "output" if self.host in ("127.0.0.1", "localhost", "::1", "[::1]"): - try: - ws_path = workspace_manager.get_workspace_path()[0] - except Exception: - ws_path = None + ws_path = self._text_mode_workspace_path() if ws_path: parts = [subfolder, filename] if subfolder else [filename] type_root = os.path.normpath(os.path.join(ws_path, output_type)) @@ -778,6 +775,17 @@ def format_image_path(self, img): url_params = {"filename": filename, "subfolder": subfolder, "type": output_type} return f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" + def _text_mode_workspace_path(self) -> str | None: + # workspace_manager.get_workspace_path() can print a warning and + # write config on the stale-recent path. Memoize so a workflow + # with N outputs doesn't repeat the side effects N times. + if not hasattr(self, "_ws_path_cached"): + try: + self._ws_path_cached = workspace_manager.get_workspace_path()[0] + except Exception: + self._ws_path_cached = None + return self._ws_path_cached + def _build_output_object(self, node_id, category, item) -> dict: """Construct a structured Output dict for the JSON contract.""" filename = item["filename"] From ebd78e1c4db8f6df7a5696ab5ccf28e7b9288a9d Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 17:31:10 +0000 Subject: [PATCH 10/32] feat(run): add --print-prompt for dry-run preview of the converted body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents working with UI-format workflow exports had no way to preview the conversion without paying GPU time or importing `comfy_cli.workflow_to_api` from Python (which still needs a live ComfyUI for `/object_info`). `--print-prompt` closes that gap: it runs the full pre-flight up through conversion, prints the workflow body that *would* have been POSTed to `/prompt`, and exits 0 without ever hitting the queue. Behaviour: * API-format input: no server hit at all (the server probe is skipped when `--print-prompt` is set). Works against an offline ComfyUI. * UI-format input: still fetches `/object_info` for the converter; unreachable host surfaces as the usual `connection_error`. * In JSON mode emits a new terminal `prompt_preview` event carrying just the workflow graph — no `client_id`, no `extra_data` (so the `--api-key` value doesn't leak to stdout). * In text mode pretty-prints the workflow JSON. * Pre-flight failures (workflow_not_found, workflow_format_invalid, conversion_error, etc.) still go through the normal `failed` path and exit 1. Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 14 ++++ comfy_cli/command/run.py | 22 +++++- docs/json-output.md | 23 ++++++ tests/comfy_cli/command/test_run_json.py | 91 ++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 02cc81ea..dd2f14a9 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -497,6 +497,19 @@ def run( ), ), ] = False, + print_prompt: Annotated[ + bool, + typer.Option( + "--print-prompt", + help=( + "Print the API-format prompt body that WOULD be sent to /prompt and exit. " + "Does not POST and does not execute. For UI-format input the workflow is " + "converted first (requires a reachable ComfyUI for /object_info); API input " + "is printed as-is with no server hit. In --json mode emits a `prompt_preview` " + "event; otherwise pretty-prints to stdout." + ), + ), + ] = False, ): if api_key: api_key = api_key.strip() or None @@ -530,6 +543,7 @@ def run( timeout, api_key=api_key, json_mode=json_output, + print_prompt=print_prompt, ) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 42aa86ba..18154c6f 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -149,6 +149,15 @@ def workflow_manifest(self) -> list[dict]: ) return manifest + def emit_prompt_preview(self, prompt: dict) -> None: + self._emit( + { + "event": "prompt_preview", + "schema_version": SCHEMA_VERSION, + "prompt": prompt, + } + ) + def emit_queued(self, prompt_id: str, validation_warnings: list[dict]) -> None: self.prompt_id = prompt_id self._emit( @@ -314,6 +323,7 @@ def execute( timeout=30, api_key: str | None = None, json_mode: bool = False, + print_prompt: bool = False, ): # `0.0.0.0` is a wildcard bind, not a connect address. macOS / Windows # clients can't reach it; on Linux it happens to resolve to a loopback. @@ -335,7 +345,10 @@ def execute( ) raise typer.Exit(code=1) - if not check_comfy_server_running(port, host): + # --print-prompt skips the server probe: API-format input doesn't need + # a server at all, and UI-format input will surface a connection_error + # naturally when fetch_object_info() can't reach the host. + if not print_prompt and not check_comfy_server_running(port, host): msg = f"ComfyUI not running at {host}:{port} (override with --host / --port)" if json_mode: emitter.emit_failed("connection_error", msg) @@ -428,6 +441,13 @@ def execute( workflow = validated emitter.set_workflow(workflow) + if print_prompt: + if json_mode: + emitter.emit_prompt_preview(workflow) + else: + print(json.dumps(workflow, indent=2, ensure_ascii=False)) + return + progress = None start = time.time() if wait and not json_mode: diff --git a/docs/json-output.md b/docs/json-output.md index c1b33d44..1883959d 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -69,6 +69,7 @@ outcome: | --------------------------------- | --------------------------------------------------------- | | Success | `[converted]? + queued + [node_*]* + completed` | | `--no-wait` queued | `[converted]? + queued` | +| `--print-prompt` | `[converted]? + prompt_preview` | | Failure mid-execution | `[converted]? + queued + [node_*]* + failed` | | Failure during submission | `[converted]? + failed` | | Failure pre-flight | `failed` | @@ -98,6 +99,7 @@ feature-detect by field presence rather than version comparison. | Event | When | Terminal | | ----------------- | ------------------------------------------------- | -------- | | `converted` | UI-format workflow was client-side converted | | +| `prompt_preview` | `--print-prompt` mode: the would-be prompt body | ✓ | | `queued` | Server accepted the prompt (HTTP 200 on `/prompt`)| | | `node_cached` | Node hit the execution cache and was skipped | | | `node_executing` | Node started execution | | @@ -126,6 +128,27 @@ format. | `schema_version` | int | `1` | | `node_count` | int | Number of nodes in the converted graph | +### `prompt_preview` + +Terminal event for `--print-prompt` mode. Emitted in place of `queued` + +`node_*` + `completed`, after the optional `converted` event when the +input was UI-format. Carries the API-format workflow body that *would* +have been sent to `POST /prompt`. The CLI exits 0 right after emitting. + +```json +{"event": "prompt_preview", "schema_version": 1, "prompt": {"1": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}}}} +``` + +| Field | Type | Description | +| ---------------- | ---- | ---------------------------------------------------------------------- | +| `event` | str | `"prompt_preview"` | +| `schema_version` | int | `1` | +| `prompt` | dict | The API-format workflow graph keyed by node id. Same shape as the `prompt` field POSTed to `/prompt`. Does NOT include `client_id` or `extra_data` (those are runtime fields, not part of the workflow). | + +For UI-format input, `/object_info` must still be reachable (the +converter consults it). For API-format input, `--print-prompt` makes +no server requests at all and works against an offline ComfyUI host. + ### `queued` Emitted after `POST /prompt` returns 200. Carries the server's prompt diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index ff36df01..4bb0db2b 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -749,6 +749,97 @@ def ui_workflow_file(): os.unlink(path) +class TestPrintPrompt: + """`--print-prompt` returns the would-be `/prompt` body and exits 0 + without POSTing. UI input still needs `/object_info`; API input + doesn't touch the server at all.""" + + def test_api_input_emits_prompt_preview_and_no_other_events(self, workflow_file, capsys): + # No server probe, no /object_info fetch — API input is printed as-is. + with ( + patch("comfy_cli.command.run.check_comfy_server_running") as mock_check, + patch("comfy_cli.command.run.fetch_object_info") as mock_fetch, + patch("comfy_cli.command.run.request.urlopen") as mock_post, + ): + events = _run_execute_capture(workflow_file, capsys, print_prompt=True) + assert mock_check.call_count == 0 + assert mock_fetch.call_count == 0 + assert mock_post.call_count == 0 + assert len(events) == 1 + assert events[0]["event"] == "prompt_preview" + assert events[0]["schema_version"] == 1 + assert isinstance(events[0]["prompt"], dict) + assert "1" in events[0]["prompt"] + assert events[0]["prompt"]["1"]["class_type"] == "EmptyLatentImage" + + def test_ui_input_emits_converted_then_prompt_preview(self, ui_workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.fetch_object_info", return_value=OBJECT_INFO), + patch("comfy_cli.command.run.request.urlopen") as mock_post, + ): + events = _run_execute_capture(ui_workflow_file, capsys, print_prompt=True) + assert mock_post.call_count == 0 + assert [e["event"] for e in events] == ["converted", "prompt_preview"] + prompt = events[1]["prompt"] + assert isinstance(prompt, dict) + # The converted prompt should have entries for the UI nodes. + assert len(prompt) >= 1 + for entry in prompt.values(): + assert "class_type" in entry + + def test_ui_input_with_unreachable_object_info_routes_to_connection_error(self, ui_workflow_file, capsys): + # --print-prompt skips the pre-flight server probe, but UI conversion + # still needs /object_info, so an unreachable host surfaces here. + with ( + patch("comfy_cli.command.run.request.urlopen", side_effect=urllib.error.URLError("Connection refused")), + ): + events = _run_execute_capture(ui_workflow_file, capsys, print_prompt=True) + assert events[-1]["event"] == "failed" + assert events[-1]["error"]["kind"] == "connection_error" + + def test_api_input_works_with_offline_server(self, workflow_file, capsys): + # Hard-fail the server probe — the API path must not call it under --print-prompt. + with patch( + "comfy_cli.command.run.check_comfy_server_running", side_effect=AssertionError("must not be called") + ): + events = _run_execute_capture(workflow_file, capsys, print_prompt=True) + assert len(events) == 1 + assert events[0]["event"] == "prompt_preview" + + def test_print_prompt_does_not_include_api_key_or_client_id(self, workflow_file, capsys): + # The prompt_preview body should only carry the workflow graph, + # not the runtime POST envelope (which would otherwise leak the api_key). + events = _run_execute_capture(workflow_file, capsys, print_prompt=True, api_key="sk-secret") + prompt = events[0]["prompt"] + assert "extra_data" not in prompt + assert "client_id" not in prompt + assert "sk-secret" not in json.dumps(prompt) + + def test_print_prompt_text_mode_pretty_prints_json(self, workflow_file, capsys): + try: + execute(workflow_file, host="127.0.0.1", port=8188, print_prompt=True, json_mode=False) + except typer.Exit: + pass + out, _err = capsys.readouterr() + parsed = json.loads(out) + assert "1" in parsed + assert parsed["1"]["class_type"] == "EmptyLatentImage" + + def test_print_prompt_does_not_post_when_workflow_invalid(self, capsys): + # Pre-flight failures (workflow_not_found, workflow_format_invalid) + # still trigger `failed` and exit 1 under --print-prompt. + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"not": "a workflow"}, f) + path = f.name + try: + events = _run_execute_capture(path, capsys, print_prompt=True) + assert events[-1]["event"] == "failed" + assert events[-1]["error"]["kind"] == "workflow_format_invalid" + finally: + os.unlink(path) + + class TestConvertedAndConversionErrors: """UI-input event path and the conversion_error / conversion_crash kinds.""" From 379604fe5c860f8388e2d86fe3446eb114180222 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 17:43:45 +0000 Subject: [PATCH 11/32] docs(run): clarify why --print-prompt skips the pre-flight server probe The prior one-line comment read "API-format input doesn't need a server at all" without acknowledging that UI-format input does still need /object_info for the converter. The new wording spells out both cases: API works offline end-to-end, UI surfaces the same connection_error kind a few lines later (just from fetch_object_info instead of the explicit probe). Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 18154c6f..3e5828b8 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -345,9 +345,11 @@ def execute( ) raise typer.Exit(code=1) - # --print-prompt skips the server probe: API-format input doesn't need - # a server at all, and UI-format input will surface a connection_error - # naturally when fetch_object_info() can't reach the host. + # Under --print-prompt we skip this pre-flight probe. API-format input + # makes no server calls downstream so it works fully offline; UI-format + # input still needs /object_info for the converter, but if it's + # unreachable, fetch_object_info() surfaces the same connection_error + # kind a few lines later. if not print_prompt and not check_comfy_server_running(port, host): msg = f"ComfyUI not running at {host}:{port} (override with --host / --port)" if json_mode: From b5d58957f0a1b18aaf87e15072ce18209d5be448 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 17:48:32 +0000 Subject: [PATCH 12/32] fix(run): expand ~ in --workflow path before existence check The pre-flight existence check used the raw CLI argument while the actual open() used the expanded path. A scripted caller passing literal `~/wf.json` (or anything with shell variables / a leading `~` that wasn't shell-expanded) got a misleading `workflow_not_found` even when the file existed at the resolved location. Use the already-computed `workflow_name` (which goes through `os.path.expanduser` + `os.path.abspath`) for both the check and the error message, so the user sees the actual path the CLI tried. Regression flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 6 +++--- tests/comfy_cli/command/test_run_json.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 3e5828b8..62a5bb53 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -335,12 +335,12 @@ def execute( emitter = JsonEmitter(json_mode=json_mode) workflow_name = os.path.abspath(os.path.expanduser(workflow)) - if not os.path.isfile(workflow): + if not os.path.isfile(workflow_name): if json_mode: - emitter.emit_failed("workflow_not_found", f"Workflow file not found: {workflow}") + emitter.emit_failed("workflow_not_found", f"Workflow file not found: {workflow_name}") else: pprint( - f"[bold red]Specified workflow file not found: {workflow}[/bold red]", + f"[bold red]Specified workflow file not found: {workflow_name}[/bold red]", file=sys.stderr, ) raise typer.Exit(code=1) diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 4bb0db2b..af6a3cf3 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -749,6 +749,28 @@ def ui_workflow_file(): os.unlink(path) +class TestWorkflowPathExpansion: + """Regression: `~/wf.json` must be expanded before the existence check. + Otherwise scripted callers passing literal `~/...` see a misleading + workflow_not_found.""" + + def test_tilde_path_is_expanded_before_existence_check(self, capsys, monkeypatch, tmp_path): + workflow_path = tmp_path / "wf.json" + workflow_path.write_text(json.dumps({"1": {"class_type": "X", "inputs": {}}})) + monkeypatch.setenv("HOME", str(tmp_path)) + events = _run_execute_capture("~/wf.json", capsys, print_prompt=True) + assert events[0]["event"] == "prompt_preview", events + + def test_tilde_path_to_missing_file_reports_expanded_path(self, capsys, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + events = _run_execute_capture("~/missing.json", capsys, print_prompt=True) + assert events[0]["event"] == "failed" + assert events[0]["error"]["kind"] == "workflow_not_found" + # The error message should name the resolved path so the user can + # see exactly where we looked. + assert str(tmp_path) in events[0]["error"]["message"] + + class TestPrintPrompt: """`--print-prompt` returns the would-be `/prompt` body and exits 0 without POSTing. UI input still needs `/object_info`; API input From f85c6f251da03ba409c193f763973e0fc2ed89e7 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 17:59:38 +0000 Subject: [PATCH 13/32] fix(run): wire --timeout through the pre-flight server probe `check_comfy_server_running()` defaults its `timeout` to 5.0s, but the call site in `execute()` was passing only `(port, host)`. A user running against a slow-to-respond (but otherwise live) ComfyUI with `--timeout 60` got a misleading "not running" report from the probe because the underlying GET timed out at 5s while the rest of the pipeline was happy to wait. Pass `timeout=timeout` through so the probe respects the same bound as `/prompt` POST and the websocket operations. Regression flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 2 +- tests/comfy_cli/command/test_run_json.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 62a5bb53..cd097147 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -350,7 +350,7 @@ def execute( # input still needs /object_info for the converter, but if it's # unreachable, fetch_object_info() surfaces the same connection_error # kind a few lines later. - if not print_prompt and not check_comfy_server_running(port, host): + if not print_prompt and not check_comfy_server_running(port, host, timeout=timeout): msg = f"ComfyUI not running at {host}:{port} (override with --host / --port)" if json_mode: emitter.emit_failed("connection_error", msg) diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index af6a3cf3..d8dfa460 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -1375,6 +1375,29 @@ def test_queue_passes_timeout_to_urlopen(self, workflow_file, capsys): timeout_arg = call.args[1] assert timeout_arg == 42, f"urlopen not called with timeout=42, got {timeout_arg!r}" + def test_preflight_probe_passes_timeout(self, workflow_file, capsys): + # Pre-flight probe gets the same --timeout as everything else, + # otherwise a slow-to-respond ComfyUI would be falsely reported + # "not running" by the probe's default 5s. + with patch("comfy_cli.command.run.check_comfy_server_running", return_value=False) as mock_probe: + try: + execute( + workflow_file, + host="127.0.0.1", + port=8188, + timeout=55, + json_mode=True, + ) + except typer.Exit: + pass + _ = capsys.readouterr() + assert mock_probe.called + call = mock_probe.call_args + timeout_arg = call.kwargs.get("timeout") + if timeout_arg is None and len(call.args) >= 3: + timeout_arg = call.args[2] + assert timeout_arg == 55, f"check_comfy_server_running not called with timeout=55, got {timeout_arg!r}" + def test_connect_passes_timeout_to_ws_connect(self, workflow_file, capsys): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), From e2db03549f800f11db3bc50cc081af2bfb882198 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 18:12:39 +0000 Subject: [PATCH 14/32] test(env_checker): pin the default probe timeout value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test asserted only that `timeout` was passed; the value was treated as implementation detail. But the 5.0s default IS the user-visible "is the server up?" contract — if it silently dropped to 1s, real users on slow hosts would see spurious negatives, and this test wouldn't catch it. Assert the exact value, and add a sibling test for the caller-override path so both directions are pinned. Regression flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- tests/comfy_cli/test_env_checker.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/comfy_cli/test_env_checker.py b/tests/comfy_cli/test_env_checker.py index c4ec7f57..bed31756 100644 --- a/tests/comfy_cli/test_env_checker.py +++ b/tests/comfy_cli/test_env_checker.py @@ -52,11 +52,17 @@ def test_non_200_status(self, mock_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") - # `timeout` is passed defensively so a hung server doesn't block the - # caller indefinitely; default value is unimportant here. mock_get.assert_called_once() assert mock_get.call_args.args == ("http://0.0.0.0:9999/history",) - assert "timeout" in mock_get.call_args.kwargs + # Pin the default timeout — a silent change to this value would + # 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") + 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) + assert mock_get.call_args.kwargs["timeout"] == 42 class TestEnvChecker: From a43b3260c3af1cfbfd884c1f3ef1461881883f60 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 18:14:00 +0000 Subject: [PATCH 15/32] docs(json-output): add json language tag to NDJSON example blocks The inline per-event examples earlier in the doc already used \`\`\`json, but the long NDJSON archetypes in the Examples section dropped the tag. Adds it for the seven affected blocks (success, --no-wait, workflow_not_found, validation_error, execution_error, timeout, interrupted) so rendered docs (GitHub, IDE previews) get syntax highlighting on every JSON-looking line, and markdownlint MD040 is satisfied. Regression flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- docs/json-output.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/json-output.md b/docs/json-output.md index 1883959d..261058d4 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -551,7 +551,7 @@ trailing entries. ### Successful run (UI-format input) -``` +```json {"event":"converted","schema_version":1,"node_count":2} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} @@ -571,7 +571,7 @@ server doesn't send an `executed` ws message for it. ### `--no-wait` (API-format input) -``` +```json {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} ``` @@ -580,7 +580,7 @@ Exit code: `0`. The agent is responsible for polling ### Failure: workflow file missing -``` +```json {"event":"failed","schema_version":1,"prompt_id":null,"client_id":null,"elapsed_seconds":0.001,"error":{"kind":"workflow_not_found","message":"Workflow file not found: /tmp/missing.json"}} ``` @@ -588,7 +588,7 @@ Exit code: `1`. ### Failure: server returned validation errors -``` +```json {"event":"converted","schema_version":1,"node_count":2} {"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Workflow failed validation","node_errors":[{"node_id":"1","errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}]}} ``` @@ -597,7 +597,7 @@ Exit code: `1`. ### Failure: node raised during execution -``` +```json {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":2.1,"error":{"kind":"execution_error","message":"API key invalid","node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","exception_type":"RuntimeError","traceback":" File \"/path/to/node.py\", line 42, in execute\n raise RuntimeError(\"API key invalid\")\n"}} @@ -607,7 +607,7 @@ Exit code: `1`. ### Failure: websocket timeout -``` +```json {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":30.0,"error":{"kind":"timeout","message":"WebSocket timed out after 30s waiting for server response","timeout_seconds":30.0}} @@ -617,7 +617,7 @@ Exit code: `1`. ### Failure: workflow interrupted -``` +```json {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":3.2,"error":{"kind":"interrupted","message":"Workflow execution was interrupted"}} From a94130e424fe10d7277776c95fcbd66f44616d36 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 18:26:38 +0000 Subject: [PATCH 16/32] fix(run,tracking): two correctness bugs caught in final pre-merge review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `prompt_tracking_consent` (non-interactive branch) called `config_manager.set(...)` without guarding the disk write. On a fresh CI / sandbox with a read-only or missing `~/.config/comfy-cli`, the resulting `FileNotFoundError` / `PermissionError` propagated out of the typer entry callback BEFORE `comfy run` started — agents got a Python traceback on stderr and no terminal `failed` event on stdout, violating the documented stream contract. Wrap the write in a best-effort try/except so the in-memory `_session_only_tracking` state still takes effect and this process tracks normally. * `on_error` passed `node_id` straight through to `emit_failed` from the server's WS payload. Every other `node_id`-bearing emitter coerces to `str` defensively (line 175, 188, 204, 218) — only `execution_error` was missing the coercion, so a server that emitted an int `node_id` would have produced a contract-violating int field. Latent today (ComfyUI always sends a string) but cheap insurance. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 3 ++- comfy_cli/tracking.py | 9 +++++++- tests/comfy_cli/command/test_run_json.py | 26 ++++++++++++++++++++++++ tests/comfy_cli/test_tracking.py | 14 +++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index cd097147..e1af90ca 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -924,7 +924,8 @@ def on_executed(self, data): self.emitter.emit_node_executed(node_id, structured_outputs) def on_error(self, data): - node_id = data.get("node_id", "") + raw_node_id = data.get("node_id", "") + node_id = str(raw_node_id) if raw_node_id is not None else "" class_type = data.get("node_type") or data.get("class_type") or "" exception_type = data.get("exception_type", "") raw_tb = data.get("traceback", "") diff --git a/comfy_cli/tracking.py b/comfy_cli/tracking.py index 17f86313..106dd438 100644 --- a/comfy_cli/tracking.py +++ b/comfy_cli/tracking.py @@ -122,7 +122,14 @@ def prompt_tracking_consent(skip_prompt: bool = False, default_value: bool = Fal _session_only_tracking = True if user_id is None: user_id = str(uuid.uuid4()) - config_manager.set(constants.CONFIG_KEY_USER_ID, user_id) + # Best-effort persistence — a read-only config dir (fresh CI, + # restricted sandbox) must not crash the caller. If the write + # fails we keep the in-memory user_id so this process still + # tracks normally; the next run on a writable host will retry. + try: + config_manager.set(constants.CONFIG_KEY_USER_ID, user_id) + except OSError: + pass return enable_tracking = ui.prompt_confirm_action("Do you agree to enable tracking to improve the application?", False) diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index d8dfa460..6121bf2e 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -586,6 +586,32 @@ def test_execution_error(self, workflow_file, capsys): assert isinstance(terminal["error"]["traceback"], str) assert "raise RuntimeError" in terminal["error"]["traceback"] + def test_execution_error_node_id_coerced_to_str(self, workflow_file, capsys): + # If ComfyUI ever sends node_id as an int in execution_error (other + # node_id-bearing events all string-coerce defensively), the + # contract still requires a string. + messages = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": "1"}}), + json.dumps( + { + "type": "execution_error", + "data": { + "prompt_id": "p", + "node_id": 7, + "node_type": "EmptyLatentImage", + "exception_type": "RuntimeError", + "exception_message": "boom", + "traceback": [], + }, + } + ), + ] + events = self._run_with_ws_messages(workflow_file, messages, capsys) + terminal = events[-1] + assert terminal["error"]["kind"] == "execution_error" + assert terminal["error"]["node_id"] == "7" + assert isinstance(terminal["error"]["node_id"], str) + def test_execution_interrupted(self, workflow_file, capsys): messages = [ json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": "1"}}), diff --git a/tests/comfy_cli/test_tracking.py b/tests/comfy_cli/test_tracking.py index 3b5bede2..a2e619eb 100644 --- a/tests/comfy_cli/test_tracking.py +++ b/tests/comfy_cli/test_tracking.py @@ -187,6 +187,20 @@ def test_session_only_persists_user_id(self, tracking_module): assert persisted is not None assert persisted == tracking_module.user_id + def test_session_only_survives_unwritable_config(self, tracking_module): + # Read-only / missing config dir (fresh CI, restricted sandbox) must + # not crash the caller mid-typer-callback — otherwise an agent gets + # a Python traceback instead of a structured `failed` event. + 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.config_manager, "set", side_effect=PermissionError("read-only fs")), + ): + tracking_module.prompt_tracking_consent() + # In-memory state is still correct so this process tracks normally. + assert tracking_module._session_only_tracking is True + assert tracking_module.user_id is not None + def test_session_only_reuses_existing_user_id(self, tracking_module): existing_id = "existing-uuid-from-prior-run" tracking_module.config_manager.set(constants.CONFIG_KEY_USER_ID, existing_id) From a121a296a83df8c1a92041deef22a1f50b52a95a Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 18:29:28 +0000 Subject: [PATCH 17/32] docs(json-output): tighten contract wording for several drifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pre-merge review surfaced a handful of places where the doc overstated, understated, or mis-described what the code does: * `completed.elapsed_seconds` row claimed "from prompt POST" but the clock starts at JsonEmitter __init__ (top of execute()) — same reference point as `failed.elapsed_seconds`. Aligned the wording. * `validation_error` example had the fallback message string (`"Workflow failed validation"`), but with the example's populated `node_errors` the code would pull and emit the inner `errors[0].message` (`"Value not in list"`). Fixed the example. * `connection_error` trigger description listed only "URLError / pre-queue socket.timeout" — the code also catches `TimeoutError` and other `OSError` paths from the /prompt POST. Broadened. * `object_info_unavailable` trigger said "HTTP error" but the kind also fires on HTTP 200 with an unparseable body (line 311 in run.py). Broadened. * `title` fallback chain in every event row stopped at `class_type`, but `get_title()` also falls back to `node_id` when class_type is missing/empty/non-str. Documented the full chain. * `subfolder` and `type` defaults when the server omits them weren't documented; agents seeing `"output"` couldn't distinguish server-said vs CLI-defaulted. Noted both. * `exception_type` can be `""` when the server omits it. Noted. * `--no-wait` makes `queued` the terminal event for the invocation, but the event table marked only `completed`/`failed`/`prompt_preview` as terminal. Added a conditional ✓ to the `queued` row. Signed-off-by: Alexander Piskun --- docs/json-output.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/json-output.md b/docs/json-output.md index 261058d4..5da14b00 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -100,7 +100,7 @@ feature-detect by field presence rather than version comparison. | ----------------- | ------------------------------------------------- | -------- | | `converted` | UI-format workflow was client-side converted | | | `prompt_preview` | `--print-prompt` mode: the would-be prompt body | ✓ | -| `queued` | Server accepted the prompt (HTTP 200 on `/prompt`)| | +| `queued` | Server accepted the prompt (HTTP 200 on `/prompt`)| (✓ in `--no-wait`) | | `node_cached` | Node hit the execution cache and was skipped | | | `node_executing` | Node started execution | | | `node_progress` | In-flight progress update for the running node | | @@ -175,7 +175,7 @@ handle, which can be used to correlate against `/history/{prompt_id}`. | `prompt_id` | str | Server-assigned prompt UUID | | `client_id` | str | Client-generated UUID (sent with `/prompt`) | | `validation_warnings` | array of dict | List of per-node validation issues that ComfyUI reported alongside a successful queue (some output chains validated, others didn't). Same record shape as `validation_error.node_errors` (see below). Empty (`[]`) in the common case. | -| `nodes` | array of dict | Manifest of every node in the submitted (post-conversion) workflow. Each entry has `node_id` (str), `class_type` (str), and `title` (str — `_meta.title` if present, else `class_type`). Lets piped consumers (who don't have the workflow file at hand) render a per-node UI immediately without waiting for `completed`. | +| `nodes` | array of dict | Manifest of every node in the submitted (post-conversion) workflow. Each entry has `node_id` (str), `class_type` (str), and `title` (str — `_meta.title` if present, else `class_type`, else `node_id`). Lets piped consumers (who don't have the workflow file at hand) render a per-node UI immediately without waiting for `completed`. | The `validation_warnings` field exists specifically for the case where ComfyUI's `validate_prompt` returns success because at least one output @@ -206,7 +206,7 @@ See [completed](#completed) for the resulting semantics of | `schema_version` | int | `1` | | `node_id` | str | Node key in the workflow dict | | `class_type` | str | Node class name | -| `title` | str | `_meta.title` if present, else `class_type` | +| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | ### `node_executing` @@ -243,7 +243,7 @@ recently-emitted `node_executing` event. | `schema_version` | int | `1` | | `node_id` | str | Node currently running | | `class_type` | str | Node class name (duplicated from the workflow so stateless consumers don't need to track the prior `node_executing`) | -| `title` | str | `_meta.title` if present, else `class_type` | +| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | | `value` | number | Current progress. Typically int (step count); some custom nodes emit float (fractional progress) | | `max` | number | Total progress; same caveat as `value` | @@ -288,7 +288,7 @@ cached output-bearing node also emits `node_executed` (in addition to | `schema_version` | int | `1` | | `node_id` | str | Node key | | `class_type` | str | Node class name | -| `title` | str | `_meta.title` if present, else `class_type` | +| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | | `outputs` | array of Output | File-like outputs (empty if none) | `outputs` is populated by iterating each key in ComfyUI's @@ -323,7 +323,7 @@ output list, and node-execution metadata. | `schema_version` | int | `1` | | `prompt_id` | str | Server-assigned prompt UUID | | `client_id` | str | Client-generated UUID | -| `elapsed_seconds` | float | Wall-clock duration from prompt POST | +| `elapsed_seconds` | float | Wall-clock duration from start of `comfy run` (same clock as `failed.elapsed_seconds`) | | `outputs` | array of Output | All file-like outputs across all nodes (empty if none) | | `cached_node_ids` | array of str | Node IDs the server reported as cached (via `execution_cached`) | | `executed_node_ids` | array of str | Node IDs the server executor touched in this run — the union of every `node_id` that appeared in a `node_executing` or `node_executed` event. Includes intermediate compute nodes (CheckpointLoaderSimple, KSampler, etc.) that don't surface output to the client and don't get a dedicated `node_executed` event, in addition to leaf/output nodes that do. | @@ -396,10 +396,10 @@ cannot be assigned without a `client_id`). | `category` | str | Output category as keyed by ComfyUI's `executed.output` dict. **Open set.** Current ComfyUI versions emit values like `images`, `audio`, `3d`, `latents`; agents must accept and pass through unknown values. | | `node_id` | str | Node that produced the output | | `class_type` | str | Node class name | -| `title` | str | `_meta.title` if present, else `class_type` | +| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | | `filename` | str | Raw filename as reported by the server | -| `subfolder` | str | Subfolder within the output folder's root (`""` if none) | -| `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. | +| `subfolder` | str | Subfolder within the output folder's root. `""` if the server omits or empties the field. | +| `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. Defaults to `"output"` if the server omits the field. | | `url` | str | `http(s)://:/view?...` URL — always present, fetch this to get the bytes | ### Fetching output bytes @@ -449,8 +449,8 @@ removed without a schema version bump. | `workflow_empty` | Workflow has no executable nodes (UI conversion produced `{}`, or API workflow is `{}`) | — | | `conversion_error` | UI→API converter raised `WorkflowConversionError` | — | | `conversion_crash` | UI→API converter raised an unexpected exception | `exception_type` (str) | -| `object_info_unavailable` | `/object_info` returned an HTTP error (needed for UI→API conversion) | `status_code` (int), `body` (str) | -| `connection_error` | ComfyUI server not reachable (URLError / pre-queue socket.timeout, including on `/object_info`)| — | +| `object_info_unavailable` | `/object_info` returned an HTTP error, or an HTTP 200 with an unparseable body | `status_code` (int), `body` (str) | +| `connection_error` | ComfyUI server unreachable: `URLError`, `TimeoutError`, or other `OSError` while contacting it (including on `/object_info`) | — | | `validation_error` | Server returned HTTP 400 with `node_errors` | `node_errors` (array of dict; see below) | | `client_error` | Server returned an HTTP 4xx response (not validation) | `status_code` (int, 4xx), `body` (str) | | `server_error` | Server returned an HTTP 5xx response | `status_code` (int, 5xx), `body` (str) | @@ -466,9 +466,10 @@ removed without a schema version bump. (e.g., metrics bucketing). The format is whatever ComfyUI sends — typically the bare class name for builtins (`RuntimeError`, `ValueError`) and a dotted module path for non-builtins -(`comfy.model_management.InterruptProcessingException`). Agents should -not key retry or routing logic on `exception_type`; use `error.kind` -for coarse dispatch and `error.message` for human display. +(`comfy.model_management.InterruptProcessingException`). May be `""` +when the server omits it. Agents should not key retry or routing +logic on `exception_type`; use `error.kind` for coarse dispatch and +`error.message` for human display. ### `traceback` field @@ -590,7 +591,7 @@ Exit code: `1`. ```json {"event":"converted","schema_version":1,"node_count":2} -{"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Workflow failed validation","node_errors":[{"node_id":"1","errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}]}} +{"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Value not in list","node_errors":[{"node_id":"1","errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}]}} ``` Exit code: `1`. From f08ff92b7fb0441d68a3a7d8df3ff96af14557d7 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 18:45:12 +0000 Subject: [PATCH 18/32] feat(run): always emit prompt_preview in --json mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent feedback: --print-prompt is one-shot — to debug a conversion you have to run twice (once to preview, once to execute), and the two runs hit /object_info separately so they can in principle disagree. There's also no audit trail of what the CLI actually submitted for runs that fail server-side or produce unexpected outputs. Make the prompt_preview event a default, always-on part of the --json stream. It's emitted immediately after the optional `converted` event and immediately before `queued`, on every run regardless of input format. Agents get full provenance for free; the run-twice debugging pattern goes away. prompt_preview becomes non-terminal in the normal flow and terminal only under --print-prompt (which keeps its existing "preview and exit" semantics). The body is the workflow graph — no client_id, no extra_data, so the --api-key value still never appears in the stream. Doc: event-table marker switched from terminal-always to terminal-in- --print-prompt; stream-archetypes table gains the new event in every non-pre-flight row; six example NDJSON snippets gained the line so they match what the implementation now emits; --json help text mentions the audit-trail property. Tests: TestPromptPreviewAlwaysEmitted pins the always-on behaviour (API input, UI input, and the no-secrets-in-body invariant); the no-wait shape test updated to assert prompt_preview-before-queued. Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 5 +- comfy_cli/command/run.py | 10 ++- docs/json-output.md | 49 ++++++++++----- tests/comfy_cli/command/test_run_json.py | 80 ++++++++++++++++++++++-- 4 files changed, 118 insertions(+), 26 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index dd2f14a9..140c8497 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -493,7 +493,10 @@ def run( "for the event reference and stability contract. In this mode, " "--verbose has no effect and Rich progress is suppressed. " "Workflow input accepts both API and UI format JSON (UI input " - "triggers a `converted` event before `queued`)." + "triggers a `converted` event before `queued`). The converted " + "prompt body is always emitted as a `prompt_preview` event " + "before `queued`, so agents have a full audit trail of what " + "the CLI submitted." ), ), ] = False, diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index e1af90ca..73dae692 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -443,10 +443,14 @@ def execute( workflow = validated emitter.set_workflow(workflow) + # In JSON mode, always emit the converted prompt body so agents have a + # complete audit trail of what the CLI is about to submit. The event + # is non-terminal in normal flow and terminal under --print-prompt. + if json_mode: + emitter.emit_prompt_preview(workflow) + if print_prompt: - if json_mode: - emitter.emit_prompt_preview(workflow) - else: + if not json_mode: print(json.dumps(workflow, indent=2, ensure_ascii=False)) return diff --git a/docs/json-output.md b/docs/json-output.md index 5da14b00..1a7b62c2 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -65,14 +65,14 @@ event. The stream takes one of these shapes depending on the workflow format and outcome: -| Outcome | Stream | -| --------------------------------- | --------------------------------------------------------- | -| Success | `[converted]? + queued + [node_*]* + completed` | -| `--no-wait` queued | `[converted]? + queued` | -| `--print-prompt` | `[converted]? + prompt_preview` | -| Failure mid-execution | `[converted]? + queued + [node_*]* + failed` | -| Failure during submission | `[converted]? + failed` | -| Failure pre-flight | `failed` | +| Outcome | Stream | +| --------------------------------- | ------------------------------------------------------------------- | +| Success | `[converted]? + prompt_preview + queued + [node_*]* + completed` | +| `--no-wait` queued | `[converted]? + prompt_preview + queued` | +| `--print-prompt` | `[converted]? + prompt_preview` (terminal) | +| Failure mid-execution | `[converted]? + prompt_preview + queued + [node_*]* + failed` | +| Failure during submission | `[converted]? + prompt_preview + failed` | +| Failure pre-flight | `failed` | Where `[node_*]*` is zero or more interleaved `node_cached`, `node_executing`, `node_progress`, and `node_executed` events. `[X]?` @@ -99,7 +99,7 @@ feature-detect by field presence rather than version comparison. | Event | When | Terminal | | ----------------- | ------------------------------------------------- | -------- | | `converted` | UI-format workflow was client-side converted | | -| `prompt_preview` | `--print-prompt` mode: the would-be prompt body | ✓ | +| `prompt_preview` | The API-format prompt body about to be submitted | (✓ in `--print-prompt`) | | `queued` | Server accepted the prompt (HTTP 200 on `/prompt`)| (✓ in `--no-wait`) | | `node_cached` | Node hit the execution cache and was skipped | | | `node_executing` | Node started execution | | @@ -130,10 +130,17 @@ format. ### `prompt_preview` -Terminal event for `--print-prompt` mode. Emitted in place of `queued` + -`node_*` + `completed`, after the optional `converted` event when the -input was UI-format. Carries the API-format workflow body that *would* -have been sent to `POST /prompt`. The CLI exits 0 right after emitting. +**Always emitted in `--json` mode**, immediately after the optional +`converted` event and immediately before `queued`. Carries the +API-format workflow body the CLI is about to POST to `/prompt` — the +same dict that would land in the request's `prompt` field. Gives +agents a complete audit trail of what was submitted, useful for +debugging conversions, logging, and post-mortem analysis, without +needing a separate run. + +Under `--print-prompt` this event is also the **terminal** event: +the CLI emits it and exits 0 without queuing. In normal flow it's +informational and execution continues with `queued`. ```json {"event": "prompt_preview", "schema_version": 1, "prompt": {"1": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}}}} @@ -143,11 +150,13 @@ have been sent to `POST /prompt`. The CLI exits 0 right after emitting. | ---------------- | ---- | ---------------------------------------------------------------------- | | `event` | str | `"prompt_preview"` | | `schema_version` | int | `1` | -| `prompt` | dict | The API-format workflow graph keyed by node id. Same shape as the `prompt` field POSTed to `/prompt`. Does NOT include `client_id` or `extra_data` (those are runtime fields, not part of the workflow). | +| `prompt` | dict | The API-format workflow graph keyed by node id. Same shape as the `prompt` field POSTed to `/prompt`. Does NOT include `client_id` or `extra_data` (those are runtime fields, not part of the workflow — so any `--api-key` value never appears here). | -For UI-format input, `/object_info` must still be reachable (the -converter consults it). For API-format input, `--print-prompt` makes -no server requests at all and works against an offline ComfyUI host. +For UI-format input under `--print-prompt`, `/object_info` must still +be reachable (the converter consults it). For API-format input under +`--print-prompt`, no server requests are made and the command works +fully offline. In normal (non-`--print-prompt`) flow the server is +always contacted regardless, since execution follows. ### `queued` @@ -554,6 +563,7 @@ trailing entries. ```json {"event":"converted","schema_version":1,"node_count":2} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":1,"max":4} @@ -573,6 +583,7 @@ server doesn't send an `executed` ws message for it. ### `--no-wait` (API-format input) ```json +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} ``` @@ -591,6 +602,7 @@ Exit code: `1`. ```json {"event":"converted","schema_version":1,"node_count":2} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","resolution":"5K"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} {"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Value not in list","node_errors":[{"node_id":"1","errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}]}} ``` @@ -599,6 +611,7 @@ Exit code: `1`. ### Failure: node raised during execution ```json +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":2.1,"error":{"kind":"execution_error","message":"API key invalid","node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","exception_type":"RuntimeError","traceback":" File \"/path/to/node.py\", line 42, in execute\n raise RuntimeError(\"API key invalid\")\n"}} @@ -609,6 +622,7 @@ Exit code: `1`. ### Failure: websocket timeout ```json +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":30.0,"error":{"kind":"timeout","message":"WebSocket timed out after 30s waiting for server response","timeout_seconds":30.0}} @@ -619,6 +633,7 @@ Exit code: `1`. ### Failure: workflow interrupted ```json +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":3.2,"error":{"kind":"interrupted","message":"Workflow execution was interrupted"}} diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 6121bf2e..b19444e8 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -351,17 +351,19 @@ def test_connection_error_server_down(self, workflow_file, capsys): class TestSuccessfulRun: - def test_no_wait_emits_queued_only(self, workflow_file, capsys): + def test_no_wait_emits_prompt_preview_then_queued(self, workflow_file, capsys): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.request.urlopen") as mock_open, ): mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p123"}).encode() events = _run_execute_capture(workflow_file, capsys, wait=False) - assert len(events) == 1 - assert events[0]["event"] == "queued" - assert events[0]["prompt_id"] == "p123" - assert events[0]["validation_warnings"] == [] + # prompt_preview is always emitted in --json before queued so agents + # have a full audit trail of the submitted body. + assert [e["event"] for e in events] == ["prompt_preview", "queued"] + assert events[0]["prompt"] + assert events[1]["prompt_id"] == "p123" + assert events[1]["validation_warnings"] == [] def test_completed_event_after_success(self, workflow_file, capsys): """Mocked WS flow → expect queued + node_* + completed.""" @@ -797,6 +799,74 @@ def test_tilde_path_to_missing_file_reports_expanded_path(self, capsys, monkeypa assert str(tmp_path) in events[0]["error"]["message"] +class TestPromptPreviewAlwaysEmitted: + """In JSON mode the converted prompt body is always emitted as a + `prompt_preview` event before `queued`. Agents debugging conversions + or building an audit trail get full visibility without re-running + with a flag.""" + + def test_api_input_emits_prompt_preview_before_queued(self, workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + events = _run_execute_capture(workflow_file, capsys) + kinds = [e["event"] for e in events] + assert kinds[0] == "prompt_preview" + assert "queued" in kinds + assert kinds.index("prompt_preview") < kinds.index("queued") + assert events[0]["prompt"]["1"]["class_type"] == "EmptyLatentImage" + + def test_ui_input_emits_converted_then_prompt_preview_then_queued(self, ui_workflow_file, capsys): + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.fetch_object_info", return_value=OBJECT_INFO), + patch("comfy_cli.command.run.request.urlopen") as mock_post, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_post.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + events = _run_execute_capture(ui_workflow_file, capsys) + kinds = [e["event"] for e in events] + # Ordering: converted, prompt_preview, queued (then node_* / completed). + c = kinds.index("converted") + p = kinds.index("prompt_preview") + q = kinds.index("queued") + assert c < p < q + + def test_prompt_preview_excludes_client_id_and_extra_data(self, workflow_file, capsys): + # The audit trail must carry only the workflow graph, never the + # POST envelope's runtime fields (client_id, extra_data with api_key). + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket") as MockWs, + ): + mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p"}).encode() + ws_instance = MagicMock() + MockWs.return_value = ws_instance + ws_instance.recv.side_effect = [ + json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}}), + ] + events = _run_execute_capture(workflow_file, capsys, api_key="sk-secret") + preview = next(e for e in events if e["event"] == "prompt_preview") + prompt = preview["prompt"] + assert "client_id" not in prompt + assert "extra_data" not in prompt + assert "sk-secret" not in json.dumps(prompt) + + class TestPrintPrompt: """`--print-prompt` returns the would-be `/prompt` body and exits 0 without POSTing. UI input still needs `/object_info`; API input From dd2c80afa862f953d64bb1d0199331deb45c0d4a Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 18:48:59 +0000 Subject: [PATCH 19/32] feat(run): raise default --timeout from 30s to 120s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30s default tripped agents on cold-start runs of big-model workflows (Flux dev fp16, Wan-2.1-i2v-14B, etc.) — the queued → first-`node_executing` gap is dominated by checkpoint load on cold disk + cold VRAM, which routinely exceeds 30s for FP16 models in the >10 GB range. Once the first event arrives, per-event gaps drop to milliseconds and the timeout is effectively irrelevant for the rest of the run. 120s comfortably covers the load-from-cold case for current popular checkpoints while still catching a genuinely stuck server an order of magnitude faster than the typical workflow's wall-clock execution time. Agents that need more can still set `--timeout` explicitly. No semantic change otherwise — same per-event meaning, same fallback on stuck recv(), same value flowed through to the HTTP / WS handshakes. Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 140c8497..a57c94c8 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -469,7 +469,7 @@ def run( "indefinitely." ), ), - ] = 30, + ] = 120, api_key: Annotated[ str | None, typer.Option( From 14d754d1a4b99b10483a6c5fb0b4f6bf2995393c Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 19:13:15 +0000 Subject: [PATCH 20/32] fix(run): guard on_message / on_executing against malformed WS frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `on_message` indexed `message["data"]` (gated by `"data" in message`, which raises TypeError on non-iterable scalars like None) and `on_executing` indexed `data["node"]` (raises KeyError if the server sends an `executing` frame without it). A misbehaving sender — bad proxy, future protocol change, or even a custom node misusing the WS — could tear the recv loop down with an uncaught TypeError/KeyError that propagates all the way out of `comfy run`, leaving the agent with a Python traceback on stderr and no terminal `failed` event on stdout. Violates the documented stream contract. Defensive guards: * on_message: isinstance(message, dict) AND isinstance(data, dict), else return True (skip frame, keep recv'ing). * on_executing: `"node" not in data` returns True (skip — a missing key is a protocol violation but not necessarily "done"; let the next properly-shaped frame decide). Regression flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 16 ++++++++++-- tests/comfy_cli/command/test_run_json.py | 31 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 73dae692..b67fa397 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -833,8 +833,15 @@ def _build_output_object(self, node_id, category, item) -> dict: } def on_message(self, message): - data = message["data"] if "data" in message else {} - if "prompt_id" not in data or data["prompt_id"] != self.prompt_id: + # Defensive: a malformed (non-object) JSON frame from the server + # must not raise out of the recv loop — that would tear down the + # run without a terminal `failed` event and break the contract. + if not isinstance(message, dict): + return True + data = message.get("data") + if not isinstance(data, dict): + return True + if data.get("prompt_id") != self.prompt_id: return True msg_type = message.get("type") @@ -858,6 +865,11 @@ def on_executing(self, data): self.progress.remove_task(self.progress_task) self.progress_task = None + # `node: null` is the documented "execution done" signal. A + # missing key is a protocol violation — skip the frame and keep + # listening rather than prematurely terminating. + if "node" not in data: + return True if data["node"] is None: return False diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index b19444e8..4092b6e6 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -1386,6 +1386,37 @@ def test_on_progress_none_node_id_does_not_emit(self, capsys): out, _ = capsys.readouterr() assert out.strip() == "" + @pytest.mark.parametrize("malformed", [None, 42, "string", [1, 2, 3], True]) + def test_on_message_skips_non_dict_payloads(self, capsys, malformed): + # A bad JSON frame (scalar, array, etc.) must not raise out of the + # recv loop — that would tear down the run without a terminal + # `failed` event and break the stream contract. + wf = self._make_workflow() + ex = self._make_exec(wf) + ex.prompt_id = "p" + assert ex.on_message(malformed) is True + out, _err = capsys.readouterr() + assert out == "" + + def test_on_message_skips_when_data_is_not_dict(self, capsys): + wf = self._make_workflow() + ex = self._make_exec(wf) + ex.prompt_id = "p" + # message is a dict but `data` is the wrong shape. + assert ex.on_message({"type": "executing", "data": "not a dict"}) is True + assert ex.on_message({"type": "executing", "data": [1, 2, 3]}) is True + assert ex.on_message({"type": "executing", "data": None}) is True + out, _err = capsys.readouterr() + assert out == "" + + def test_on_executing_skips_when_node_key_missing(self, capsys): + # Missing `node` key is a protocol violation; we skip rather than + # treating it as None (which means "execution done"). + wf = self._make_workflow() + ex = self._make_exec(wf) + ex.prompt_id = "p" + assert ex.on_executing({"prompt_id": "p"}) is True + def test_on_cached_skips_none_entries(self, capsys): wf = self._make_workflow() ex = self._make_exec(wf) From 89f2ce6997d36e20391cea80b819e660819d12d5 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 19:29:17 +0000 Subject: [PATCH 21/32] docs(json-output): name prompt_preview as terminal under --print-prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event-reference table and stream-archetype table already mark prompt_preview as the terminal event under --print-prompt, but the prose in three other places ("Stream shape", "Process-level termination", and the Examples preamble) still only mentioned completed/failed. A reader scanning the prose could conclude that --json --print-prompt violates the terminal-event contract, when in fact the contract treats prompt_preview as a third terminal kind in that mode (and a successful exit 0, since no execution was attempted). Bring the prose into line with the tables, including the exit-code note and the Stability section's "exit code is always 0 or 1" recap. The alternative — emitting `completed` after `prompt_preview` in --print-prompt mode — would be a category error: `completed` carries elapsed_seconds, outputs, executed_node_ids whose semantics require that the workflow actually ran. Emitting `completed` with empty executed_node_ids for a never-started run would lie to agents dispatching on event kind. Keeping `prompt_preview` as the terminal event is the honest design. Doc-only nit flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- docs/json-output.md | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/json-output.md b/docs/json-output.md index 1a7b62c2..c9444714 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -22,10 +22,11 @@ machine-readable mode: contract: framework-level Python errors, uncaught exceptions, library warnings. Agents should not parse stderr; they may discard it or capture it for diagnostics. -- **Exit code** is `0` on a `completed` terminal event and `1` on a - `failed` terminal event. Error categorisation is carried in the - `error.kind` field of the `failed` event, not in the exit code (see - [Stability](#stability-and-exit-codes)). +- **Exit code** is `0` when the terminal event is `completed`, + `queued` (`--no-wait` mode), or `prompt_preview` (`--print-prompt` + mode); `1` on a `failed` terminal event. Error categorisation is + carried in the `error.kind` field of the `failed` event, not in the + exit code (see [Stability](#stability-and-exit-codes)). In `--json` mode, `--verbose` has no effect: agents receive the full event stream regardless. @@ -53,10 +54,13 @@ Comfy Cloud should expect to use their own client code for now. Every line on stdout is a JSON object containing a non-empty string `event` field acting as a discriminator. Agents must dispatch on this -field. The stream ends with a terminal event (`completed` or `failed`) -in wait mode. In `--no-wait` mode the stream ends at `queued`, and the -agent is responsible for polling `/history/{prompt_id}` to observe -completion. See [Process-level termination](#process-level-termination) +field. In normal `--json` execution the stream ends with a terminal +event (`completed` or `failed`). In `--no-wait` mode the stream ends +at `queued`, and the agent is responsible for polling +`/history/{prompt_id}` to observe completion. In `--print-prompt` +mode the stream ends at `prompt_preview` (no execution happens, so +`completed`/`failed` would be category-error events about something +that was never started). See [Process-level termination](#process-level-termination) for the edge case where the CLI is killed before emitting its terminal event. @@ -542,8 +546,9 @@ cases, no terminal event is emitted and the stream may be truncated. Agents should treat the run as failed when **both**: - the process exit code is non-zero, and -- the last line on stdout is not a `completed` or `failed` event (or - stdout is empty). +- the last line on stdout is not one of the documented terminal events + (`completed`, `failed`, `queued` under `--no-wait`, or `prompt_preview` + under `--print-prompt`), or stdout is empty. Stderr may contain a Python traceback in these cases. @@ -554,8 +559,9 @@ etc.) are illustrative — they reflect specific ComfyUI/partner nodes. Agents should not hardcode behavior on specific `class_type` strings; the contract guarantees the *shape* of these fields, not their content. -Every line, including the terminal `completed`/`failed`, ends with -`\n`. Agents using line iteration (`for line in stdout`) are fine; +Every line, including the terminal event (`completed` / `failed` / +`queued` under `--no-wait` / `prompt_preview` under `--print-prompt`), +ends with `\n`. Agents using line iteration (`for line in stdout`) are fine; agents using `splitlines()` or `split("\n")` should filter empty trailing entries. @@ -668,7 +674,8 @@ than being treated as an additive change. ### Why exit codes are not granular -In `--json` mode, exit code is always `0` (completed) or `1` (failed). +In `--json` mode, exit code is always `0` (`completed`, or the +mode-specific terminal `queued`/`prompt_preview`) or `1` (`failed`). This is part of the v1 JSON contract and will not change without a `schema_version` bump. Non-`--json` exit codes are documented elsewhere and not governed by this contract. From e9c8767a912ee0731c7f12924080f65b74fe94a8 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 19:35:23 +0000 Subject: [PATCH 22/32] test(run): add CLI-runner integration tests for --json non-TTY path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing JSON tests call execute() directly via _run_execute_capture and bypass the typer entry callback chain (where consent prompt / decorator / config init happen pre-execute). That's exactly where the original first-run-corrupts-stdout bug lived. If anyone adds a stray print() or pprint() in cmdline.py, none of the existing unit tests would catch it. Two integration tests via typer.testing.CliRunner (which is non-TTY by default — i.e., the agent scenario): * test_cli_json_print_prompt_emits_clean_ndjson: smoke, default config state. Every stdout line parses as JSON with `event` and `schema_version`. Consent prompt text strings absent. * test_cli_json_with_fresh_consent_state_stays_clean: the actual regression scenario — fresh tmp config dir (consent never recorded), mocked Mixpanel client. Verifies the session-only tracking branch fires silently and stdout stays NDJSON-clean. Both use --print-prompt so the tests don't need to mock /object_info or websocket plumbing. The CliRunner integration seam is what's being tested, not the workflow lifecycle. Gap flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- tests/comfy_cli/command/test_run_json.py | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 4092b6e6..a03a6ab1 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -799,6 +799,72 @@ def test_tilde_path_to_missing_file_reports_expanded_path(self, capsys, monkeypa assert str(tmp_path) in events[0]["error"]["message"] +class TestCliRunnerIntegration: + """End-to-end: the typer entry callback chain (consent prompt, decorators, + config init) must not leak any prose to stdout in JSON mode. Direct + `execute()` tests bypass this seam; agents on a fresh machine with + no recorded consent are exactly where the original prompt-corrupts-stream + bug would have hidden.""" + + def _make_workflow_file(self, tmp_path): + wf_path = tmp_path / "wf.json" + wf_path.write_text(json.dumps({"1": {"class_type": "X", "inputs": {}}})) + return str(wf_path) + + def test_cli_json_print_prompt_emits_clean_ndjson(self, tmp_path): + # Smoke: default config state, --json --print-prompt → every stdout + # line is valid JSON with `event` and `schema_version`. + from typer.testing import CliRunner + + from comfy_cli.cmdline import app + + runner = CliRunner() # non-TTY by default + result = runner.invoke( + app, ["run", "--workflow", self._make_workflow_file(tmp_path), "--json", "--print-prompt"] + ) + assert result.exit_code == 0, f"stdout={result.stdout!r}\nexc={result.exception!r}" + lines = [line for line in result.stdout.splitlines() if line.strip()] + assert lines, "expected at least one NDJSON line" + for line in lines: + event = json.loads(line) + assert "event" in event + assert "schema_version" in event + # Consent prompt text must not appear. + assert "Do you agree" not in result.stdout + assert "improve the application" not in result.stdout + + def test_cli_json_with_fresh_consent_state_stays_clean(self, tmp_path): + # The exact regression scenario: a fresh machine where consent has + # never been recorded. The entry callback enables session-only + # tracking via the non-TTY branch (mocked Mixpanel client so no + # network), and the resulting stdout must still be clean NDJSON. + from typer.testing import CliRunner + + from comfy_cli.cmdline import app + from comfy_cli.config_manager import ConfigManager + + _Cls = ConfigManager.__closure__[0].cell_contents + cfg_dir = tmp_path / "config" + cfg_dir.mkdir() + with ( + patch.object(_Cls, "get_config_path", return_value=str(cfg_dir)), + patch("comfy_cli.tracking.mp") as mock_mp, + ): + mock_mp.track.return_value = None + runner = CliRunner() + result = runner.invoke( + app, ["run", "--workflow", self._make_workflow_file(tmp_path), "--json", "--print-prompt"] + ) + assert result.exit_code == 0, f"stdout={result.stdout!r}\nexc={result.exception!r}" + for line in result.stdout.splitlines(): + if not line.strip(): + continue + event = json.loads(line) + assert "event" in event + assert "Do you agree" not in result.stdout + assert "tracking" not in result.stdout.lower() + + class TestPromptPreviewAlwaysEmitted: """In JSON mode the converted prompt body is always emitted as a `prompt_preview` event before `queued`. Agents debugging conversions From 6b6169568cfa53c556b84cd60e35047478b0558f Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Mon, 18 May 2026 19:39:23 +0000 Subject: [PATCH 23/32] test(run): drop the local_path-absence regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_output_object_has_no_local_path_field` was pinning the absence of a field that never existed pre-PR — JSON output mode is new, so there's no historical baseline where local_path was emitted. The test caught a narrow scenario (someone re-adding `"local_path": None` to `_build_output_object`) but at the cost of being a regression test for something that wasn't a regression. The deprecation rationale is still load-bearing in `docs/json-output.md` ("Agents should rely on `url` exclusively") and the contract section that lists the output object's fields. Reviewers considering a future re-introduction would land in the doc first, not be ambushed by an oddly-named "must NOT have" test. Signed-off-by: Alexander Piskun --- tests/comfy_cli/command/test_run_json.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index a03a6ab1..80c5c785 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -1328,25 +1328,6 @@ def _make_exec(self, workflow): emitter=e, ) - def test_output_object_has_no_local_path_field(self, capsys): - """local_path was removed from the JSON contract — `outputs[i]` - carries only `url`, never a filesystem path.""" - wf = self._make_workflow() - ex = self._make_exec(wf) - ex.prompt_id = "p" - ex.on_executed( - { - "node": "2", - "output": { - "images": [{"filename": "out.png", "subfolder": "", "type": "output"}], - }, - } - ) - ev = json.loads(capsys.readouterr().out.strip()) - out0 = ev["outputs"][0] - assert "local_path" not in out0 - assert out0["url"].startswith("http://") - def test_object_info_timeout_routes_to_connection_error(self, capsys): """fetch_object_info(timeout → connection_error). Previously untested.""" ui_wf = { From 9c67f32a87c3bf2899d7d897154ab78b949073ff Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 05:51:27 +0000 Subject: [PATCH 24/32] docs(json-output): align Stability section with the Overview contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two inconsistencies between the Stability section's "What is stable" bullets and the Overview section earlier in the doc: * The exit-code bullet only mentioned `completed`→0 and `failed`→1, dropping the `queued` (--no-wait) and `prompt_preview` (--print-prompt) success paths that the Overview already documents. * The stdout/stderr separation bullet said "NDJSON on stdout, no progress on stderr" — the second clause is technically true but uninformative; the actual contract (per the Overview) is that stdout is NDJSON-only with no human-readable progress bar / ANSI, and stderr carries Python errors / library warnings that agents should not parse. Expand both bullets to match what the Overview says, so the contract sings one tune throughout. Doc-only nit flagged by CodeRabbit on PR #455. Signed-off-by: Alexander Piskun --- docs/json-output.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/json-output.md b/docs/json-output.md index c9444714..0fa9e6d9 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -655,8 +655,13 @@ For the v1 contract documented here: - The set of event names listed above and the field names within them. - The set of `error.kind` values listed above and the per-kind extra fields documented for each. -- The exit code mapping (`0` on `completed`, `1` on `failed`). -- The stdout/stderr separation (NDJSON on stdout, no progress on stderr). +- The exit code mapping: `0` when the terminal event is `completed`, + `queued` (under `--no-wait`), or `prompt_preview` (under + `--print-prompt`); `1` on `failed`. +- The stdout/stderr separation: stdout carries only NDJSON (no ANSI, + no human-readable progress bar, no headings); stderr is reserved + for framework-level Python errors, uncaught exceptions, and library + warnings — agents should not parse it. - The `schema_version: 1` field on every event of v1 streams. ### What may change in a non-breaking way From d15d1bbbfd3333b6a0fdca14549af754eb174068 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 06:08:35 +0000 Subject: [PATCH 25/32] fix(run): coerce output node_id to str + rename interrupted kind + cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from a fresh full-PR review: * `_build_output_object` stored `node_id` directly from the server WS frame without `str()` coercion. The top-level `node_executed.node_id` IS coerced (in `emit_node_executed`), but each item inside `outputs[]` was passing through whatever shape the server sent. If ComfyUI ever sends an int `node` field, agents would see a contract violation (`node_id` documented as str). One-line fix. * Rename `error.kind = "interrupted"` → `"execution_interrupted"`. Parallels the existing `execution_error` kind, matches ComfyUI's source WS message name (the `msg_type` dispatch already uses `"execution_interrupted"`), and removes the only "bare past- participle" kind from the taxonomy. v1 hasn't shipped, contract is still mutable. * Test `test_connection_lost_on_malformed_frame` was misleadingly named — body asserts `event == "completed"` (the run survives a malformed frame), NOT a `connection_lost` failure. Renamed to `test_malformed_frame_is_skipped_run_completes`. * `test_object_info_unavailable_on_http_error` had a dead `mock_open.side_effect = _make_http_error(500, ...)` line immediately overwritten by a manually-built 503 HTTPError two lines later. Removed. Also adds a regression test pinning the outputs[i].node_id coercion. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 3 +- docs/json-output.md | 4 +-- tests/comfy_cli/command/test_run_json.py | 36 +++++++++++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index b67fa397..22083b99 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -814,6 +814,7 @@ def _text_mode_workspace_path(self) -> str | None: def _build_output_object(self, node_id, category, item) -> dict: """Construct a structured Output dict for the JSON contract.""" + node_id = str(node_id) filename = item["filename"] subfolder = item.get("subfolder") or "" file_type = item.get("type") or "output" @@ -975,7 +976,7 @@ def on_interrupted(self, data): self._stop_progress() if self.emitter.json_mode: self.emitter.emit_failed( - "interrupted", + "execution_interrupted", "Workflow execution was interrupted", ) else: diff --git a/docs/json-output.md b/docs/json-output.md index 0fa9e6d9..5e42b4e6 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -470,7 +470,7 @@ removed without a schema version bump. | `invalid_response` | Server returned HTTP 2xx but body was unparseable or lacked `prompt_id` | `status_code` (int, 2xx), `body` (str) | | `timeout` | WebSocket `recv` timed out | `timeout_seconds` (float) | | `connection_lost` | WebSocket connection dropped mid-execution | — | -| `interrupted` | Server signaled the workflow was interrupted (`execution_interrupted` WS, e.g., via `/interrupt`) | — | +| `execution_interrupted` | Server signaled the workflow was interrupted (`execution_interrupted` WS, e.g., via `/interrupt`) | — | | `execution_error` | A node raised during execution (server emitted `execution_error`) | `node_id` (str), `class_type` (str), `title` (str), `exception_type` (str), `traceback` (str) | ### `exception_type` field @@ -642,7 +642,7 @@ Exit code: `1`. {"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} -{"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":3.2,"error":{"kind":"interrupted","message":"Workflow execution was interrupted"}} +{"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":3.2,"error":{"kind":"execution_interrupted","message":"Workflow execution was interrupted"}} ``` Exit code: `1`. diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 80c5c785..e92f2569 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -548,10 +548,10 @@ def test_connection_lost_websocket(self, workflow_file, capsys): terminal = events[-1] assert terminal["error"]["kind"] == "connection_lost" - def test_connection_lost_on_malformed_frame(self, workflow_file, capsys): - """We silently skip malformed frames; if ALL frames are malformed the - stream just hangs forever — so the realistic test is: WS raises during - recv after a malformed frame. Verify the bad frame doesn't crash.""" + def test_malformed_frame_is_skipped_run_completes(self, workflow_file, capsys): + """We silently skip malformed JSON frames mid-stream. A valid + executing(node=None) frame following the bad one should still + terminate the run normally with `completed`.""" events = self._run_with_ws_messages( workflow_file, ["{not json", json.dumps({"type": "executing", "data": {"prompt_id": "p", "node": None}})], @@ -621,7 +621,7 @@ def test_execution_interrupted(self, workflow_file, capsys): ] events = self._run_with_ws_messages(workflow_file, messages, capsys) terminal = events[-1] - assert terminal["error"]["kind"] == "interrupted" + assert terminal["error"]["kind"] == "execution_interrupted" class TestOutputObject: @@ -1111,8 +1111,8 @@ def test_object_info_unavailable_on_http_error(self, ui_workflow_file, capsys): patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), patch("comfy_cli.command.run.request.urlopen") as mock_open, ): - mock_open.side_effect = _make_http_error(500, b"internal error") - # _make_http_error builds a /prompt URL by default; switch to object_info + # _make_http_error builds a /prompt URL by default — build the + # /object_info HTTPError inline so the test exercises that path. mock_open.side_effect = urllib.error.HTTPError( url="http://127.0.0.1:8188/object_info", code=503, @@ -1195,6 +1195,28 @@ def _exec(self, simple_workflow): emitter=e, ) + def test_output_node_id_coerced_to_str(self, simple_workflow, capsys): + # If the server ever sends `node` as an int, every other emit site + # coerces — outputs[i].node_id must too, since the contract says + # node_id is always str. + ex = self._exec(simple_workflow) + ex.prompt_id = "p" + ex.on_executed( + { + "node": 2, + "output": {"images": [{"filename": "x.png", "subfolder": "", "type": "output"}]}, + } + ) + events = [json.loads(line) for line in capsys.readouterr().out.splitlines() if line.strip()] + executed = next(e for e in events if e["event"] == "node_executed") + assert isinstance(executed["node_id"], str) + assert executed["outputs"] + for out in executed["outputs"]: + assert isinstance(out["node_id"], str), ( + f"outputs[i].node_id leaked non-str: {type(out['node_id']).__name__}" + ) + assert out["node_id"] == "2" + def test_executed_with_missing_output(self, simple_workflow, capsys): ex = self._exec(simple_workflow) ex.prompt_id = "p" From 16a278c0901e145f872930ebd4aaccf2a1f82f88 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 06:11:07 +0000 Subject: [PATCH 26/32] docs(json-output): tighten contract wording (7 corroborated findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven inconsistencies / gaps a fresh full-PR review surfaced — all doc-only, all in the same file: * `prompt_preview` was described as "Always emitted in --json mode" but the pre-flight failure archetype shows the stream is just `failed` with no prompt_preview. Reworded to spell out the actual invariant (emitted on every stream where the workflow was loaded, parsed, and converted — i.e., everywhere except the five pre-flight failure kinds that fail before conversion). * The `prompt_preview.prompt` field was described as "prompt body" (event table), "workflow body" (prose), and "workflow graph" (field table) within ~50 lines. Unified to "workflow graph" since the value is a node-id-keyed dict, not an HTTP body. * The `execution_error` row's `title (str)` extras cell didn't describe the fallback chain, even though every other place `title` appears spells it out as `_meta.title → class_type → node_id`. Inlined the chain in the cell. * `class_type` and `exception_type` are both open-valued (any custom node, any Python exception) but only `category` and `type` carried the **Open set** marker. Added the marker plus a one-line description to both (`class_type` in `queued.nodes` row, since that's its first authoritative table mention; `exception_type` in its own subsection). * `node_progress.value` / `max` default to `0` when the server omits them — the code uses `data.get(..., 0)` — but the field table didn't say so. Added the default. * `subfolder` said "omits or empties"; `type` said "omits". Both use the same `or`-fallback in code, so both treat missing AND empty identically. Aligned the wording. * The 7-bit ASCII / `ensure_ascii=True` invariant was in the Overview but absent from the "What is stable" bullet list under Stability. Added a bullet so anyone scanning the contract guarantees sees it. Signed-off-by: Alexander Piskun --- docs/json-output.md | 49 ++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/docs/json-output.md b/docs/json-output.md index 5e42b4e6..31b48261 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -103,7 +103,7 @@ feature-detect by field presence rather than version comparison. | Event | When | Terminal | | ----------------- | ------------------------------------------------- | -------- | | `converted` | UI-format workflow was client-side converted | | -| `prompt_preview` | The API-format prompt body about to be submitted | (✓ in `--print-prompt`) | +| `prompt_preview` | The API-format workflow graph about to be submitted | (✓ in `--print-prompt`) | | `queued` | Server accepted the prompt (HTTP 200 on `/prompt`)| (✓ in `--no-wait`) | | `node_cached` | Node hit the execution cache and was skipped | | | `node_executing` | Node started execution | | @@ -134,13 +134,17 @@ format. ### `prompt_preview` -**Always emitted in `--json` mode**, immediately after the optional -`converted` event and immediately before `queued`. Carries the -API-format workflow body the CLI is about to POST to `/prompt` — the -same dict that would land in the request's `prompt` field. Gives -agents a complete audit trail of what was submitted, useful for -debugging conversions, logging, and post-mortem analysis, without -needing a separate run. +Emitted in `--json` mode once the workflow has been successfully +loaded, parsed, and (if UI-format) converted — i.e., in every stream +except the pre-flight failure cases where the workflow was rejected +before conversion (`workflow_not_found`, `workflow_invalid_json`, +`workflow_read_error`, `workflow_format_invalid`, `workflow_empty`). +Fires immediately after the optional `converted` event and immediately +before `queued`. Carries the API-format workflow graph the CLI is +about to POST to `/prompt` — the same dict that would land in the +request's `prompt` field. Gives agents a complete audit trail of what +was submitted, useful for debugging conversions, logging, and +post-mortem analysis, without needing a separate run. Under `--print-prompt` this event is also the **terminal** event: the CLI emits it and exits 0 without queuing. In normal flow it's @@ -218,7 +222,7 @@ See [completed](#completed) for the resulting semantics of | `event` | str | `"node_cached"` | | `schema_version` | int | `1` | | `node_id` | str | Node key in the workflow dict | -| `class_type` | str | Node class name | +| `class_type` | str | Node class name. **Open set** — current ComfyUI versions emit names like `KSampler`, `SaveImage`, plus arbitrary custom-node class names; agents must accept and pass through unknown values without keying behaviour on specific strings. | | `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | ### `node_executing` @@ -257,8 +261,8 @@ recently-emitted `node_executing` event. | `node_id` | str | Node currently running | | `class_type` | str | Node class name (duplicated from the workflow so stateless consumers don't need to track the prior `node_executing`) | | `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | -| `value` | number | Current progress. Typically int (step count); some custom nodes emit float (fractional progress) | -| `max` | number | Total progress; same caveat as `value` | +| `value` | number | Current progress. Typically int (step count); some custom nodes emit float (fractional progress). Defaults to `0` when the server omits the field. | +| `max` | number | Total progress; same caveat as `value`. Defaults to `0` when the server omits the field. | Some custom nodes may emit `value > max` near the end of execution. Agents rendering a progress bar should clamp `value` to `max`. @@ -411,8 +415,8 @@ cannot be assigned without a `client_id`). | `class_type` | str | Node class name | | `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | | `filename` | str | Raw filename as reported by the server | -| `subfolder` | str | Subfolder within the output folder's root. `""` if the server omits or empties the field. | -| `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. Defaults to `"output"` if the server omits the field. | +| `subfolder` | str | Subfolder within the output folder's root. Defaults to `""` when the server omits or empties the field. | +| `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. Defaults to `"output"` when the server omits or empties the field. | | `url` | str | `http(s)://:/view?...` URL — always present, fetch this to get the bytes | ### Fetching output bytes @@ -471,18 +475,18 @@ removed without a schema version bump. | `timeout` | WebSocket `recv` timed out | `timeout_seconds` (float) | | `connection_lost` | WebSocket connection dropped mid-execution | — | | `execution_interrupted` | Server signaled the workflow was interrupted (`execution_interrupted` WS, e.g., via `/interrupt`) | — | -| `execution_error` | A node raised during execution (server emitted `execution_error`) | `node_id` (str), `class_type` (str), `title` (str), `exception_type` (str), `traceback` (str) | +| `execution_error` | A node raised during execution (server emitted `execution_error`) | `node_id` (str), `class_type` (str), `title` (str — same fallback chain as the per-node events: `_meta.title`, else `class_type`, else `node_id`), `exception_type` (str), `traceback` (str) | ### `exception_type` field `exception_type` is provided for diagnostic and observability purposes -(e.g., metrics bucketing). The format is whatever ComfyUI sends — -typically the bare class name for builtins (`RuntimeError`, -`ValueError`) and a dotted module path for non-builtins -(`comfy.model_management.InterruptProcessingException`). May be `""` -when the server omits it. Agents should not key retry or routing -logic on `exception_type`; use `error.kind` for coarse dispatch and -`error.message` for human display. +(e.g., metrics bucketing). **Open set** — the format is whatever +ComfyUI sends, typically the bare class name for builtins +(`RuntimeError`, `ValueError`) and a dotted module path for non- +builtins (`comfy.model_management.InterruptProcessingException`). May +be `""` when the server omits it. Agents should not key retry or +routing logic on `exception_type`; use `error.kind` for coarse +dispatch and `error.message` for human display. ### `traceback` field @@ -662,6 +666,9 @@ For the v1 contract documented here: no human-readable progress bar, no headings); stderr is reserved for framework-level Python errors, uncaught exceptions, and library warnings — agents should not parse it. +- The 7-bit ASCII encoding of stdout (non-ASCII characters in string + fields are emitted as `\uXXXX` JSON escapes, equivalent to + `json.dumps(..., ensure_ascii=True)`). - The `schema_version: 1` field on every event of v1 streams. ### What may change in a non-breaking way From c1e1fed27f07906065eae999ed6893faa00c2ade Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 06:21:48 +0000 Subject: [PATCH 27/32] fix: finish "workflow graph" rename + 3 doc-consistency follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four follow-ups from a fresh full-PR review: * The earlier doc-only commit `16a278c` unified the field terminology to "workflow graph" inside `docs/json-output.md`, but missed five occurrences outside it: three in `cmdline.py` flag help (--json, --print-prompt, --api-key), one in a `run.py` code comment, and one in a test-class docstring. Finished the rename. (`--api-key` help also rephrased from "the prompt body" to "POST /prompt request body" — that one was about the HTTP envelope, not the workflow graph itself, and "prompt body" elided the distinction.) * `prompt_preview` rewording in the prior commit enumerated five "pre-flight failure kinds" (workflow_not_found, workflow_invalid_json, workflow_read_error, workflow_format_invalid, workflow_empty), but the code actually fails before prompt_preview in nine paths — also `connection_error`, `object_info_unavailable`, `conversion_error`, `conversion_crash`. Switched the enumeration to a generic reference to the "Failure pre-flight" stream archetype, which the table already covers correctly. * All six long NDJSON example archetypes carried humanized titles ("Nano Banana 2", "Save Image") in their `queued.nodes` / per-node events / output objects, but the corresponding `prompt_preview` objects had no `_meta.title` on either node — meaning the code's fallback chain would emit `"GeminiNanoBanana2"` / `"SaveImage"` instead. Added `_meta.title` to both nodes in every prompt_preview example so the titles downstream are reproducible. * `validation_error` extras row said "(array of dict; see below)" but the actual shape section is 27 lines further down (past `exception_type` and `traceback` subsections). Replaced with an explicit markdown anchor link to the shape section. Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 6 +++--- comfy_cli/command/run.py | 4 ++-- docs/json-output.md | 24 ++++++++++++------------ tests/comfy_cli/command/test_run_json.py | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index a57c94c8..738b0f35 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -477,7 +477,7 @@ def run( envvar="COMFY_API_KEY", help=( "Comfy API key for API Nodes (Partner Nodes). " - "Embedded in the prompt body as extra_data.api_key_comfy_org on POST /prompt. " + "Embedded in the POST /prompt request body as extra_data.api_key_comfy_org. " "For scripting, prefer the COMFY_API_KEY environment variable so the secret " "stays out of shell history." ), @@ -494,7 +494,7 @@ def run( "--verbose has no effect and Rich progress is suppressed. " "Workflow input accepts both API and UI format JSON (UI input " "triggers a `converted` event before `queued`). The converted " - "prompt body is always emitted as a `prompt_preview` event " + "workflow graph is always emitted as a `prompt_preview` event " "before `queued`, so agents have a full audit trail of what " "the CLI submitted." ), @@ -505,7 +505,7 @@ def run( typer.Option( "--print-prompt", help=( - "Print the API-format prompt body that WOULD be sent to /prompt and exit. " + "Print the API-format workflow graph that WOULD be sent to /prompt and exit. " "Does not POST and does not execute. For UI-format input the workflow is " "converted first (requires a reachable ComfyUI for /object_info); API input " "is printed as-is with no server hit. In --json mode emits a `prompt_preview` " diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 22083b99..7f354d7f 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -443,8 +443,8 @@ def execute( workflow = validated emitter.set_workflow(workflow) - # In JSON mode, always emit the converted prompt body so agents have a - # complete audit trail of what the CLI is about to submit. The event + # In JSON mode, always emit the converted workflow graph so agents have + # a complete audit trail of what the CLI is about to submit. The event # is non-terminal in normal flow and terminal under --print-prompt. if json_mode: emitter.emit_prompt_preview(workflow) diff --git a/docs/json-output.md b/docs/json-output.md index 31b48261..dfee1bd0 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -136,11 +136,11 @@ format. Emitted in `--json` mode once the workflow has been successfully loaded, parsed, and (if UI-format) converted — i.e., in every stream -except the pre-flight failure cases where the workflow was rejected -before conversion (`workflow_not_found`, `workflow_invalid_json`, -`workflow_read_error`, `workflow_format_invalid`, `workflow_empty`). -Fires immediately after the optional `converted` event and immediately -before `queued`. Carries the API-format workflow graph the CLI is +except the **Failure pre-flight** archetype (a `failed`-only stream +where the CLI bails out before it has a workflow to preview: file +errors, parse errors, server-probe / `/object_info` failures, UI +conversion failures, etc.). Fires immediately after the optional +`converted` event and immediately before `queued`. Carries the API-format workflow graph the CLI is about to POST to `/prompt` — the same dict that would land in the request's `prompt` field. Gives agents a complete audit trail of what was submitted, useful for debugging conversions, logging, and @@ -468,7 +468,7 @@ removed without a schema version bump. | `conversion_crash` | UI→API converter raised an unexpected exception | `exception_type` (str) | | `object_info_unavailable` | `/object_info` returned an HTTP error, or an HTTP 200 with an unparseable body | `status_code` (int), `body` (str) | | `connection_error` | ComfyUI server unreachable: `URLError`, `TimeoutError`, or other `OSError` while contacting it (including on `/object_info`) | — | -| `validation_error` | Server returned HTTP 400 with `node_errors` | `node_errors` (array of dict; see below) | +| `validation_error` | Server returned HTTP 400 with `node_errors` | `node_errors` (array of dict; see [shape](#validation_errornode_errors-shape)) | | `client_error` | Server returned an HTTP 4xx response (not validation) | `status_code` (int, 4xx), `body` (str) | | `server_error` | Server returned an HTTP 5xx response | `status_code` (int, 5xx), `body` (str) | | `invalid_response` | Server returned HTTP 2xx but body was unparseable or lacked `prompt_id` | `status_code` (int, 2xx), `body` (str) | @@ -573,7 +573,7 @@ trailing entries. ```json {"event":"converted","schema_version":1,"node_count":2} -{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]},"_meta":{"title":"Save Image"}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"node_progress","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","value":1,"max":4} @@ -593,7 +593,7 @@ server doesn't send an `executed` ws message for it. ### `--no-wait` (API-format input) ```json -{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]},"_meta":{"title":"Save Image"}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} ``` @@ -612,7 +612,7 @@ Exit code: `1`. ```json {"event":"converted","schema_version":1,"node_count":2} -{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","resolution":"5K"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","resolution":"5K"},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]},"_meta":{"title":"Save Image"}}}} {"event":"failed","schema_version":1,"prompt_id":null,"client_id":"fe2a…","elapsed_seconds":0.45,"error":{"kind":"validation_error","message":"Value not in list","node_errors":[{"node_id":"1","errors":[{"type":"value_not_in_list","message":"Value not in list","details":"resolution: '5K' not in ['1K','2K','4K']","extra_info":{"input_name":"resolution","received_value":"5K"}}],"dependent_outputs":["2"],"class_type":"GeminiNanoBanana2"}]}} ``` @@ -621,7 +621,7 @@ Exit code: `1`. ### Failure: node raised during execution ```json -{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]},"_meta":{"title":"Save Image"}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":2.1,"error":{"kind":"execution_error","message":"API key invalid","node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2","exception_type":"RuntimeError","traceback":" File \"/path/to/node.py\", line 42, in execute\n raise RuntimeError(\"API key invalid\")\n"}} @@ -632,7 +632,7 @@ Exit code: `1`. ### Failure: websocket timeout ```json -{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]},"_meta":{"title":"Save Image"}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":30.0,"error":{"kind":"timeout","message":"WebSocket timed out after 30s waiting for server response","timeout_seconds":30.0}} @@ -643,7 +643,7 @@ Exit code: `1`. ### Failure: workflow interrupted ```json -{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]}}}} +{"event":"prompt_preview","schema_version":1,"prompt":{"1":{"class_type":"GeminiNanoBanana2","inputs":{"prompt":"a banana","width":2048,"height":2048},"_meta":{"title":"Nano Banana 2"}},"2":{"class_type":"SaveImage","inputs":{"filename_prefix":"banana_test","images":["1",0]},"_meta":{"title":"Save Image"}}}} {"event":"queued","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","validation_warnings":[],"nodes":[{"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"},{"node_id":"2","class_type":"SaveImage","title":"Save Image"}]} {"event":"node_executing","schema_version":1,"node_id":"1","class_type":"GeminiNanoBanana2","title":"Nano Banana 2"} {"event":"failed","schema_version":1,"prompt_id":"9b1c…","client_id":"fe2a…","elapsed_seconds":3.2,"error":{"kind":"execution_interrupted","message":"Workflow execution was interrupted"}} diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index e92f2569..ef7b0ce8 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -866,7 +866,7 @@ def test_cli_json_with_fresh_consent_state_stays_clean(self, tmp_path): class TestPromptPreviewAlwaysEmitted: - """In JSON mode the converted prompt body is always emitted as a + """In JSON mode the converted workflow graph is always emitted as a `prompt_preview` event before `queued`. Agents debugging conversions or building an audit trail get full visibility without re-running with a flag.""" From 6062026e155427363194f4faf4cbf470a98ad739 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 06:24:57 +0000 Subject: [PATCH 28/32] docs(json-output): canonicalize Open-set convention + shrink local_path archaeology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc polish items from the latest review pass: * `class_type` was marked **Open set** only in the `node_cached` row, even though it appears in five other rows with identical semantics (queued.nodes, node_progress, node_executed, output object, execution_error). Repeating the marker on every row would be noisy. Instead, added a short paragraph at the top of the Event reference section that names the four open-set fields (`class_type`, `category`, `type`, `exception_type`) and explains the convention once. Each field still carries its canonical **Open set** marker at one description; the new paragraph tells readers it applies consistently across every row. * The `local_path` removal paragraph in the "Fetching output bytes" section read as CHANGELOG-style archaeology — five lines listing why we don't emit it. New readers of a reference doc just need to know the field doesn't exist; two lines covers that. Signed-off-by: Alexander Piskun --- docs/json-output.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/json-output.md b/docs/json-output.md index dfee1bd0..2ae88506 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -116,6 +116,13 @@ Agents must ignore events whose `event` value they do not recognise — new event kinds may be added in a backward-compatible manner. Agents must ignore unknown fields on known events for the same reason. +A handful of fields carry values from a server-defined open set rather +than a fixed enumeration: `class_type`, `category`, `type`, and +`exception_type`. Each is flagged **Open set** at one canonical +description below; the same treatment applies wherever that field +appears across events. Agents must accept and pass through unknown +values without keying behaviour on specific strings. + ### `converted` Emitted once if the input workflow was in UI format and was converted to @@ -428,11 +435,11 @@ talking to Cloud. For the local case, a loopback HTTP fetch from a ComfyUI on the same box is cheap — the agent's HTTP client reads through the kernel loopback in the same way it'd read a local file. -The CLI used to also emit a `local_path` field for the same-machine -case, but the heuristic for resolving ComfyUI's output directory was -unreliable in real setups (manual launches, alternate install paths, -multi-install machines, containers with bind-mounted volumes). Agents -should rely on `url` exclusively. +An earlier draft of v1 also emitted a `local_path` field for the +same-machine case; it was removed because resolving ComfyUI's actual +output directory reliably (across manual launches, alternate install +paths, multi-install machines, bind-mounted volumes) wasn't feasible. +Agents should rely on `url` exclusively. ## Error object From ca0a6f9537d1c3f6157dbf1bafeb4f0f067f668c Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 06:41:04 +0000 Subject: [PATCH 29/32] chore: align timeout defaults + mop up a leftover "submitted body" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes: * `cmdline.py`'s `--timeout` was annotated `int | None` but nothing in the code handles `None` — the default is `120` and typer never emits `None` for an int option with a default. Tighten to `int`. * `execute()` and `WorkflowExecution.__init__` still had the old `timeout=30` default, even though the user-facing `--timeout` was bumped to `120` in commit `dd2c80a`. Tests pass `timeout` explicitly in every code path that cares about its value, so this is mostly cosmetic — but it removes a confusing dual source of truth. * `test_run_json.py:362` still said "submitted body" in a comment; the workflow-graph rename in commit `c1e1fed` missed this one. Aligned with the rest of the renamed sites. Signed-off-by: Alexander Piskun --- comfy_cli/cmdline.py | 2 +- comfy_cli/command/run.py | 4 ++-- tests/comfy_cli/command/test_run_json.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/comfy_cli/cmdline.py b/comfy_cli/cmdline.py index 738b0f35..659440d9 100644 --- a/comfy_cli/cmdline.py +++ b/comfy_cli/cmdline.py @@ -459,7 +459,7 @@ def run( typer.Option(help="The port where the ComfyUI instance is running, e.g. 8188."), ] = None, timeout: Annotated[ - int | None, + int, typer.Option( help=( "Per-event timeout in seconds: bails out if the server is silent " diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 7f354d7f..b207ac8b 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -320,7 +320,7 @@ def execute( port, wait=True, verbose=False, - timeout=30, + timeout=120, api_key: str | None = None, json_mode: bool = False, print_prompt: bool = False, @@ -551,7 +551,7 @@ def __init__( port, verbose, progress, - timeout=30, + timeout=120, api_key: str | None = None, emitter: JsonEmitter | None = None, ): diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index ef7b0ce8..79180be5 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -359,7 +359,7 @@ def test_no_wait_emits_prompt_preview_then_queued(self, workflow_file, capsys): mock_open.return_value.read.return_value = json.dumps({"prompt_id": "p123"}).encode() events = _run_execute_capture(workflow_file, capsys, wait=False) # prompt_preview is always emitted in --json before queued so agents - # have a full audit trail of the submitted body. + # have a full audit trail of the submitted workflow graph. assert [e["event"] for e in events] == ["prompt_preview", "queued"] assert events[0]["prompt"] assert events[1]["prompt_id"] == "p123" From a691ca9b6f2e4329251f1d38c49f1ab441972b18 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 10:33:38 +0000 Subject: [PATCH 30/32] refactor(run,json-output): cut redundancy across code, docs, and tests * Introduce JsonEmitter.fail() helper that emits a failed event, prints the red text-mode message, and returns typer.Exit(1) for the caller to raise. Migrates ~10 sites; preserves the explicit if-json-mode pattern where text-mode needs multi-colour Rich markup. * Hoist _MAX_BODY_PREVIEW constant (replaces 5x [:500] literals and fixes 2x [:200] mismatches in HTTP body previews). * Add WorkflowExecution._view_url() to deduplicate /view URL building. * Inline get_node_title() into the two callers (emitter.get_title). * docs: canonicalize the title-fallback rule and Open-set convention into single paragraphs, replacing 6+ in-table repetitions. * tests: parametrize 5 HTTP-status routing tests, extract a single _make_workflow_execution factory, drop a stale docstring bullet, and pin JsonEmitter.fail()'s three behaviours (JSON emission, text-mode wrap, rich_message override). Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 215 ++++++++++------------- docs/json-output.md | 38 ++-- tests/comfy_cli/command/test_run_json.py | 158 +++++++++-------- 3 files changed, 192 insertions(+), 219 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index b207ac8b..321e3c8c 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -22,6 +22,10 @@ # JSON output schema version. Bumped only for breaking changes per docs/json-output.md. SCHEMA_VERSION = 1 +# Maximum bytes of a server response body we surface to the user (or +# embed in a `failed.error.body` field). Anything longer is truncated. +_MAX_BODY_PREVIEW = 500 + def _node_errors_to_list(node_errors) -> list[dict]: """Transform ComfyUI's dict-keyed `node_errors` payload into a list of self-contained records. @@ -244,6 +248,19 @@ def emit_completed(self) -> None: } ) + def fail(self, kind: str, message: str, *, rich_message: str | None = None, **extras) -> typer.Exit: + """Emit a `failed` event (in JSON mode) or print a red text message + (otherwise), then return the `typer.Exit(code=1)` for the caller to + raise. Returning rather than raising keeps `raise ... from e` + chaining clean at call sites. `rich_message` overrides `message` + for the human-readable text only — it is auto-wrapped in + `[bold red]...[/bold red]`. Sites that need multi-colour Rich + markup should emit the failure event explicitly.""" + self.emit_failed(kind, message, **extras) + if not self.json_mode: + pprint(f"[bold red]{rich_message if rich_message is not None else message}[/bold red]") + return typer.Exit(code=1) + def emit_failed(self, kind: str, message: str, **extras) -> None: error = {"kind": kind, "message": message} error.update(extras) @@ -280,10 +297,12 @@ def fetch_object_info(host, port, timeout, emitter=None): "object_info_unavailable", f"Failed to fetch /object_info (HTTP {e.code})", status_code=e.code, - body=body_text[:500], + body=body_text[:_MAX_BODY_PREVIEW], ) else: - pprint(f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body_text[:500]}[/bold red]") + pprint( + f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body_text[:_MAX_BODY_PREVIEW]}[/bold red]" + ) raise typer.Exit(code=1) from e except urllib.error.URLError as e: msg = f"Failed to fetch /object_info from {host}:{port}: {e.reason} (override with --host / --port)" @@ -307,7 +326,7 @@ def fetch_object_info(host, port, timeout, emitter=None): "object_info_unavailable", "Server returned invalid JSON for /object_info", status_code=200, - body=body.decode("utf-8", errors="replace")[:500], + body=body.decode("utf-8", errors="replace")[:_MAX_BODY_PREVIEW], ) else: pprint("[bold red]Failed to fetch /object_info: server returned invalid JSON[/bold red]") @@ -351,31 +370,21 @@ def execute( # unreachable, fetch_object_info() surfaces the same connection_error # kind a few lines later. if not print_prompt and not check_comfy_server_running(port, host, timeout=timeout): - msg = f"ComfyUI not running at {host}:{port} (override with --host / --port)" - if json_mode: - emitter.emit_failed("connection_error", msg) - else: - pprint(f"[bold red]{msg}[/bold red]") - raise typer.Exit(code=1) + raise emitter.fail( + "connection_error", + f"ComfyUI not running at {host}:{port} (override with --host / --port)", + ) try: with open(workflow_name, encoding="utf-8") as f: raw_workflow = json.load(f) except (OSError, UnicodeDecodeError) as e: - if json_mode: - emitter.emit_failed("workflow_read_error", f"Unable to read workflow file: {e}") - else: - pprint(f"[bold red]Unable to read workflow file: {e}[/bold red]") - raise typer.Exit(code=1) from e + raise emitter.fail("workflow_read_error", f"Unable to read workflow file: {e}") from e except json.JSONDecodeError as e: - if json_mode: - emitter.emit_failed( - "workflow_invalid_json", - f"Specified workflow file is not valid JSON: {e}", - ) - else: - pprint(f"[bold red]Specified workflow file is not valid JSON: {e}[/bold red]") - raise typer.Exit(code=1) from e + raise emitter.fail( + "workflow_invalid_json", + f"Specified workflow file is not valid JSON: {e}", + ) from e if is_ui_workflow(raw_workflow): if not json_mode: @@ -384,11 +393,7 @@ def execute( try: workflow = convert_ui_to_api(raw_workflow, object_info) except WorkflowConversionError as e: - if json_mode: - emitter.emit_failed("conversion_error", f"Workflow conversion failed: {e}") - else: - pprint(f"[bold red]Workflow conversion failed: {e}[/bold red]") - raise typer.Exit(code=1) from e + raise emitter.fail("conversion_error", f"Workflow conversion failed: {e}") from e except Exception as e: if json_mode: emitter.emit_failed( @@ -409,37 +414,24 @@ def execute( _tb.print_exc() raise typer.Exit(code=1) from e if not workflow: - if json_mode: - emitter.emit_failed( - "workflow_empty", - "Workflow conversion produced no executable nodes", - ) - else: - pprint("[bold red]Workflow conversion produced no executable nodes[/bold red]") - raise typer.Exit(code=1) + raise emitter.fail("workflow_empty", "Workflow conversion produced no executable nodes") emitter.set_workflow(workflow) if json_mode: emitter.emit_converted(len(workflow)) else: kind, validated = _classify_api_workflow(raw_workflow) if kind == "empty": - if json_mode: - emitter.emit_failed("workflow_empty", "API workflow contains no nodes") - else: - pprint("[bold red]Specified API workflow has no nodes[/bold red]") - raise typer.Exit(code=1) + raise emitter.fail( + "workflow_empty", + "API workflow contains no nodes", + rich_message="Specified API workflow has no nodes", + ) if kind == "invalid": - if json_mode: - emitter.emit_failed( - "workflow_format_invalid", - "Workflow file is neither a ComfyUI API workflow nor an exported UI workflow", - ) - else: - pprint( - "[bold red]Specified workflow is neither a ComfyUI API workflow " - "nor an exported UI workflow[/bold red]" - ) - raise typer.Exit(code=1) + raise emitter.fail( + "workflow_format_invalid", + "Workflow file is neither a ComfyUI API workflow nor an exported UI workflow", + rich_message=("Specified workflow is neither a ComfyUI API workflow nor an exported UI workflow"), + ) workflow = validated emitter.set_workflow(workflow) @@ -500,27 +492,24 @@ def execute( if not json_mode: pprint("[bold green]Workflow queued[/bold green]") except WebSocketTimeoutException: + # Not migrated to emitter.fail(): the text-mode message combines + # a red error line and a yellow remediation hint, which the + # single-colour auto-wrap in fail() can't express. + msg = f"WebSocket timed out after {timeout}s waiting for server response" if json_mode: - emitter.emit_failed( - "timeout", - f"WebSocket timed out after {timeout}s waiting for server response", - timeout_seconds=float(timeout), - ) + emitter.emit_failed("timeout", msg, timeout_seconds=float(timeout)) else: pprint( - f"[bold red]Error: WebSocket timed out after {timeout}s waiting for server response.[/bold red]\n" + f"[bold red]Error: {msg}.[/bold red]\n" "[yellow]For long-running workflows, increase the timeout: comfy run --workflow --timeout 300[/yellow]" ) raise typer.Exit(code=1) except (WebSocketException, ConnectionError, OSError) as e: - if json_mode: - emitter.emit_failed( - "connection_lost", - f"Lost connection to ComfyUI server: {e}", - ) - else: - pprint(f"[bold red]Error: Lost connection to ComfyUI server: {e}[/bold red]") - raise typer.Exit(code=1) + raise emitter.fail( + "connection_lost", + f"Lost connection to ComfyUI server: {e}", + rich_message=f"Error: Lost connection to ComfyUI server: {e}", + ) finally: if progress is not None: progress.stop() @@ -602,59 +591,47 @@ def queue(self): raise typer.Exit(code=1) from e except urllib.error.URLError as e: self._stop_progress() - msg = f"Cannot reach server at {self.host}:{self.port}: {e.reason}" - if self.emitter.json_mode: - self.emitter.emit_failed("connection_error", msg) - else: - pprint(f"[bold red]{msg}[/bold red]") - raise typer.Exit(code=1) from e + raise self.emitter.fail( + "connection_error", + f"Cannot reach server at {self.host}:{self.port}: {e.reason}", + ) from e except TimeoutError as e: self._stop_progress() - msg = f"Connection to {self.host}:{self.port} timed out: {e}" - if self.emitter.json_mode: - self.emitter.emit_failed("connection_error", msg) - else: - pprint(f"[bold red]{msg}[/bold red]") - raise typer.Exit(code=1) from e + raise self.emitter.fail( + "connection_error", + f"Connection to {self.host}:{self.port} timed out: {e}", + ) from e except OSError as e: self._stop_progress() - msg = f"Network error contacting {self.host}:{self.port}: {e}" - if self.emitter.json_mode: - self.emitter.emit_failed("connection_error", msg) - else: - pprint(f"[bold red]{msg}[/bold red]") - raise typer.Exit(code=1) from e + raise self.emitter.fail( + "connection_error", + f"Network error contacting {self.host}:{self.port}: {e}", + ) from e try: body = json.loads(raw_body) if raw_body else None except json.JSONDecodeError as e: self._stop_progress() - body_str = raw_body.decode("utf-8", errors="replace")[:500] - if self.emitter.json_mode: - self.emitter.emit_failed( - "invalid_response", - "Server returned HTTP 200 with unparseable body", - status_code=200, - body=body_str, - ) - else: - pprint(f"[bold red]Server returned HTTP 200 with unparseable body: {body_str[:200]}[/bold red]") - raise typer.Exit(code=1) from e + body_str = raw_body.decode("utf-8", errors="replace")[:_MAX_BODY_PREVIEW] + raise self.emitter.fail( + "invalid_response", + "Server returned HTTP 200 with unparseable body", + rich_message=f"Server returned HTTP 200 with unparseable body: {body_str}", + status_code=200, + body=body_str, + ) from e prompt_id = body.get("prompt_id") if isinstance(body, dict) else None if not isinstance(prompt_id, str) or not prompt_id: self._stop_progress() - body_str = json.dumps(body)[:500] if body is not None else "" - if self.emitter.json_mode: - self.emitter.emit_failed( - "invalid_response", - "Server returned HTTP 200 without a prompt_id", - status_code=200, - body=body_str, - ) - else: - pprint(f"[bold red]Server returned HTTP 200 without a prompt_id: {body_str[:200]}[/bold red]") - raise typer.Exit(code=1) + body_str = json.dumps(body)[:_MAX_BODY_PREVIEW] if body is not None else "" + raise self.emitter.fail( + "invalid_response", + "Server returned HTTP 200 without a prompt_id", + rich_message=f"Server returned HTTP 200 without a prompt_id: {body_str}", + status_code=200, + body=body_str, + ) self.prompt_id = prompt_id @@ -695,7 +672,7 @@ def _handle_submit_http_error(self, e: urllib.error.HTTPError) -> None: kind, f"Server returned HTTP {code}", status_code=code, - body=body_str[:500], + body=body_str[:_MAX_BODY_PREVIEW], ) else: if code == 500: @@ -703,7 +680,7 @@ def _handle_submit_http_error(self, e: urllib.error.HTTPError) -> None: elif code == 400 and isinstance(body, dict): pprint(f"[bold red]Error running workflow\n{json.dumps(body, indent=2)}[/bold red]") else: - pprint(f"[bold red]Error running workflow (HTTP {code})\n{body_str[:500]}[/bold red]") + pprint(f"[bold red]Error running workflow (HTTP {code})\n{body_str[:_MAX_BODY_PREVIEW]}[/bold red]") def _emit_validation_error(self, node_errors: dict) -> None: if self.emitter.json_mode: @@ -751,14 +728,6 @@ def update_overall_progress(self): return self.progress.update(self.overall_task, completed=self.total_nodes - len(self.remaining_nodes)) - def get_node_title(self, node_id): - node = self.workflow.get(node_id) - if node is None: - return str(node_id) - if "_meta" in node and "title" in node["_meta"]: - return node["_meta"]["title"] - return node["class_type"] - def log_node(self, type, node_id): if not self.verbose: return @@ -771,7 +740,7 @@ def log_node(self, type, node_id): pprint(f"{type} : [bright_black]({node_id})[/]") return class_type = node["class_type"] - title = self.get_node_title(node_id) + title = self.emitter.get_title(node_id) if title != class_type: title += f"[bright_black] - {class_type}[/]" @@ -798,8 +767,11 @@ def format_image_path(self, img): if (candidate == type_root or candidate.startswith(type_root + os.sep)) and os.path.isfile(candidate): return candidate - url_params = {"filename": filename, "subfolder": subfolder, "type": output_type} - return f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" + return self._view_url(filename, subfolder, output_type) + + def _view_url(self, filename: str, subfolder: str, file_type: str) -> str: + params = {"filename": filename, "subfolder": subfolder, "type": file_type} + return f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(params)}" def _text_mode_workspace_path(self) -> str | None: # workspace_manager.get_workspace_path() can print a warning and @@ -819,9 +791,6 @@ def _build_output_object(self, node_id, category, item) -> dict: subfolder = item.get("subfolder") or "" file_type = item.get("type") or "output" - url_params = {"filename": filename, "subfolder": subfolder, "type": file_type} - url = f"http://{self.host}:{self.port}/view?{urllib.parse.urlencode(url_params)}" - return { "category": category, "node_id": node_id, @@ -830,7 +799,7 @@ def _build_output_object(self, node_id, category, item) -> dict: "filename": filename, "subfolder": subfolder, "type": file_type, - "url": url, + "url": self._view_url(filename, subfolder, file_type), } def on_message(self, message): @@ -907,7 +876,7 @@ def on_progress(self, data): if self.progress_task is not None: self.progress.remove_task(self.progress_task) self.progress_task = self.progress.add_task( - self.get_node_title(node), total=max_val, progress_type="node" + self.emitter.get_title(node), total=max_val, progress_type="node" ) self.progress.update(self.progress_task, completed=value) if self.emitter.json_mode: diff --git a/docs/json-output.md b/docs/json-output.md index 2ae88506..31f3db1f 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -123,6 +123,12 @@ description below; the same treatment applies wherever that field appears across events. Agents must accept and pass through unknown values without keying behaviour on specific strings. +Every event that names a single node also carries a `title` field — +the human-readable label to show in a per-node UI. The contract for +`title` is the same wherever it appears: **`_meta.title` if present, +else `class_type`, else `node_id`**. Per-event field tables list it +simply as "display label" rather than repeating the chain. + ### `converted` Emitted once if the input workflow was in UI format and was converted to @@ -199,7 +205,7 @@ handle, which can be used to correlate against `/history/{prompt_id}`. | `prompt_id` | str | Server-assigned prompt UUID | | `client_id` | str | Client-generated UUID (sent with `/prompt`) | | `validation_warnings` | array of dict | List of per-node validation issues that ComfyUI reported alongside a successful queue (some output chains validated, others didn't). Same record shape as `validation_error.node_errors` (see below). Empty (`[]`) in the common case. | -| `nodes` | array of dict | Manifest of every node in the submitted (post-conversion) workflow. Each entry has `node_id` (str), `class_type` (str), and `title` (str — `_meta.title` if present, else `class_type`, else `node_id`). Lets piped consumers (who don't have the workflow file at hand) render a per-node UI immediately without waiting for `completed`. | +| `nodes` | array of dict | Manifest of every node in the submitted (post-conversion) workflow. Each entry has `node_id` (str), `class_type` (str), and `title` (str — display label, see canonical `title` rule). Lets piped consumers (who don't have the workflow file at hand) render a per-node UI immediately without waiting for `completed`. | The `validation_warnings` field exists specifically for the case where ComfyUI's `validate_prompt` returns success because at least one output @@ -230,7 +236,7 @@ See [completed](#completed) for the resulting semantics of | `schema_version` | int | `1` | | `node_id` | str | Node key in the workflow dict | | `class_type` | str | Node class name. **Open set** — current ComfyUI versions emit names like `KSampler`, `SaveImage`, plus arbitrary custom-node class names; agents must accept and pass through unknown values without keying behaviour on specific strings. | -| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | +| `title` | str | display label (see canonical `title` rule above) | ### `node_executing` @@ -267,7 +273,7 @@ recently-emitted `node_executing` event. | `schema_version` | int | `1` | | `node_id` | str | Node currently running | | `class_type` | str | Node class name (duplicated from the workflow so stateless consumers don't need to track the prior `node_executing`) | -| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | +| `title` | str | display label (see canonical `title` rule above) | | `value` | number | Current progress. Typically int (step count); some custom nodes emit float (fractional progress). Defaults to `0` when the server omits the field. | | `max` | number | Total progress; same caveat as `value`. Defaults to `0` when the server omits the field. | @@ -312,7 +318,7 @@ cached output-bearing node also emits `node_executed` (in addition to | `schema_version` | int | `1` | | `node_id` | str | Node key | | `class_type` | str | Node class name | -| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | +| `title` | str | display label (see canonical `title` rule above) | | `outputs` | array of Output | File-like outputs (empty if none) | `outputs` is populated by iterating each key in ComfyUI's @@ -350,7 +356,7 @@ output list, and node-execution metadata. | `elapsed_seconds` | float | Wall-clock duration from start of `comfy run` (same clock as `failed.elapsed_seconds`) | | `outputs` | array of Output | All file-like outputs across all nodes (empty if none) | | `cached_node_ids` | array of str | Node IDs the server reported as cached (via `execution_cached`) | -| `executed_node_ids` | array of str | Node IDs the server executor touched in this run — the union of every `node_id` that appeared in a `node_executing` or `node_executed` event. Includes intermediate compute nodes (CheckpointLoaderSimple, KSampler, etc.) that don't surface output to the client and don't get a dedicated `node_executed` event, in addition to leaf/output nodes that do. | +| `executed_node_ids` | array of str | Node IDs the executor *ran* — the union of every `node_id` that appeared in a `node_executing` or `node_executed` event. Named for what the executor did (run a node), broader than the leaf-only `node_executed` event: includes intermediate compute nodes (CheckpointLoaderSimple, KSampler, etc.) that don't surface output to the client. | `cached_node_ids` and `executed_node_ids` are independent signals about what the server reported. **They may overlap**: a cached output-bearing @@ -358,11 +364,6 @@ node emits both `execution_cached` and `executed`, so it appears in both lists. Agents wanting "ran fresh, not from cache" should compute `set(executed_node_ids) - set(cached_node_ids)`. -Note: `executed_node_ids` is named for what the executor *did* (run a -node), not for which event was emitted. The leaf-only `node_executed` -event remains the per-node signal for "this node produced a visible -artifact"; the aggregate field here is the broader "this node ran". - ### `failed` Terminal event on any failure. The `error.kind` discriminator is the @@ -420,7 +421,7 @@ cannot be assigned without a `client_id`). | `category` | str | Output category as keyed by ComfyUI's `executed.output` dict. **Open set.** Current ComfyUI versions emit values like `images`, `audio`, `3d`, `latents`; agents must accept and pass through unknown values. | | `node_id` | str | Node that produced the output | | `class_type` | str | Node class name | -| `title` | str | `_meta.title` if present, else `class_type`, else `node_id` | +| `title` | str | display label (see canonical `title` rule above) | | `filename` | str | Raw filename as reported by the server | | `subfolder` | str | Subfolder within the output folder's root. Defaults to `""` when the server omits or empties the field. | | `type` | str | ComfyUI output folder discriminator. **Open set.** Current ComfyUI versions emit `output`, `temp`, `input`; agents must accept and pass through unknown values. Defaults to `"output"` when the server omits or empties the field. | @@ -482,7 +483,7 @@ removed without a schema version bump. | `timeout` | WebSocket `recv` timed out | `timeout_seconds` (float) | | `connection_lost` | WebSocket connection dropped mid-execution | — | | `execution_interrupted` | Server signaled the workflow was interrupted (`execution_interrupted` WS, e.g., via `/interrupt`) | — | -| `execution_error` | A node raised during execution (server emitted `execution_error`) | `node_id` (str), `class_type` (str), `title` (str — same fallback chain as the per-node events: `_meta.title`, else `class_type`, else `node_id`), `exception_type` (str), `traceback` (str) | +| `execution_error` | A node raised during execution (server emitted `execution_error`) | `node_id` (str), `class_type` (str), `title` (str — display label, see canonical `title` rule), `exception_type` (str), `traceback` (str) | ### `exception_type` field @@ -693,14 +694,9 @@ than being treated as an additive change. ### Why exit codes are not granular -In `--json` mode, exit code is always `0` (`completed`, or the -mode-specific terminal `queued`/`prompt_preview`) or `1` (`failed`). -This is part of the v1 JSON contract and will not change without a -`schema_version` bump. Non-`--json` exit codes are documented elsewhere -and not governed by this contract. - -The `error.kind` string namespace is more expressive than granular exit -codes — no number bikeshedding, easy to add sub-categories — and it is -co-located with the rich error context. Granular exit codes can be +The 0/1 mapping (defined in "What is stable" above) intentionally +trades resolution for stability. `error.kind` is the expressive, +extensible discriminator — agents dispatch on it; the exit code is +just a coarse "did we succeed?" signal. Granular exit codes can be introduced later for non-`--json` callers in a separate, evidence-driven change without breaking the JSON contract. diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 79180be5..50c3184c 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -8,7 +8,6 @@ - stream archetypes from the spec table - the duck-typed output filter rule - the cached/executed overlap semantics - - per-line stdout flushing (so live consumers see events as they happen) """ from __future__ import annotations @@ -93,6 +92,27 @@ def _make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError: ) +def _make_workflow_execution(workflow, *, with_progress: bool = False, json_mode: bool = True): + """Build a `WorkflowExecution` with a `JsonEmitter` pre-wired to the + workflow. `with_progress=True` attaches a MagicMock progress object — + needed by tests that exercise `update_overall_progress`.""" + e = JsonEmitter(json_mode=json_mode) + e.set_workflow(workflow) + progress = None + if with_progress: + progress = MagicMock() + progress.add_task.return_value = 0 + return WorkflowExecution( + workflow=workflow, + host="127.0.0.1", + port=8188, + verbose=False, + progress=progress, + timeout=30, + emitter=e, + ) + + class TestJsonEmitter: """Direct emitter tests — verify event shape, schema_version, no-op in non-JSON mode.""" @@ -246,6 +266,49 @@ def test_failed_event_carries_universal_and_extras(self, capsys): assert event["prompt_id"] is None # never set assert isinstance(event["elapsed_seconds"], float) + def test_fail_helper_emits_event_and_returns_exit(self, capsys): + # JSON mode: the helper emits a `failed` event, returns a typer.Exit + # (not raised — caller raises so `from e` chaining is clean), and + # does NOT print prose (stdout stays NDJSON-only). + e = JsonEmitter(json_mode=True) + e.set_client_id("c") + result = e.fail("client_error", "Bad request", status_code=403, body="forbidden") + assert isinstance(result, typer.Exit) + assert result.exit_code == 1 + out = capsys.readouterr().out + event = json.loads(out.strip()) + assert event["event"] == "failed" + assert event["error"]["kind"] == "client_error" + assert event["error"]["message"] == "Bad request" + assert event["error"]["status_code"] == 403 + + def test_fail_helper_wraps_text_mode_message_in_bold_red(self, capsys): + # Non-JSON mode: the helper auto-wraps `message` in + # [bold red]...[/bold red] and returns typer.Exit (no event on stdout). + e = JsonEmitter(json_mode=False) + result = e.fail("client_error", "Bad request") + assert isinstance(result, typer.Exit) + out = capsys.readouterr().out + assert "Bad request" in out + # Rich strips markup tags but still applies the formatting; the + # message content must reach stdout. No NDJSON in text mode. + assert "failed" not in out # no event was emitted + + def test_fail_helper_rich_message_overrides_text_only(self, capsys): + # `rich_message` replaces the auto-wrapped text; JSON event still + # carries the original `message`. + e = JsonEmitter(json_mode=True) + e.set_client_id("c") + e.fail("client_error", "machine-readable", rich_message="human-friendly") + event = json.loads(capsys.readouterr().out.strip()) + assert event["error"]["message"] == "machine-readable" + # In text mode it'd flip — verify separately. + e2 = JsonEmitter(json_mode=False) + e2.fail("client_error", "machine-readable", rich_message="human-friendly") + out = capsys.readouterr().out + assert "human-friendly" in out + assert "machine-readable" not in out + def test_title_falls_back_to_class_type(self, simple_workflow): e = JsonEmitter(json_mode=True) # Drop _meta.title from node 1 @@ -427,31 +490,22 @@ def test_400_with_node_errors_routes_to_validation_error(self, workflow_file, ca assert isinstance(node_errors, list) assert any(rec["node_id"] == "1" for rec in node_errors) - def test_401_routes_to_client_error(self, workflow_file, capsys): - events = self._setup_and_run(workflow_file, None, capsys, status=401, body=b"unauthorized") - terminal = events[-1] - assert terminal["error"]["kind"] == "client_error" - assert terminal["error"]["status_code"] == 401 - - def test_403_routes_to_client_error(self, workflow_file, capsys): - events = self._setup_and_run(workflow_file, None, capsys, status=403, body=b"forbidden") - assert events[-1]["error"]["kind"] == "client_error" - assert events[-1]["error"]["status_code"] == 403 - - def test_429_routes_to_client_error(self, workflow_file, capsys): - events = self._setup_and_run(workflow_file, None, capsys, status=429, body=b"too many") - assert events[-1]["error"]["kind"] == "client_error" - - def test_500_routes_to_server_error(self, workflow_file, capsys): - events = self._setup_and_run(workflow_file, None, capsys, status=500, body=b"oops") + @pytest.mark.parametrize( + "status,body,kind", + [ + (401, b"unauthorized", "client_error"), + (403, b"forbidden", "client_error"), + (429, b"too many", "client_error"), + (500, b"oops", "server_error"), + (503, b"down", "server_error"), + ], + ) + def test_http_status_routes_to_kind(self, workflow_file, capsys, status, body, kind): + events = self._setup_and_run(workflow_file, None, capsys, status=status, body=body) terminal = events[-1] - assert terminal["error"]["kind"] == "server_error" - assert terminal["error"]["status_code"] == 500 - assert terminal["error"]["body"] == "oops" - - def test_503_routes_to_server_error(self, workflow_file, capsys): - events = self._setup_and_run(workflow_file, None, capsys, status=503, body=b"down") - assert events[-1]["error"]["kind"] == "server_error" + assert terminal["error"]["kind"] == kind + assert terminal["error"]["status_code"] == status + assert terminal["error"]["body"] == body.decode() def test_200_with_non_json_body_routes_to_invalid_response(self, workflow_file, capsys): with ( @@ -626,19 +680,7 @@ def test_execution_interrupted(self, workflow_file, capsys): class TestOutputObject: def _exec(self, simple_workflow): - progress = MagicMock() - progress.add_task.return_value = 0 - e = JsonEmitter(json_mode=True) - e.set_workflow(simple_workflow) - return WorkflowExecution( - workflow=simple_workflow, - host="127.0.0.1", - port=8188, - verbose=False, - progress=progress, - timeout=30, - emitter=e, - ) + return _make_workflow_execution(simple_workflow, with_progress=True) def test_duck_typed_filter_skips_strings(self, simple_workflow, capsys): """ComfyUI's `text` output key emits a list of strings; the filter must skip non-file shapes.""" @@ -1181,19 +1223,7 @@ class TestNodeExecutedFiresEvenWithoutOutputs: prompt, even when there's no `output` dict or it's empty (outputs=[]).""" def _exec(self, simple_workflow): - progress = MagicMock() - progress.add_task.return_value = 0 - e = JsonEmitter(json_mode=True) - e.set_workflow(simple_workflow) - return WorkflowExecution( - workflow=simple_workflow, - host="127.0.0.1", - port=8188, - verbose=False, - progress=progress, - timeout=30, - emitter=e, - ) + return _make_workflow_execution(simple_workflow, with_progress=True) def test_output_node_id_coerced_to_str(self, simple_workflow, capsys): # If the server ever sends `node` as an int, every other emit site @@ -1251,19 +1281,7 @@ class TestFormatImagePathDefensive: keys — the duck-type filter only requires `filename`.""" def _exec(self, simple_workflow): - progress = MagicMock() - progress.add_task.return_value = 0 - e = JsonEmitter(json_mode=True) - e.set_workflow(simple_workflow) - return WorkflowExecution( - workflow=simple_workflow, - host="127.0.0.1", - port=8188, - verbose=False, - progress=progress, - timeout=30, - emitter=e, - ) + return _make_workflow_execution(simple_workflow, with_progress=True) def test_no_keyerror_on_missing_type(self, simple_workflow): ex = self._exec(simple_workflow) @@ -1338,17 +1356,7 @@ def _make_workflow(self): } def _make_exec(self, workflow): - e = JsonEmitter(json_mode=True) - e.set_workflow(workflow) - return WorkflowExecution( - workflow=workflow, - host="127.0.0.1", - port=8188, - verbose=False, - progress=None, - timeout=30, - emitter=e, - ) + return _make_workflow_execution(workflow) def test_object_info_timeout_routes_to_connection_error(self, capsys): """fetch_object_info(timeout → connection_error). Previously untested.""" From d06dd5e1cd340474d6b3d0eb5580ddf030f22a48 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 10:34:01 +0000 Subject: [PATCH 31/32] fix(run): catch UnicodeDecodeError alongside JSONDecodeError on /prompt body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json.loads(bytes) runs encoding detection before parsing — a body whose first bytes look like a UTF-16/UTF-32 BOM (e.g. b"\x00\x01\xff\xfe...") steers it down the UTF-16 decode path, which raises UnicodeDecodeError on invalid sequences. The previous handler only caught JSONDecodeError, so the exception escaped: no terminal failed event was emitted (breaking the contract for exit 1) and a multi-KB Rich traceback was printed to stderr (breaking the "stderr stays clean in JSON mode" guarantee). Plausible trigger in the wild is a misconfigured reverse proxy serving a UTF-16-encoded HTML error page on the /prompt route. Apply the same fix to _handle_submit_http_error where the pattern is mirrored. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 4 ++-- tests/comfy_cli/command/test_run_json.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 321e3c8c..24e0b438 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -610,7 +610,7 @@ def queue(self): try: body = json.loads(raw_body) if raw_body else None - except json.JSONDecodeError as e: + except (json.JSONDecodeError, UnicodeDecodeError) as e: self._stop_progress() body_str = raw_body.decode("utf-8", errors="replace")[:_MAX_BODY_PREVIEW] raise self.emitter.fail( @@ -651,7 +651,7 @@ def _handle_submit_http_error(self, e: urllib.error.HTTPError) -> None: pass try: body = json.loads(raw) if raw else None - except json.JSONDecodeError: + except (json.JSONDecodeError, UnicodeDecodeError): body = None body_str = (raw or b"").decode("utf-8", errors="replace") self._stop_progress() diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index 50c3184c..a7c2524d 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -530,6 +530,21 @@ def test_200_without_prompt_id_routes_to_invalid_response(self, workflow_file, c terminal = events[-1] assert terminal["error"]["kind"] == "invalid_response" + def test_200_with_utf16_bom_body_routes_to_invalid_response(self, workflow_file, capsys): + # `json.loads(bytes)` sniffs encoding before parsing — a UTF-16 BOM + # makes it raise `UnicodeDecodeError`, not `JSONDecodeError`. + with ( + patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), + patch("comfy_cli.command.run.request.urlopen") as mock_open, + patch("comfy_cli.command.run.WebSocket"), + ): + mock_open.return_value.read.return_value = b"\x00\x01\xff\xfeNOT JSON \x80\x81" + events = _run_execute_capture(workflow_file, capsys) + terminal = events[-1] + assert terminal["event"] == "failed" + assert terminal["error"]["kind"] == "invalid_response" + assert terminal["error"]["status_code"] == 200 + def test_url_error_routes_to_connection_error(self, workflow_file, capsys): with ( patch("comfy_cli.command.run.check_comfy_server_running", return_value=True), From f8da96c14b23974baa8553aaa065033ae3ecae35 Mon Sep 17 00:00:00 2001 From: Alexander Piskun Date: Tue, 19 May 2026 14:00:01 +0000 Subject: [PATCH 32/32] fix(run): emit failed event on client SIGINT (Ctrl-C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, KeyboardInterrupt would escape execute() and Python's default handler would tear the process down with exit code 130 — but the JSON stream had no terminal event, violating the exit-code-non-zero-implies-failed-event invariant. Reuse the existing execution_interrupted kind: from a consumer's perspective, the remediation is identical to a server-side /interrupt and the protocol stays smaller. Broaden the doc row to cover both sources. The new handler sits inside execute()'s existing try block so it only catches Ctrl-C during connect / queue / watch_execution. Ctrl-C during the pre-flight (file read, server probe, conversion) is still uncaught — a narrow window, accepted for now. Signed-off-by: Alexander Piskun --- comfy_cli/command/run.py | 2 ++ docs/json-output.md | 2 +- tests/comfy_cli/command/test_run_json.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/comfy_cli/command/run.py b/comfy_cli/command/run.py index 24e0b438..f74810e3 100644 --- a/comfy_cli/command/run.py +++ b/comfy_cli/command/run.py @@ -510,6 +510,8 @@ def execute( f"Lost connection to ComfyUI server: {e}", rich_message=f"Error: Lost connection to ComfyUI server: {e}", ) + except KeyboardInterrupt: + raise emitter.fail("execution_interrupted", "Interrupted by user") from None finally: if progress is not None: progress.stop() diff --git a/docs/json-output.md b/docs/json-output.md index 31f3db1f..c5f83bbb 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -482,7 +482,7 @@ removed without a schema version bump. | `invalid_response` | Server returned HTTP 2xx but body was unparseable or lacked `prompt_id` | `status_code` (int, 2xx), `body` (str) | | `timeout` | WebSocket `recv` timed out | `timeout_seconds` (float) | | `connection_lost` | WebSocket connection dropped mid-execution | — | -| `execution_interrupted` | Server signaled the workflow was interrupted (`execution_interrupted` WS, e.g., via `/interrupt`) | — | +| `execution_interrupted` | Workflow was interrupted — either by the server (`execution_interrupted` WS, e.g., via `/interrupt`) or by the client process receiving `SIGINT` (Ctrl-C) | — | | `execution_error` | A node raised during execution (server emitted `execution_error`) | `node_id` (str), `class_type` (str), `title` (str — display label, see canonical `title` rule), `exception_type` (str), `traceback` (str) | ### `exception_type` field diff --git a/tests/comfy_cli/command/test_run_json.py b/tests/comfy_cli/command/test_run_json.py index a7c2524d..773d9363 100644 --- a/tests/comfy_cli/command/test_run_json.py +++ b/tests/comfy_cli/command/test_run_json.py @@ -617,6 +617,16 @@ def test_connection_lost_websocket(self, workflow_file, capsys): terminal = events[-1] assert terminal["error"]["kind"] == "connection_lost" + def test_keyboard_interrupt_emits_execution_interrupted(self, workflow_file, capsys): + events = self._run_with_ws_messages( + workflow_file, + KeyboardInterrupt(), + capsys, + ) + terminal = events[-1] + assert terminal["event"] == "failed" + assert terminal["error"]["kind"] == "execution_interrupted" + def test_malformed_frame_is_skipped_run_completes(self, workflow_file, capsys): """We silently skip malformed JSON frames mid-stream. A valid executing(node=None) frame following the bad one should still