fix(run): conform the cloud NDJSON stream to the documented event contract - #656
fix(run): conform the cloud NDJSON stream to the documented event contract#656mattmillerai wants to merge 4 commits into
Conversation
…tract
`comfy run --where cloud` had drifted from docs/json-output.md: it emitted no
`converted` event, emitted `prompt_preview` only under `--print-prompt`, and
emitted `queued` BEFORE submitting with `{workflow, base_url}` instead of the
documented `{prompt_id, client_id, validation_warnings, nodes}` — so a consumer
treating `queued` as "the server has it" was wrong. Under `--wait` the submit
was announced as `executing`, a per-node event type, with no `node` field.
Cloud now conforms:
- emits `converted` after a client-side UI->API conversion
- emits `prompt_preview` unconditionally, not just under `--print-prompt`
- emits `queued` AFTER a successful submit, with the documented fields plus
`base_url`; a failed submit emits no `queued` at all
- `--print-prompt` returns instead of raising `typer.Exit(0)`, matching local
(both still exit 0 through the CLI)
- the async envelope carries `watcher_spawned`, like local
- `prompt_rejected` details carry the documented array-of-records
`node_errors`, not the server's id-keyed dict
`queued.nodes` is now built by one shared `workflow_manifest()` helper so the
two pipelines cannot diverge on the manifest shape again. The pipelines
themselves are deliberately left unmerged.
Docs gain a "Per-target differences" section for what genuinely cannot match
(no per-node events on cloud, host/port vs base_url, local-only
cached/executed_node_ids, cloud-only warnings, cloud-only error codes), and the
published run.json / run_event.json schemas are corrected to accept the real
streams they claim to describe.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 51 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe run command now aligns local and cloud execution paths. It adds shared workflow manifests, consistent preview and queue events, normalized node errors, expanded JSON schemas, updated documentation, and lifecycle and NDJSON contract tests. ChangesRun execution and output contracts
Sequence Diagram(s)sequenceDiagram
participant CLI
participant CloudExecution
participant CloudAPI
participant Watcher
CLI->>CloudExecution: convert workflow
CloudExecution-->>CLI: converted and prompt_preview
CloudExecution->>CloudAPI: submit prompt
CloudAPI-->>CloudExecution: prompt metadata and warnings
CloudExecution-->>CLI: queued with workflow manifest
CloudExecution->>Watcher: start detached watcher
Watcher-->>CLI: watcher_spawned status
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 7 finding(s).
| Severity | Count |
|---|---|
| 🟡 Medium | 3 |
| 🟢 Low | 3 |
| ⚪ Nit | 1 |
Panel: 8/8 reviewers contributed findings.
…de shape
Cursor review panel follow-ups on the cloud event-contract conformance:
- `_node_errors_to_list` silently dropped any record whose value was not a
dict, so a server payload like `{"1": "missing input"}` produced
`details.node_errors == []` under a "rejected 1 node(s)" message, with the
hint pointing at an empty array. Non-dict records are now wrapped as
`{"node_id", "errors": [...]}` so the count always matches the array and the
only diagnostic on that path survives.
- The same helper built `{"node_id": ...}` and then spread the server record
over it, letting a record-supplied `node_id` shadow the authoritative map
key. The record is applied first now, so the map key always wins.
- The cloud `prompt_rejected` hint builder duck-typed every level of the
server payload; a non-dict `errors` item raised AttributeError, aborting the
process with a traceback and NO envelope — breaking the "exactly one
terminal envelope" guarantee this contract rests on. Each level is
shape-checked, and non-dict records now get a hint line too.
- `run_event.json` typed `nodes.items` as `["object", "string"]` so one
property could serve both `queued` (records) and `jobs watch`'s
`execution_cached` (bare ids) — which meant the schema could no longer
reject a `queued` that regressed to plain node-id strings. Discriminated on
`type` via `if`/`then`, restoring the guarantee.
- Noted that the `type` enum is advisory and open-ended: adding an event type
is additive and does not bump `event/1`, so a strict consumer must ignore
unknown types rather than fail validation.
- Corrected the stream-archetype table. "Failure pre-flight -> envelope(error)"
was wrong for the CQL pre-flight, the spend gate, and cloud auth: all three
run AFTER `prompt_preview` on both targets, so those refusals emit
`prompt_preview` first. Split the row on whether a parsed graph is in hand
yet, documented that `converted` can precede a `workflow_not_api_format`
envelope, and flagged that `prompt_preview` carries the full graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
The following carry
|
bigcat88
left a comment
There was a problem hiding this comment.
Requesting changes on one line. Everything else here holds up — I ran the whole thing against the real Comfy Cloud staging environment rather than fixtures, and the contract really was as broken as you describe.
Blocker: --print-prompt --where cloud stops emitting execution_success
The description says:
--print-promptreturns instead ofraise typer.Exit(0)…cmdline.py'selse:branch tracksexecution_successon a normal return exactly as itsexcept typer.Exitbranch does forExit(0), so telemetry and exit code are unchanged
The exit code is unchanged. The telemetry isn't. cmdline.py:1004 is a bare return immediately after the execute_cloud(...) call — inside the same try whose else: (line 1043) does the tracking — and Python skips an else: clause when the try suite leaves via return. The only thing reaching that tracking call on the cloud branch was the except typer.Exit handler, which is exactly what this hunk removes.
Measured on the wire, with a MITM'd telemetry endpoint capturing the real Mixpanel and PostHog uploads, running comfy run --where cloud --print-prompt against staging:
| Mixpanel | PostHog | |
|---|---|---|
main |
run, execution_success |
cli:execution_start, cli:execution_success |
| this branch | run |
cli:execution_start |
Two adjacent facts that argue for fixing it properly rather than reverting the hunk:
- The local path really does track it, so the asymmetry this hunk set out to remove is the one it creates.
execute()'s print-prompt branch alsoreturns (run/__init__.py:272) — butrun_inner.execute(...)is the last statement of the try suite with noreturnafter it, so control falls through toelse:. - No cloud run emits
execution_successtoday either. Samereturnat 1004. I measured a successful--no-waitcloud submit onmain:run+cli:execution_start, no success event. So cloud success telemetry has been silently missing all along, and this hunk removes the one case that still worked by accident.
Fix that closes both:
if decision.target is where_module.WhereTarget.CLOUD:
where_module.cloud_preflight_or_exit()
run_inner.execute_cloud(...)
else:
from comfy_cli.host_port import parse_host_port_arg, resolve_host_port
...
run_inner.execute(...)
# try suite ends normally on both targets -> else: firesIf you'd rather keep this PR tightly scoped, reverting line 820 to raise typer.Exit(code=0) restores the status quo — but it leaves the pre-existing cloud gap in place, and I'd rather see that one go.
Everything else — verified live against staging
Every stream shape below was run against https://stagingcloud.comfy.org on both main and this branch.
The contract really was broken
Real async cloud submit (--no-wait), same workflow (SD1.5 txt2img), same server:
main:queued {workflow, base_url}— emitted before the POST, with noprompt_id, noclient_id, nonodes, novalidation_warnings. Noprompt_previewanywhere. Envelope missingwatcher_spawned.- this branch:
prompt_preview→queued {prompt_id, client_id, validation_warnings: [], nodes: [7 records, titles included], base_url}→envelope(ok, status="queued", watcher_spawned=true).
And a live --wait run (a real job, executed and returned): prompt_preview → queued → envelope(ok, status="completed") carrying outputs (1), outputs_by_node, outputs_by_item, warnings: [], base_url, state_file, elapsed_seconds — no per-node events and no cached_node_ids/executed_node_ids, exactly as your per-target table says. Schema-valid on all three lines.
The schemas really did reject real output
Validating the captured staging streams against the repo's own published schemas:
main's output vsmain's schemas: 2 failures —'prompt_preview' is not one of [...]and'preview' is not one of ['queued','completed','cancelled']. Exactly the two you name.- this branch's output vs this branch's schemas: 0 failures, across
--print-promptand async.
converted
Fed tests/comfy_cli/fixtures/sd15_ui_workflow.json to the cloud path: branch emits converted {node_count: 7} before prompt_preview; main emits nothing.
_node_errors_to_list is a bigger fix than the description claims
It's the shared helper, so this changes the local path too. Probed both versions directly:
| input | main |
this branch |
|---|---|---|
{"1": "missing input"} |
[] — silently dropped |
[{"errors": ["missing input"], "node_id": "1"}] |
{"2": ["a", "b"]} |
[] — dropped |
[{"errors": ["a", "b"], "node_id": "2"}] |
record carrying its own node_id: "99" under key "4" |
node_id: "99" — server wins |
node_id: "4" — map key wins |
So on main a rejection could report "rejected 1 node(s)" alongside an empty node_errors array. And while running the non-vacuity pass I hit the other half: on main a bare-string error item raises AttributeError: 'str' object has no attribute 'get' out of the hint builder at __init__.py:878 — aborting with a traceback and no envelope at all, breaking the exactly-one-envelope guarantee. Your shape-checking closes that. Both are worth saying out loud in the description; right now the comment undersells them.
Credential safety of the unconditional prompt_preview
Confirmed by reading comfy_client.submit_prompt: extra_data — where auth_token_comfy_org / api_key_comfy_org ride — is built inside payload() at send time and attached beside "prompt"; the workflow dict is never mutated. Your riskiest-line note holds.
Tests and suite
With the three run/* sources and both schemas reverted to main, 13 of 18 new tests fail — non-vacuous. Full pytest . on your branch merged with current main: 3781 passed, 31 skipped. ruff check and ruff format --diff clean at the CI-pinned 0.15.15.
Follow-up nit (pre-existing, not yours)
The CQL pre-flight failure envelope comes out with where: null on the cloud path — I hit it with an unknown ckpt_name — while the per-target table this PR adds says envelope where is "cloud". Cause is preflight.py:106, which calls renderer.error(...) without where=; that file isn't in this diff. Out of scope here, but since this PR is the one documenting the field, worth a ticket.
The cloud branch of `comfy run` returned early from inside the `try`
suite whose `else:` fires `track_event("execution_success", ...)`.
Python skips a try's `else:` clause when the suite leaves via `return`,
so that tracking was unreachable on the cloud target — the only thing
that ever hit it was the `except typer.Exit` handler, which BE-6039
removed when `--print-prompt` switched from `raise typer.Exit(0)` to a
plain `return`.
Turn the early return into an if/else so both targets fall off the END
of the try suite. This also closes the pre-existing gap: no cloud run
emitted `execution_success` before, not just `--print-prompt`.
Verified: reverting only the cmdline.py hunk fails the two new
`execution_success` assertions (print-prompt and plain submit), so the
coverage is non-vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@comfy_cli/cmdline.py`:
- Around line 1012-1015: Update the port-selection condition in the host parsing
flow around parse_host_port_arg to check whether port is None rather than using
its truthiness. Preserve an explicitly supplied port value of 0, while still
applying parsed_port when no --port value was provided.
In `@comfy_cli/schemas/run.json`:
- Line 10: Add a documented prompt property to the run.json response schema,
defining it as an object to represent the workflow graph returned by
--print-prompt, while preserving the existing status enum and permissive
additional-property behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: b9aa1e97-b438-4495-95da-ad9b10a2f38a
📒 Files selected for processing (10)
comfy_cli/cmdline.pycomfy_cli/command/run/__init__.pycomfy_cli/command/run/execution.pycomfy_cli/command/run/loader.pycomfy_cli/schemas/run.jsoncomfy_cli/schemas/run_event.jsondocs/json-output.mdtests/comfy_cli/command/test_run_cli.pytests/comfy_cli/command/test_run_json.pytests/comfy_cli/test_run_execution_lifecycle.py
`--print-prompt` emits `prompt` (the API-format graph it WOULD submit) alongside `status: "preview"`, but run.json never described the field. `additionalProperties: true` meant this was a documentation gap rather than a validation failure — the schema is published via `comfy --json discover` as this contract's machine-readable half, so an undocumented field is still a hole in the contract. Left deliberately unconstrained beyond `"type": "object"`: node shapes are the server's contract, not the CLI's, and over-tightening a new constraint is exactly what made these schemas reject real output to begin with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
The following carry
|
ELI-5
comfy run --jsonpromises a fixed stream of events: "I converted your workflow", "here's the graph I'm about to send", "the server has it", then a final result line. The local run kept that promise. The cloud run (--where cloud) quietly did something else — it never said "converted", only showed the graph under--print-prompt, and said "queued" before it had actually sent anything, with the wrong fields. So a script that trusted the docs got a different stream depending on where the job ran, and "queued" on cloud didn't mean the server had the job. This makes the cloud stream match what the docs say, and documents the handful of differences that genuinely can't match.What changed
comfy_cli/command/run/__init__.py(execute_cloud) — cloud now conforms todocs/json-output.md:convertedafter a client-side UI→API conversion (it emitted none)prompt_previewunconditionally, not only under--print-promptqueuedafter a successful submit, carrying the documentedprompt_id/client_id/validation_warnings/nodes(plusbase_url). A failed submit, or one the server rejects withnode_errors, now emits noqueuedat all — the pre-submit event was the defect the ticket singles out--waitannouncement that reusedexecuting(a per-node event type) with{workflow, base_url}and nonodefield--print-promptreturns instead ofraise typer.Exit(0), matching local. Corrected in review (thanks @bigcat88): an earlier revision of this PR claimedcmdline.py'selse:branch trackedexecution_successon a normal return just as itsexcept typer.Exitbranch did forExit(0). That was wrong — Python skips atry'selse:clause when the suite leaves viareturn, andcmdline.pyreturned early on the cloud branch, so theexcept typer.Exithandler was the only thing that ever reached the tracking call. Dropping theExit(0)therefore dropped the event. Fixed at the call site: the cloud branch is now anif/elseso both targets fall off the end of the try suite. That also closes a pre-existing gap — no cloud run emittedexecution_successbefore this, not just--print-prompt. Exit codes were never affectedwatcher_spawned, like localprompt_rejecteddetails.node_errorsis now the documented array-of-records, not the server's id-keyed dict. This is a bigger fix than it reads, and it lands on the local path too, because_node_errors_to_listis the shared helper: previously any non-dict error value was silently dropped, so a rejection could report "rejected 1 node(s)" next to an emptynode_errorsarray ({"1": "missing input"}and{"2": ["a", "b"]}both yielded[]). Worse, a bare-string error item raisedAttributeError: 'str' object has no attribute 'get'out of the hint builder — aborting with a traceback and no envelope at all, breaking the exactly-one-envelope guarantee. The shape checks close both. The map key now also wins over a server-suppliednode_id, so the id you see is the one you keyed oncomfy_cli/command/run/execution.py:workflow_manifest()/node_title()lifted to module level;WorkflowExecutiondelegates to them. Both targets now buildqueued.nodesfrom one function, so the manifest shape can't diverge on one side again.docs/json-output.md: a new Per-target differences section listing every remaining difference (no per-node events from a cloud run,host/portvsbase_url, local-onlycached_node_ids/executed_node_ids, cloud-onlywarnings, cloud-only error codes, theprompt_rejecteddetails difference), plus a cloud stream example and fixes to the success-envelope table.comfy_cli/schemas/run{,_event}.json: these are published as this contract's machine-readable half viacomfy --json discover, and they rejected real output on both targets —converted,prompt_previewandlogin_urlwere missing from the event enum, anddata.statushad no"preview"value despite--print-promptemitting it. Corrected, with per-target annotations.Judgment calls
queued.base_urlkept as an extra field so cloud consumers don't lose the target they used to get from the old pre-submit event. Additive optional fields are non-breaking under the doc's own stability rules.transient_authrow in the error-code table — it is registered inerror_codes.pyand emitted byexecution_errors.classify()on both paths, but was undocumented.cached_node_ids/executed_node_idsare derived from a per-node event stream the cloud has none of; cloudwarningscome from a partial-execution guard with no local equivalent. Unifying them would require merging the pipelines, which the ticket rules out.Self-review notes
renderer.event("prompt_preview", prompt=parsed_workflow)on cloud, because it puts the graph in every cloud stream. Verified it carries no credentials:Client.submit_promptbuildsextra_data(whereauth_token_comfy_org/api_key_comfy_orgride) at send time from a local dict and never mutates the workflow, so the previewed graph is credential-free — same guarantee the local path'sprompt_previewdocuments.renderer.event()is a no-op outside stream mode, so pretty and single-envelope JSON output are byte-identical.comfy jobs watch --where cloud(jobs.py:_cloud_watch) does emit progress — coarsestatetransitions plusoutputevents — so the doc now redirects the reader there instead of dead-ending, andstatewas added to the event enum sincediscovery.pymapscomfy jobs watchto that same schema. (2) My first draft claimedexecution_error"cannot occur" on cloud;execute_cloudcalls the sameexecution_errors.classify()the local path does, so it can — that claim is removed andtransient_authdocumented. Also caught in review: my own newnodesschema constraint would have rejectedjobs watch'sexecution_cached.nodes(an array of strings), now widened.pytestgreen (3764 passed, 37 skipped) andruff check/ruff format --diffclean under the CI-pinned ruff 0.15.15. New tests pin the cloud stream shape (async,--wait,--print-prompt, UI-converted), assert noqueuedon a failed or rejected submit, assert local/cloud manifest parity, and validate both real streams against the published schemas so this drift can't silently return.