Conversation
…nd it The annotation modal's keydown handler ran on window in the bubble phase and never stopped the event, so React Flow's document-level deleteKeyCode handler (Delete/Backspace) also fired and deleted the SELECTED NODE on the canvas behind the modal — the node vanished on save. (The canvas gates on workflowStore.isModalOpen, which the annotation modal never sets, so its modal guards stay inactive.) Fix: intercept Delete/Backspace/Escape/Ctrl+Z in the CAPTURE phase and stopImmediatePropagation for the keys the modal owns, so they never reach the canvas. Text-editing keys are left alone (editingTextId returns early, and React Flow already ignores key events targeting text inputs). Includes regression tests (a simulated document-level canvas handler must not fire while the modal is open; keys pass through again once it is closed) — the interception tests fail without the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(annotation): stop Delete in the modal from deleting the node behind it
fix(annotation): handle uppercase undo shortcuts
Foundation for running ComfyUI workflows as Node Banana nodes. - graph.ts: API-format parsing, widget classification, run-time patching (media bindings, seed randomisation, preview→save rewrite, CustomCombo index re-derivation) and output-branch pruning. - editor.ts: editor-format → API-format conversion with subgraph expansion, App Mode (linearData) extraction across all three id encodings the frontend has shipped, and Blueprint (saved subgraph) support including proxyWidgets and synthesised output sinks. - inspect.ts: turns a graph plus its App Mode config into a node contract — typed input handles, inline parameters, typed output handles. - settings.ts: cloud/local/remote backend settings, stored client-side and forwarded per request. - server/: two engines behind one interface — the legacy /api/prompt surface every ComfyUI serves, and the Comfy API v2 surface via @comfyorg/sdk. Comfy Cloud is the default backend; a stock local install needs no sidecar because the legacy engine covers it. 73 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- /api/comfy/status: reachability + auth probe, reports node-catalog size - /api/comfy/inspect: upload → proposed node contract (converts editor saves) - /api/comfy/blueprints: list and import ComfyUI Blueprints from the engine - /api/comfy/run + /poll: submit and poll, so a render can outlive one request Also splits run-graph construction out as a pure function: bind connected inputs and parameters, prune to the bound outputs, randomise unpinned seeds. Pruning keeps bound input nodes as roots — pruning from outputs alone drops a loader whose branch reaches no bound sink, and patching it then fails. 86 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ComfyUI workflow attached to a node: its App Mode inputs become typed target handles, its widgets become inline settings, and its output nodes become typed source handles. - ComfyAppNode + ComfyAppParameters: handles derived from the app contract, settings rendered from the workflow's own widget schema - ComfyWorkflowImportModal: drop in a workflow file or pick a Blueprint from the connected engine, then confirm what it exposes - comfyAppExecutor: submit, poll, and map results onto output handles; cancelling a run also cancels the engine job so a GPU isn't left running - getSourceOutput reads a Comfy node's output type from the source handle, so a multi-output app feeds the right downstream type - outputs are externalized on save like any other generated media Connection validation resolves a Comfy output's type from the attached contract — those handle ids are graph node ids and carry no type in the name. Also makes two ConnectionDropMenu keyboard tests derive positions from the rendered list instead of hard-coded option names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A new tab in Project Settings for choosing how Node Banana runs ComfyUI: Comfy Cloud (the default — nothing to install), a local install, or a remote one, with a connection test that reports the engine's node-catalog size. Advanced holds the partner-node key (authenticates Gemini/Kling nodes inside a workflow, wherever it runs), the job timeout, and seed randomisation. Settings live in localStorage next to the other provider keys and are forwarded per request, so switching engines needs no restart. 105 comfy tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t types Found by running a real Comfy Cloud blueprint (Brightness and Contrast) through the pipeline end to end against a stand-in engine. - A blueprint's data enters and leaves through boundary *slots*, not through LoadImage/SaveImage nodes. Only the output half was materialised, so a blueprint inspected as having no inputs at all. Loaders are now created for each media boundary input and wired into the instance socket by name. - App Mode curates widgets, and a boundary slot is not a widget — so a curated workflow dropped any media loader the author had not listed. Those are now appended as optional inputs. - proxyWidgets carry the author's rename on the inner node's input entry. Without it, two proxied `PrimitiveFloat.value` widgets both labelled "PrimitiveFloat · Value" and the node's settings were unusable. - A FLOAT widget sitting at 0 serializes as an integer, and was being typed as one — so the user could not enter 0.5. The engine's declared type now wins over the value's shape. - Lifting a blueprint rewrites the instance's sockets, so it now works on a copy; the caller's file may be inspected again. Verified: image upload bound, prompt patched, unexposed negative prompt left alone, user steps applied, seed randomised, a user-set seed preserved. 112 comfy tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Shift+C adds a ComfyUI app node, listed in the shortcuts dialog
- The import dialog is rendered from inside a node, and React Flow's viewport
carries a transform — which makes it the containing block for
`position: fixed`. Without a portal the dialog was scaled and shifted by the
canvas zoom. Matches how ModelSearchDialog already handles this.
- Untitled loader and sink nodes label as their plain type ("Image") rather
than "LoadImage (#1)", which reads better on a handle
- Settings modal widened to 580px so five tabs stay on one line
- CLAUDE.md + README document the feature, backends and formats
Verified in a browser against both a stand-in engine and the live Comfy Cloud
blueprint catalog: node renders with correctly typed handles, App Mode is
detected and pre-selected, the connection test reports the engine's node
count, and the Blueprint library lists all 93 Cloud blueprints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Comfy app's output handle ids are ComfyUI graph node ids, so none of the name-based handle helpers could decode them. - Dragging from a Comfy output onto empty canvas resolved no handle type, so the connection menu offered the wrong node list. It now reads the type off the attached contract. - Auto-connecting *into* a Comfy app as the source returned the generic "image"/"video" names, creating an edge from a handle that does not exist. It now picks a real output of the requested type. - Replacing a node's workflow left edges bound to handles the new contract does not declare — hanging off the node and silently feeding nothing. Those edges are now pruned on attach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An image produced by a Comfy app is a generation like any other, so it now lands in the global image history and, when a project directory is set, in its generations folder — the same places every other generate node writes to. `ImageHistoryItem.model` widens to accept a free-form producer name; the history row shows it verbatim instead of mislabelling anything unrecognised as "Standard". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- An unconnected Comfy input fell back to the first value of its type, so an app with a positive and a negative prompt fed the positive one into both when only one was wired. The legacy fallback now only applies when the app has exactly ONE input of that type; otherwise the author's saved value runs, which is wrong visibly rather than invisibly. - `findCompatibleHandle` fell through from the needInput branch to the output branch when a schema declared no input of the requested type, handing back an OUTPUT handle as a connection target. - A Comfy source handle that the attached workflow no longer declares was substituting the first output instead of carrying nothing — silently feeding the wrong image downstream after a workflow was replaced. - Creating a Comfy app node from the connection menu dropped the wire with no explanation (a node with no workflow has no handles). It now opens the import dialog immediately so the node becomes connectable. 2280 tests. Re-verified end to end in a browser: image input wired through to a rendered result, no console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Transport: - Uploaded input filenames are now content-addressed. The legacy upload uses overwrite:true, so two Comfy nodes running concurrently with the same input name clobbered each other on the engine and rendered the wrong image. - Cancel no longer fires /api/interrupt unconditionally. That endpoint takes no job id and kills whatever is executing — on a shared (or locally-used) ComfyUI it stopped someone else's render. It now only interrupts once this job is confirmed running, and deletes a merely-queued job from the queue. - The cached in-flight catalog fetch no longer carries the first caller's AbortSignal, which let one client disconnect fail everyone else's import. - After a missing-node failure the catalog is re-read once, so installing the pack and retrying works instead of hitting a stale answer for five minutes. - `connectionFromRequest` had a dead `null : null` ternary that dropped the COMFY_API_KEY env fallback whenever a browser supplied a base URL. Conversion: - `extractAppMode`'s namespaced-id fallback matched by suffix, so node id "5" matched both "140:5" and "77:5" — binding the author's selection to whichever came first. Ambiguous matches are now dropped. - `loaderWidgetKey` assumed the filename widget is named after the media type. Core LoadVideo calls it `file`; the node's own inputs are now the authority. - A Blueprint boundary input with no loader (MODEL, CONDITIONING) produced a graph the engine always rejects. It is now reported at import. Execution: - A timed-out run abandoned the engine job; it is now cancelled, so a GPU (and on Cloud, a bill) is not left running. - A cancel is recognised however it surfaces, and normalised to an AbortError so executeWorkflow treats it as a stop rather than a failure. - Only a failure the route itself reported is terminal. A bare 5xx from something in front of it is retried, so one gateway blip no longer kills a long render. 2286 tests. Re-verified end to end in a browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getNodeHandles("comfyApp")` returns a superset for connection *validation*;
it is not a list of real handles. Using it as the auto-connect fallback bound
an edge to a handle the node never renders — React Flow (in strict mode) then
cannot draw it, so the user saw no wire, could not select or delete it, and it
still counted as a dependency in the topological sort, running the paid Comfy
job whenever the upstream node ran.
Auto-connect now returns no handle for a Comfy node, and `isValidConnection`
requires both ends to name a handle the attached workflow actually declares.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a result ComfyUI caches each node's result by the signature of its inputs. Submitting a graph the engine has already executed therefore re-runs nothing — and a job that executes nothing emits no outputs at all, which the poll route could only report as "finished the run but produced no output". That made an app node fail on the *second* run and every one after it, and made two nodes resolving to the same graph fail whichever of them ran second. It was easy to read as intermittent, because any edit to a parameter produced a fresh graph and one more working run. Bound sinks now get a per-run token appended to their `filename_prefix`, so the sink alone misses the cache. Verified against Comfy Cloud with the blueprint that surfaced this: four sequential repeats and three concurrent runs each returned an image, in ~7.7s against ~11.3s cold — the expensive work upstream is still served from the cache, only the save is redone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comfy Cloud returns an empty `content_type` for the files a workflow saves itself, which produced `data:;base64,…` — a *text* data URL. It happened to render because browsers sniff image bytes, but decoding it back lost the format: feeding one Comfy node's image into another re-uploaded it as an extensionless `application/octet-stream` file, which the engine will not load. The SDK engine now falls back to the filename for both the MIME type and the handle type, as the legacy engine already did — without that second part a `.glb` or `.mp4` output was classified as an image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A workflow's picks were fixed at import: the only way to change which widgets a node exposed was to re-import it, which reset every parameter value and dropped the run. A gear button on the node header now reopens the same list. Reopening is non-destructive, which is the whole point of it being separate from replacing the workflow: the graph has not changed, so the last result stands, and a setting the user kept keeps its value rather than reverting to the workflow's. Two supporting changes: - The candidate list is stored on the node at import, because App Mode lives in the uploaded file rather than the runnable graph — re-deriving it would lose the author's curation and their names for things. Nodes attached before this fall back to re-inspecting the graph, with the node's own labels carried over so identically-named widgets stay tellable apart. - Media loaders are now selectable, not just renameable. A workflow can carry several and only some are meant to be wired from the canvas; re-enabling one restores the requiredness inspection gave it, so an optional loader does not become a blocking input. Verified in a browser end to end: attach, reopen, drop an image input and an output, save, reopen again — handles follow, picks persist, no console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A blueprint's data enters through boundary slots, and a slot that accepts more than one type is written as a comma-separated union: an image-to-video blueprint's frame is `IMAGE,MASK`, meaning either will do. The type was matched as a whole string, so the union matched nothing, no loader was materialised, and the blueprint lost the only input it exists for — reported as "expects first_frame (IMAGE,MASK) to be wired inside ComfyUI". Each member is now tried in the author's declared order and the first one Node Banana can supply wins, on both the loader and the sink side. The link and the loader's output carry the resolved member rather than the union, so downstream slot matching still sees a real type. Five of Comfy Cloud's 93 blueprints were affected, across seven slots: Image to Video (LTX-2.3), both First-Last-Frame to Video variants, and both SDPose pose maps. Verified against the live catalog — all five now expose their image inputs and the misleading warning is gone, with unaffected blueprints unchanged. In a browser, Image to Video (LTX-2.3) now renders a text handle for its prompt, an image handle for first_frame, and a video output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blueprints such as Color Curves expose their controls as `CurveEditor` nodes,
whose value is a JSON object — `{points, interpolation}` — rather than a scalar.
Widget collection only accepted strings, numbers and booleans, so all four
curves were dropped and the node imported with an image in, an image out, and no
controls whatsoever.
`curve` is now a parameter type of its own, recognised structurally so it works
without a reachable catalog, and never offered as a handle (the engine declares
CURVE socketless, and nothing on the canvas could produce one).
The editor draws the same monotone cubic Hermite spline the engine applies —
Fritsch–Carlson, reimplemented rather than approximated with a smooth Bézier,
because the limiting step is exactly what stops a tone curve overshooting into
banding. Drag a point, click to add, double-click to remove; the endpoints are
x-locked since they anchor the input range.
Two adjacent fixes the same blueprints needed:
- Numeric bounds of ±2^63 are the int64 extremes a generic `PrimitiveFloat`
declares, meaning "no limit". Reported verbatim they told the user their
brightness had to stay under 9,223,372,036,854,775,807.
- ComfyUI defaults a proxied widget's `label` to the input's own name, so four
proxied `CurveEditor.curve` widgets all read "curve" while their node titles
said RGB Master, Red, Green and Blue. Only a genuine rename is taken now, and
a curve keeps its full label rather than being shortened to the tail.
Verified against Comfy Cloud end to end: an S-curve reaches the submitted graph
verbatim and the job returns a processed image. In a browser, Color Curves
renders four labelled editors, click-to-add and drag-to-shape both work, and the
drawn curve stays monotone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dropped .json was always read as one of our own saves, so a ComfyUI workflow — the thing this feature exists to run — was rejected outright. Both ComfyUI formats now create a comfyApp node where they landed and open the confirm step already read, so the only step left is agreeing to the handles. The two formats are told apart by shape, since neither file names its own origin: ours carries nodes *and* edges under a version, while an editor save carries the canvas bookkeeping ComfyUI needs to redraw itself. Note that a ComfyUI save also has a top-level `version`, which is why ours is not identified by that alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
React Flow reads a node's handles once, when it measures the node, and caches where they are. Every other node declares its handles up front, so that is enough — but this one has none until a workflow is attached, and the ones that appear afterwards were invisible to the connection system: a wire dropped on them landed nowhere, and an edge made anyway could not be drawn. Resizing forced a re-measure, which is why it looked like a size problem. Re-register whenever the contract changes the set of handle ids, as the router and switch nodes already do for theirs. Verified in the browser: before, connecting to a freshly attached app node fails in both directions; after, both succeed with no resize. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The floating header on a Comfy app node named the node's kind in the same uppercase label every other node uses; the wordmark says it faster and marks the node as Comfy's at a glance. Traced from the official artwork into one path — 3 KB, 99.3% pixel agreement with the source at full size — and drawn with currentColor, so it dims and highlights along with the header's own text. A custom title still shows beside it, as the whole label rather than a prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workflow's name sat inside the node while the header above it said "ComfyUI App" — the generic half was the prominent one. The name now sits beside the wordmark as the node's title, editable like every other node's, seeded with the workflow's own name so clearing a custom one falls back to it rather than to nothing. That frees the line inside the node to answer the question the name cannot: whether this is a workflow the user brought or a Blueprint from the engine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Blueprint is the same kind of thing, so a card each bought nothing: the Cloud catalog alone is 93, which arrived as a scrolling wall carrying one useless detail — a node pack reading "default" on every row. One field to choose from and one button to confirm. The whole catalog is now reachable by keyboard, and nothing is fetched until the choice is made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dropdown hid ninety-three entries behind a click and let you match only from the start of a name. They now share one dark container: a search row at the top, every match below it, each row lighting up under the pointer. Clicking picks; Add — or a double-click — confirms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add now sits beside Cancel, where this dialog's committing action already lives, so the list is only a list. The catalog count went with it — it answered a question nobody was asking while the names were right there. Lighter search glyph and placeholder, and roomier rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It read as a leaflet — a title, a sentence explaining what the node is, a button, and a footnote about the configured engine — none of which the canvas needs repeated on every empty node. It is now what it is: a dashed frame, the wordmark, and the one button. And it means it: dropping a workflow on the frame fills *this* node, where before the canvas caught the file and answered with a second one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The frame inherited a content box padded for a node with a header above it — nothing on top, sixteen pixels below — so it sat off-centre in its own node. It now has the same thirteen pixels on every side. The label was centred by its line box, which on 12px text reserves four pixels for descenders "Load workflow" does not have, and by its advance width, which carries a 1px left bearing. Measured against the rendered glyphs instead: within 0.36px vertically and 0.15px horizontally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Cloud Blueprint offers around thirty widgets and its author curates one.
The dialog gave all thirty equal weight — a flat list of `ClassName ·
Widget` rows, each with its own three-way toggle — so the two decisions
that shape the node, which input and which output, sat at opposite ends of
2.4 screens of scrolling.
Now the author's picks and anything already exposed stand alone, and the
remainder waits behind "Show 27 more widgets", searchable and grouped by
the node it belongs to, where the class name is said once instead of on
every row. The three-way toggle is a checkbox: the third option, promoting
a widget to a handle, applies to one widget in thirty, so it appears on
that one, once it is exposed. The row is the hit target — 534x40 where the
toggle was 38x19.
Section headings stick as you scroll and carry their own state ("1 of 28
exposed"), and the reason Add is disabled now sits beside Add rather than
a section away.
Measured on Depth to Image (Z-Image-Turbo): 1355px of content in a 567px
body, down to 457 in 457 — the whole thing fits without scrolling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 11
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (18)
README.md-100-100 (1)
100-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winSeparate workflow import from node creation.
Line 100 associates file dropping with
Shift + C.Shift + Ccreates a newcomfyAppnode; dropping a workflow imports the file. Document these as separate actions so users do not expect the shortcut to load a workflow.Suggested wording
-Any ComfyUI workflow can be dropped onto the canvas as a node (`Shift + C`). +Drop any ComfyUI workflow onto the canvas to import it as a node. Press `Shift + C` to add a new ComfyUI app node without importing a file.🤖 Prompt for 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. In `@README.md` at line 100, Update the README statement about dropping ComfyUI workflows to separate workflow file import from node creation: document that dropping a workflow imports it, while `Shift + C` creates a new `comfyApp` node, without implying the shortcut loads a workflow.src/app/globals.css-309-349 (1)
309-349: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winUse kebab-case keyframe names.
Stylelint reports
keyframes-name-patternerrors fordialogBackdropIn,dialogPanelIn, anddialogSectionIn. Rename the keyframes and update theiranimationreferences.Suggested fix
-@keyframes dialogBackdropIn { +@keyframes dialog-backdrop-in { ... -@keyframes dialogPanelIn { +@keyframes dialog-panel-in { ... -@keyframes dialogSectionIn { +@keyframes dialog-section-in { ... - animation: dialogBackdropIn 0.15s ease-out; + animation: dialog-backdrop-in 0.15s ease-out; ... - animation: dialogPanelIn 0.2s cubic-bezier(0.2, 0, 0, 1); + animation: dialog-panel-in 0.2s cubic-bezier(0.2, 0, 0, 1); ... - animation: dialogSectionIn 0.26s cubic-bezier(0.2, 0, 0, 1) both; + animation: dialog-section-in 0.26s cubic-bezier(0.2, 0, 0, 1) both;🤖 Prompt for 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. In `@src/app/globals.css` around lines 309 - 349, Rename the keyframes dialogBackdropIn, dialogPanelIn, and dialogSectionIn to kebab-case names, and update the corresponding animation declarations in .animate-dialog-backdrop, .animate-dialog-panel, and .animate-dialog-section to reference the renamed keyframes.Source: Linters/SAST tools
src/components/AnnotationModal.tsx-112-140 (1)
112-140: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winLet the active text input receive Escape when editing text.
When
editingTextIdis set and the inline input has focus, the capture handler still callsstopImmediatePropagation()forEscapebefore the input'sonKeyDownruns. This path does not cleareditingTextId,textInputPosition, orpendingTextPosition, so Escape can leave an empty annotation instead of closing the edit state. Return for the active input before the modal shortcut handling.Add focused-input regression tests for
Escapewhile editing an existing text annotation.🤖 Prompt for 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. In `@src/components/AnnotationModal.tsx` around lines 112 - 140, Update handleKeyDown so Escape returns immediately when editingTextId is set, allowing the active text input to handle the key before modal shortcut logic runs; retain closeModal behavior when not editing. Add focused-input regression tests covering Escape while editing an existing text annotation.src/lib/comfy/__tests__/engineErrors.test.ts-156-173 (1)
156-173: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winWiden the timing margin to avoid a flaky assertion.
createEngineFetchsetsdeadline = Date.now() + timeoutMs * 2, and it checks that deadline only between attempts. WithrequestTimeoutMs: 40, one stalled attempt ends at about 40 ms and a second attempt can start just under the 80 ms deadline, so expected elapsed time is roughly 80–120 ms. The assertion allows 160 ms. That leaves about 40 ms of headroom. On a loaded CI runner, one scheduler stall or GC pause can exceed it and fail the run.Raise
requestTimeoutMsso the proportional margin grows. The assertion stays meaningful because it is still bounded by elapsed time rather than by the retry count.🧪 Proposed change to widen the margin
- const engineFetch = createEngineFetch({ retryBaseMs: 0, requestTimeoutMs: 40 }); + const engineFetch = createEngineFetch({ retryBaseMs: 0, requestTimeoutMs: 200 }); const began = Date.now(); await expect(engineFetch("https://cloud.comfy.org/api/v2/jobs/job-1")).rejects.toThrow(); // Bounded by elapsed time, not by the retry count: four retries of one // timeout would be five times the wait, and the caller checks its own // deadline only between requests. - expect(Date.now() - began).toBeLessThan(40 * 4); + expect(Date.now() - began).toBeLessThan(200 * 4);🤖 Prompt for 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. In `@src/lib/comfy/__tests__/engineErrors.test.ts` around lines 156 - 173, Increase the requestTimeoutMs value used by createEngineFetch in the stalled-request test so the proportional timing margin is larger and less sensitive to CI scheduling delays. Keep the existing elapsed-time assertion and retry-behavior coverage unchanged.src/components/settings/ComfySettingsTab.tsx-48-52 (1)
48-52: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winThe probe result survives changes to three connection fields.
The effect clears
resultformode,cloudApiKey,localUrl,remoteUrl, andcloudUrl. It omitsremoteApiKey,localUsesApiV2, andremoteUsesApiV2. Each of those changes the endpoint or the credential that the probe validated, so a green "Connected" label stays visible against a configuration that was never tested — the exact outcome the comment says to avoid.🐛 Proposed fix for the dependency list
useEffect(() => { setResult(null); - }, [settings.mode, settings.cloudApiKey, settings.localUrl, settings.remoteUrl, settings.cloudUrl]); + }, [ + settings.mode, + settings.cloudApiKey, + settings.cloudUrl, + settings.localUrl, + settings.localUsesApiV2, + settings.remoteUrl, + settings.remoteApiKey, + settings.remoteUsesApiV2, + ]);🤖 Prompt for 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. In `@src/components/settings/ComfySettingsTab.tsx` around lines 48 - 52, Update the dependency list of the result-resetting useEffect in ComfySettingsTab to include remoteApiKey, localUsesApiV2, and remoteUsesApiV2 alongside the existing connection fields, ensuring any probe-relevant configuration change clears the previous result.src/lib/comfy/editor.ts-693-703 (1)
693-703: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winWidget precedence contradicts the documented rule.
The doc comment on
parseAppModeInputId(Line 626-627) states that the widget name carried by aWidgetId"is authoritative over element[1]when they disagree". Line 697 does the opposite:entry[1]wins whenever it is notnullorundefined. For aWidgetId-encoded entry, a stale or renamedentry[1]then binds the control to the wrong widget key, and the binding is silently dropped later or written to the wrong input.Either invert the precedence or correct the comment.
🐛 Proposed fix to honour the documented precedence
- const widget = String(entry[1] ?? parsed.widget ?? ""); + const widget = String(parsed.widget ?? entry[1] ?? "");🤖 Prompt for 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. In `@src/lib/comfy/editor.ts` around lines 693 - 703, Update the input parsing loop around parseAppModeInputId so parsed.widget takes precedence over entry[1] when both provide values, matching the documented WidgetId behavior. Preserve the fallback to entry[1] when parsed.widget is unavailable, then continue using the resolved widget for deduplication and inputs.push.src/components/nodes/ComfyAppParameters.tsx-158-250 (1)
158-250: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAssociate each label with its control.
The
<label>elements at Line 161, Line 203, and Line 233 wrap no control and set nohtmlFor. Screen readers therefore announce these inputs without a name, and clicking the label does not move focus. The boolean branch at Line 185-196 is correct because it wraps itsinput.Derive an id from
param.idand pair it withhtmlFor.♿ Proposed fix for the number and text branch
function ComfyParameterInputInner({ param, value, onChange }: ComfyParameterInputProps) { const label = shortLabel(param); + const controlId = `comfy-param-${param.id.replace(/[^a-zA-Z0-9_-]+/g, "-")}`;- <label className="text-[11px] text-neutral-400 shrink-0" title={param.description}> + <label htmlFor={controlId} className="text-[11px] text-neutral-400 shrink-0" title={param.description}> {label} </label> <div className="flex-1 min-w-0 flex items-center gap-1"> <input + id={controlId} type={isNumber ? "number" : "text"}Apply the same pairing to the
selectat Line 164 and thetextareaat Line 206.🤖 Prompt for 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. In `@src/components/nodes/ComfyAppParameters.tsx` around lines 158 - 250, Associate every non-wrapping control label in the parameter renderer with its control by deriving a stable id from param.id. Add matching htmlFor values to the labels and id values to the select, textarea, and number/text input in the enum, multiline, and default branches; leave the boolean branch unchanged because its label already wraps the input.src/components/nodes/ComfyCurveEditor.tsx-137-155 (1)
137-155: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winThe point key changes during a drag and breaks pointer capture.
key={${index}-${point[0]}}includes the moving x coordinate. EverymoveCurvePointcall for an interior point produces a new key, so React unmounts the oldcircleand mounts a new one. The browser releases pointer capture with the removed element, and the capture set at Line 154 is lost after the first move. Dragging then relies on the SVG-levelonPointerMove, so tracking stops as soon as the pointer leaves the SVG box, and thehasPointerCapturecheck inendDragno longer matches.Key by index instead. The point count only changes on add and remove, where a full remount is correct.
🐛 Proposed fix for the unstable key
- key={`${index}-${point[0]}`} + key={index}🤖 Prompt for 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. In `@src/components/nodes/ComfyCurveEditor.tsx` around lines 137 - 155, Update the circle key in the curve.points rendering within ComfyCurveEditor so it uses the stable point index only, not the mutable point[0] coordinate. Preserve remounting behavior when points are added or removed while keeping the same circle mounted during dragging so pointer capture remains active.src/lib/comfy/curve.ts-189-201 (1)
189-201: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winInterior clamp can invert when neighbours are closer than
2 * MIN_POINT_GAP.
normalizeCurveonly drops points whose x gap is below1e-6, so an imported workflow can carry points 0.001 apart. For such a triple,lowerexceedsupperat Line 196-197, andMath.min(Math.max(clamp01(x), lower), upper)returnsupper— a value below the previous point. The returnedpointsarray is then unsorted, andsampleCurvepluscurvePathboth assume ascending x, so the plotted curve stops matching the stored value.Clamp to the valid range only when it is non-empty.
🐛 Proposed fix for the inverted clamp
if (!isEnd) { const lower = points[index - 1]![0] + MIN_POINT_GAP; const upper = points[index + 1]![0] - MIN_POINT_GAP; - point[0] = Math.min(Math.max(clamp01(x), lower), upper); + point[0] = + lower > upper + ? (points[index - 1]![0] + points[index + 1]![0]) / 2 + : Math.min(Math.max(clamp01(x), lower), upper); }🤖 Prompt for 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. In `@src/lib/comfy/curve.ts` around lines 189 - 201, Update the interior-point branch in moveCurvePoint so it only applies the lower/upper x clamp when the computed range is non-empty (lower <= upper); when neighbours are too close and the range is inverted, preserve the existing x value or otherwise avoid producing an x below the previous point. Keep endpoint handling and y clamping unchanged.src/lib/comfy/inspect.ts-300-312 (1)
300-312: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winDrop
STRINGboundary slots from connectable inputs ifalsoBindmust be preserved.
blueprintAppModeaddsconnectAs: "text"for allSTRINGslots, so inspect.ts turns them intoComfyAppInputs.alsoBindis kept only for parameters, andComfyAppInputhas no carry-through field, so aSTRINGboundary slot wiring multiple inputs can expose one text handle while only its primary target is bound at run time. Add analsoBindcarrier/assignment path for text inputs, or keep boundSTRINGslots as parameters instead of converting them to handles.🤖 Prompt for 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. In `@src/lib/comfy/inspect.ts` around lines 300 - 312, Update the connectable-input handling in the inspect flow around connectAs and inputFromCandidate so STRING slots with entry.alsoBind are not converted into ComfyAppInputs that lose secondary bindings. Either preserve alsoBind through the text-input carrier and runtime assignment path, or route bound STRING candidates through the existing paramFromCandidate/params path while retaining all alsoBind targets.src/components/nodes/ComfyAppNode.tsx-406-414 (1)
406-414: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winHandle a rejected
file.text().
file.text()rejects when the browser cannot read the dropped file. The.thenchain has no.catch, so the rejection is unhandled, the import dialog never opens, and the user sees nothing. Add a.catchthat still opens the dialog so the failure is reported where every other bad file is reported.🐛 Proposed fix
- void file.text().then((text) => { - try { - onDropWorkflow({ workflow: JSON.parse(text), filename: file.name }); - } catch { - // The dialog is where a bad file gets explained; it reports the - // same way whether the JSON or the workflow inside it is at fault. - onDropWorkflow({ workflow: text, filename: file.name }); - } - }); + void file + .text() + .then((text) => { + try { + onDropWorkflow({ workflow: JSON.parse(text), filename: file.name }); + } catch { + // The dialog is where a bad file gets explained; it reports the + // same way whether the JSON or the workflow inside it is at fault. + onDropWorkflow({ workflow: text, filename: file.name }); + } + }) + .catch(() => { + onDropWorkflow({ workflow: null, filename: file.name }); + });🤖 Prompt for 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. In `@src/components/nodes/ComfyAppNode.tsx` around lines 406 - 414, Add a rejection handler to the file.text() promise chain in the drop workflow logic so read failures still call onDropWorkflow with the file context and trigger the import dialog’s existing error reporting path. Preserve the current JSON parsing and fallback behavior for successfully read files.Source: Linters/SAST tools
src/components/WorkflowCanvas.tsx-2092-2095 (1)
2092-2095: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winReport a failed
loadWorkflow.
reader.onloadis async andawait loadWorkflow(...)has notry/catch. A rejection becomes an unhandled promise rejection, and the user gets no message — unlike the parse failure at line 2089 and the unknown-format case at line 2106, which both alert.🐛 Proposed fix
if (isNodeBananaWorkflow(parsed)) { - await loadWorkflow(parsed as WorkflowFile); + try { + await loadWorkflow(parsed as WorkflowFile); + } catch (err) { + console.error("Failed to load workflow:", err); + alert("Failed to load workflow file"); + } return; }🤖 Prompt for 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. In `@src/components/WorkflowCanvas.tsx` around lines 2092 - 2095, Wrap the await loadWorkflow(parsed as WorkflowFile) call inside the reader.onload handler in try/catch, and alert the caught error using the same user-facing error handling pattern as the parse-failure and unknown-format branches. Preserve the successful return path while ensuring loadWorkflow rejections do not become unhandled.src/components/modals/ComfyWorkflowImportModal.tsx-355-359 (1)
355-359: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winRe-check
cancelledafterreadErrorresolves.Line 355 checks
cancelledbeforereadErrorruns.readErrorawaitsresponse.json(), so the effect can be cancelled during that await.setErrorandsetMissingNodesthen write state for a workflow the dialog no longer shows. The success path already re-checks at line 361; do the same on the error path.🐛 Proposed fix
if (cancelled) return; if (!response.ok) { - setError(await readError(response, "Could not re-read this workflow.")); + const message = await readError(response, "Could not re-read this workflow."); + if (!cancelled) setError(message); return; }🤖 Prompt for 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. In `@src/components/modals/ComfyWorkflowImportModal.tsx` around lines 355 - 359, Update the error path in the workflow import effect around readError and setError to re-check cancelled after readError resolves, before performing any state updates. Preserve the existing response error handling and ensure cancelled workflows do not call setError or setMissingNodes, matching the success path’s cancellation guard.Source: Linters/SAST tools
src/components/ConnectionDropMenu.tsx-761-767 (1)
761-767: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winRemove Comfy app from 3D source options.
The 3D source branch connects a dragged 3D output to a new
comfyAppnode, buthandleMenuSelectsets no output handle forcomfyApp, so the dropped wire is discarded. A Comfy app contract has no3dinputs, andgetNodeHandles("comfyApp")only declares 3D as an output, so this entry belongs on the source-side output list rather than creating a node with a lost 3D input.🤖 Prompt for 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. In `@src/components/ConnectionDropMenu.tsx` around lines 761 - 767, Remove the `comfyApp` entry from the 3D source options in `ConnectionDropMenu`, leaving it available only in the appropriate output/source-side list. Do not alter the existing `handleMenuSelect` behavior or Comfy app node configuration.src/lib/comfy/server/legacyEngine.ts-65-76 (1)
65-76: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winGuard against a null output node, as
collectOutputFilesdoes.Line 70 reads
.textfromnodedirectly.collectOutputFilesusesnode ?? {}at line 53 for the same value from the same source. If the engine reportsnullfor one output node, this function raises a TypeError and fails a run that had finished.🛡️ Proposed fix
for (const [nodeId, node] of Object.entries(outputs ?? {})) { - const raw = (node as { text?: unknown }).text; + const raw = (node ?? {} as { text?: unknown }).text;Write it as
const raw = (node as { text?: unknown } | null)?.text;if you prefer optional chaining.🤖 Prompt for 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. In `@src/lib/comfy/server/legacyEngine.ts` around lines 65 - 76, Update collectOutputText so reading text from each output node is null-safe, matching the guard used by collectOutputFiles. Adjust the raw text access to handle a null node without throwing, while preserving the existing array filtering, trimming, and result collection behavior.src/lib/comfy/server/engine.ts-165-179 (1)
165-179: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winScan
errorseven whencauseyields no code.Line 170 returns as soon as
causeis present. If thatcausechain holds nocode, the function returnsnulland never reaches theerrorsarray at line 171. AnAggregateErrorthat carries both a code-lesscauseand per-address entries then reports no code.
createEngineFetchinsrc/lib/comfy/server/fetch.tstests that code againstCONNECT_FAILUREat line 198, so a missed code drops a safe connect-phase retry.🐛 Proposed fix
const cause = (error as { cause?: unknown }).cause; - if (cause) return errorCode(cause); + if (cause) { + const fromCause = errorCode(cause); + if (fromCause) return fromCause; + } const nested = (error as { errors?: unknown }).errors;🤖 Prompt for 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. In `@src/lib/comfy/server/engine.ts` around lines 165 - 179, Update errorCode so a present cause is checked recursively but does not terminate the search when it returns null; continue scanning the errors array and return the first nested code found. Preserve the existing null result when neither the cause chain nor errors entries contain a code.src/lib/comfy/server/run.ts-95-98 (1)
95-98: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winA cleared text parameter falls back to the author's value.
Line 97 skips any param whose value is
"". The user then gets the value the workflow author saved, not an empty value. For a prompt or a filename parameter, that result is hard to explain: the field looks empty in the node, and the run uses different text.The
undefinedandnullchecks match the documented intent at lines 67-73. The""check goes further. Consider skipping onlyundefinedandnull, and treating""as a deliberate clear for string-typed params.🛡️ Proposed fix
for (const param of app.params) { const value = params[param.id]; - if (value === undefined || value === null || value === "") continue; + // `""` is a deliberate clear for a text param; only an absent value keeps + // the author's default. + if (value === undefined || value === null) continue;Confirm the intent before you change it. A blank combo or numeric widget must still keep the author's value.
🤖 Prompt for 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. In `@src/lib/comfy/server/run.ts` around lines 95 - 98, Update the parameter loop in the run flow to skip only undefined and null values, allowing an empty string to be assigned for string-typed parameters so cleared text fields remain cleared. Preserve the author’s fallback for blank combo or numeric widgets by validating the parameter type before accepting "" and continuing to omit it for non-string parameters.src/app/api/comfy/shared.ts-78-107 (1)
78-107: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
DATA_URLrejects a data URL that carries a media-type parameter.The pattern places
(;base64)?directly after the media type. A data URL with any other parameter therefore fails the whole match:
data:image/png;base64,AAAA→ matches.data:text/plain;charset=utf-8;base64,AAAA→ group 1 takestext/plain,(;base64)?cannot match;charset=utf-8, and the required,cannot match.execreturns null.
decodeDataUrlthen returns null.src/app/api/comfy/run/route.tsLine 77 converts that into a 400 with "Could not read the media connected to ...", which names the wrong cause for a valid input.Allow arbitrary parameters between the media type and the optional
;base64.Separately,
Buffer.from(payload, "base64")does not throw on malformed base64; it decodes the valid prefix and stops. Malformed input therefore yields truncated bytes rather than null. If a strict reject is wanted, compare the re-encoded length against the payload.🐛 Proposed fix for the media-type parameter
-const DATA_URL = /^data:([^;,]+)?(;base64)?,/; +// media type, then any number of `;key=value` parameters, then optional `;base64` +const DATA_URL = /^data:([^;,]+)?((?:;[^;,]*)*?)(;base64)?,/;const contentType = match[1] || "application/octet-stream"; const payload = value.slice(match[0].length); try { - const bytes = match[2] + const bytes = match[3] ? new Uint8Array(Buffer.from(payload, "base64")) : new TextEncoder().encode(decodeURIComponent(payload));🤖 Prompt for 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. In `@src/app/api/comfy/shared.ts` around lines 78 - 107, Update DATA_URL and decodeDataUrl to accept arbitrary data-URL media-type parameters between the MIME type and optional ;base64 marker, including inputs such as charset=utf-8. Preserve contentType as the base media type and ensure the delimiter still requires a comma; also validate base64 payloads strictly by rejecting malformed input rather than decoding only a valid prefix.
🧹 Nitpick comments (15)
src/lib/comfy/__tests__/engineErrors.test.ts (1)
199-213: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the call count so the test proves cancellation.
This test asserts only that the call rejects.
noAddressAnswered()carries codeETIMEDOUT, which the retry loop treats as a connection failure and would otherwise repeat. The test therefore passes whether or not the caller-abort branch stops the retries. Capture the stub and assert it ran once.♻️ Proposed change to pin the cancellation behavior
it("stops when the caller cancels", async () => { const controller = new AbortController(); - vi.stubGlobal("fetch", async () => { + const inner = vi.fn(async () => { controller.abort(); throw noAddressAnswered(); }); + vi.stubGlobal("fetch", inner); const engineFetch = createEngineFetch({ retryBaseMs: 0 }); await expect( engineFetch("https://cloud.comfy.org/api/v2/jobs", { method: "POST", signal: controller.signal, }) ).rejects.toThrow(); + expect(inner).toHaveBeenCalledTimes(1); });🤖 Prompt for 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. In `@src/lib/comfy/__tests__/engineErrors.test.ts` around lines 199 - 213, Update the “stops when the caller cancels” test to retain the fetch stub reference and assert it was called exactly once after engineFetch rejects. Keep the existing AbortController setup and rejection assertion, ensuring the test specifically verifies that cancellation prevents retry attempts.src/lib/comfy/__tests__/run.test.ts (1)
62-69: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAssert the lower bound of
hashSeedtoo.The test proves only the upper bound. A negative return value would pass this test, and ComfyUI rejects a negative seed. Add a non-negative assertion so the range is fully pinned.
♻️ Proposed assertion
// ComfyUI declares seed maxima up to 2^64-1, which JSON cannot round-trip. expect(hashSeed("anything")).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER); + expect(hashSeed("anything")).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(hashSeed("anything"))).toBe(true);🤖 Prompt for 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. In `@src/lib/comfy/__tests__/run.test.ts` around lines 62 - 69, Update the hashSeed test in the “deterministic and stays inside the safe integer range” case to also assert that hashSeed("anything") is greater than or equal to zero, preserving the existing determinism, uniqueness, and upper-bound assertions.src/lib/comfy/server/import.ts (1)
114-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueScope
missingto the blueprint being imported.
editorNodeTypes(file)at Line 117 and Line 125 walks the root nodes and every subgraph definition in the file. In the blueprint branch, only the selected blueprint is converted, butconvertreportsmissingfrom the whole file. A file carrying several blueprints can therefore tell the user to install a node pack that the selected blueprint never uses.Compute the missing list from the lifted workflow instead.
♻️ Proposed scoping change
if (options.blueprintId) { const { workflow, instanceNodeId, skippedOutputs, unsupportedInputs } = blueprintToWorkflowFile(file, options.blueprintId); - const graph = convert(workflow, objectInfo, missing, engine.label); + const blueprintMissing = editorNodeTypes(workflow).filter((type) => !objectInfo[type]); + const graph = convert(workflow, objectInfo, blueprintMissing, engine.label);🤖 Prompt for 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. In `@src/lib/comfy/server/import.ts` around lines 114 - 147, Scope missing-node detection to the selected blueprint’s lifted workflow in the blueprint branch: derive the workflow via blueprintToWorkflowFile before filtering node types, then compute and refresh missing using that workflow rather than editorNodeTypes(file). Keep the non-blueprint path using the full file and pass the blueprint-specific missing list to convert.src/components/WorkflowCanvas.tsx (1)
746-748: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThis branch duplicates the fallthrough below it.
The added clause returns
sourceType === "audio" && targetType === "audio", which is exactly what line 749 already returns for the same inputs. Line 744 only returns early foroutputandroutertargets, so acomfyAppend always reaches line 749 with the same result. Remove the clause, or state what it must diverge on.♻️ Proposed change
- if (sourceNode?.type === "comfyApp" || targetNode?.type === "comfyApp") { - return sourceType === "audio" && targetType === "audio"; - } return sourceType === "audio" && targetType === "audio";🤖 Prompt for 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. In `@src/components/WorkflowCanvas.tsx` around lines 746 - 748, Remove the redundant comfyApp condition from the connection validation logic near the existing output/router early return, leaving the fallthrough audio-type check unchanged. Do not alter behavior for output or router targets.src/lib/comfy/reconfigure.ts (1)
11-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider exporting
bindingKeyinstead of duplicating it.
ComfyWorkflowImportModal.tsxline 95 defines the same${nodeId}:${inputKey}helper. Both copies must stay identical, becausewithAppLabelsand the modal'srolesmap key on the same string. Export this one and import it in the modal.♻️ Proposed change
-const bindingKey = (nodeId: string, inputKey: string): string => `${nodeId}:${inputKey}`; +export const bindingKey = (nodeId: string, inputKey: string): string => `${nodeId}:${inputKey}`;🤖 Prompt for 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. In `@src/lib/comfy/reconfigure.ts` at line 11, Export the bindingKey helper from reconfigure.ts, then remove the duplicate helper in ComfyWorkflowImportModal.tsx and import/reuse bindingKey for the modal’s roles map. Preserve the existing `${nodeId}:${inputKey}` key format.src/lib/comfy/server/run.ts (1)
21-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThe modulo in
hashSeednever applies.
his reduced to a signed 32-bit integer by| 0, soMath.abs(h)is at most 2^31.MAX_SEEDisNumber.MAX_SAFE_INTEGER, which is 2^53 − 1. The% MAX_SEEDoperation is therefore always a no-op, and the comment at line 21 describes a bound the code does not need to enforce here.Drop the modulo, or state that the 32-bit reduction is what keeps the value safe.
♻️ Proposed simplification
-/** Seeds must stay inside the range JSON can round-trip without precision loss. */ -const MAX_SEED = Number.MAX_SAFE_INTEGER; - /** A deterministic seed derived from a run key. */ export function hashSeed(key: string): number { let h = 0; + // `| 0` keeps the accumulator a signed 32-bit integer, which is well inside + // the range JSON round-trips without precision loss. for (let i = 0; i < key.length; i += 1) h = (Math.imul(h, 31) + key.charCodeAt(i)) | 0; - return Math.abs(h) % MAX_SEED; + return Math.abs(h); }🤖 Prompt for 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. In `@src/lib/comfy/server/run.ts` around lines 21 - 29, Update hashSeed to remove the redundant modulo by MAX_SEED, or revise the nearby comment to state that the signed 32-bit reduction already keeps the result within the safe range; keep the existing deterministic hashing behavior unchanged.src/lib/comfy/settings.ts (2)
32-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated job-timeout bounds and clamp logic in
src/lib/comfy/settings.tsandsrc/lib/comfy/server/connection.ts. Both files declareMIN_JOB_TIMEOUT_MSandMAX_JOB_TIMEOUT_MSwith the same values, and both repeat the identicalNumber.isFiniteclamp expression. The two copies can drift, andconnection.tsalready importsCOMFY_DEFAULT_JOB_TIMEOUT_MSfromsettings.ts, so one shared helper covers both.
src/lib/comfy/settings.ts#L32-L33: export the two bounds and add aclampJobTimeoutMshelper, then call it innormalizeComfySettings.src/lib/comfy/server/connection.ts#L13-L14: delete the local bound declarations, importclampJobTimeoutMsfrom../settings, and call it inconnectionFromRequest.🤖 Prompt for 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. In `@src/lib/comfy/settings.ts` around lines 32 - 33, Centralize job-timeout bounds and clamping: in src/lib/comfy/settings.ts:32-33, export MIN_JOB_TIMEOUT_MS and MAX_JOB_TIMEOUT_MS, add clampJobTimeoutMs using the shared finite-value clamp logic, and use it from normalizeComfySettings. In src/lib/comfy/server/connection.ts:13-14, remove the local bounds, import clampJobTimeoutMs from ../settings, and use it in connectionFromRequest.
35-65: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMove
ComfySettingsinto the Comfy type contract module.
src/lib/comfy/settings.tsdeclaresComfySettingsand importsComfyBackendMode/ComfyConnectionfromsrc/lib/comfy/types.ts, so keep matching interfaces in one Comfy contract location. Updatesrc/types/index.tsif that remains the required central export.🤖 Prompt for 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. In `@src/lib/comfy/settings.ts` around lines 35 - 65, Move the ComfySettings interface from the settings module into the Comfy contract module alongside ComfyBackendMode and ComfyConnection, updating imports and references accordingly. Preserve all fields and documentation, remove the duplicate declaration, and update src/types/index.ts if it is the central export required to expose ComfySettings.Source: Coding guidelines
src/lib/comfy/server/sdkEngine.ts (1)
254-287: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
toEngineErrorthrows instead of returning on one branch.Line 277 throws from a function whose declared return type is
ComfyEngineError. Every call site writesthrow toEngineError(...), so the behavior is correct today. The signature does not describe it, and a later refactor that assigns the result instead of throwing it would silently swallow the cancellation.Return the abort case to the caller as a decision, or split the check out.
♻️ Proposed refactor
- if (error instanceof DOMException && error.name === "AbortError") { - // Propagate cancellation untouched — the caller distinguishes it. - throw error; - }Then guard at each call site, for example in
upload:} catch (error) { + // Cancellation is not an engine failure — the caller distinguishes it. + if (isAbort(error)) throw error; throw toEngineError(error, `${this.label} rejected the upload of ${input.filename}`, this.label); }with a shared helper:
const isAbort = (error: unknown): boolean => error instanceof DOMException && error.name === "AbortError";🤖 Prompt for 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. In `@src/lib/comfy/server/sdkEngine.ts` around lines 254 - 287, Update toEngineError so it always returns a ComfyEngineError as declared, removing the internal AbortError throw; preserve cancellation by handling the abort decision at each caller before invoking toEngineError, using a shared isAbort helper if appropriate. Update every throw toEngineError call site, including upload, to rethrow AbortError unchanged and retain existing error conversion for all other errors.src/app/api/comfy/__tests__/poll.route.test.ts (1)
1-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for the limit split the docblock describes, or narrow the docblock.
Lines 1-10 explain that a status poll and a collect need different limits, and give the 45 s and 73 s figures. No test asserts which limit the route applies to which request. The four tests cover the collect / no-collect behavior only. A regression that reunites the two limits would still pass.
cancel: vi.fn()at line 21 is also never asserted. Remove it, or add the cancellation case.Run the following script to find the limit the route actually selects, so the assertion can target it:
#!/bin/bash # Description: Locate the per-request-kind timeout selection in the poll route and its shared helpers. set -euo pipefail fd -t f 'route.ts' src/app/api/comfy --exec rg -n -C4 'maxDuration|timeout|collect|cancel' {} fd -t f 'shared.ts' src/app/api/comfy --exec rg -n -C4 'maxDuration|timeout' {}🤖 Prompt for 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. In `@src/app/api/comfy/__tests__/poll.route.test.ts` around lines 1 - 29, Add tests in the poll route suite that assert status-poll and collect requests select their distinct timeout limits, using the route’s actual timeout configuration symbols and preserving the documented 45-second versus longer collection behavior. Also remove the unused cancel mock from engineFromRequest, or add a test covering the route’s cancellation path and asserting that mock is called.src/app/api/comfy/shared.ts (1)
118-123: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueBound and normalize
ext.
contentTypereaches this function from the caller's data URL, wheredecodeDataUrlcaptures the media type with[^;,]+. A path separator cannot survivesplit("/")[1], andslugfilters out/, so the filename cannot escape a directory. The remaining gap is shape rather than traversal:extis unbounded in length and may hold%, spaces, or unicode, and it is sent to the engine upload endpoint as-is.Constrain
extto a short alphanumeric token, and fall back tobin.♻️ Proposed hardening
export function uploadFilename(name: string, contentType: string, bytes: Uint8Array): string { - const ext = contentType.split("/")[1]?.split("+")[0] ?? "bin"; + const raw = contentType.split("/")[1]?.split("+")[0] ?? ""; + const ext = /^[a-zA-Z0-9]{1,8}$/.test(raw) ? raw.toLowerCase() : "bin"; const slug = name.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 24) || "input";🤖 Prompt for 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. In `@src/app/api/comfy/shared.ts` around lines 118 - 123, Update uploadFilename to normalize and bound the ext value derived from contentType: retain only alphanumeric characters, limit it to a short fixed length, and use "bin" when the result is empty. Keep the existing slug and hash naming behavior unchanged.scripts/comfy-smoke.mjs (2)
200-219: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winA catalog fetch failure leaves the recorded corpus inconsistent.
Line 200 fetches
/api/object_infowith no error handling, after the per-blueprint files were already written on Lines 189-192. If that fetch fails,recordthrows and leaves fresh blueprint recordings beside a staleobject-info.jsonandmanifest.json.
src/lib/comfy/__tests__/catalog.test.tsthen runs against a mismatched corpus. A newly recorded blueprint can name a node class the stale catalog does not hold, so the hermetic test fails with an unknown-node-type error rather than a clear "recording incomplete" signal.Collect the blueprints and the catalog first, then write all three artifacts together.
♻️ Proposed fix — fetch the catalog before writing
Collect blueprint payloads in memory during the loop, then fetch the catalog, then write:
- writeFileSync( - join(FIXTURES, "blueprints", `${id}.json`), - JSON.stringify({ name: entry.name ?? id, why, workflow }) - ); - recorded.push({ id, name: entry.name ?? id, why }); + pending.push({ + path: join(FIXTURES, "blueprints", `${id}.json`), + body: JSON.stringify({ name: entry.name ?? id, why, workflow }), + }); + recorded.push({ id, name: entry.name ?? id, why }); ok(id);const catalog = await engineJson("/api/object_info"); const trimmed = {}; for (const type of [...classTypes].sort()) { if (catalog[type]) trimmed[type] = trimEntry(catalog[type]); } + + // Everything fetched — now commit the corpus as one set. + for (const { path, body } of pending) writeFileSync(path, body); writeFileSync(join(FIXTURES, "object-info.json"), JSON.stringify(trimmed));Declare
const pending = [];besiderecordedon Line 175.As per coding guidelines: "Maintain the ComfyUI Blueprint corpus regression test using the recorded catalog and workflows; it must run hermetically in CI."
🤖 Prompt for 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. In `@scripts/comfy-smoke.mjs` around lines 200 - 219, Update the recording flow around the blueprint loop and catalog fetch to collect each blueprint payload in memory alongside recorded metadata instead of writing files immediately. Fetch and validate /api/object_info before any artifact writes, then write the pending blueprint files, object-info.json, and manifest.json together only after all collection succeeds, preserving the existing artifact contents and naming.Source: Coding guidelines
317-323: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winA non-transient poll failure leaves the job running.
Line 322 returns without cancelling. The job stays queued or running on the engine and keeps consuming GPU credits. The timeout branch on Line 311 does cancel, so the intent is established; this path misses it.
The file header states that
runcosts credits on Cloud, andrun()iterates the whole corpus, so a repeated route failure abandons one live job per blueprint.Cancel before returning on this path.
♻️ Proposed fix
const polled = await nb("/api/comfy/poll", { jobId, app }); if (!polled.res.ok) { // A route that says it could not reach the engine is reporting the // network, not a verdict — the render is very likely still going. if (polled.json?.transient) continue; + await nb("/api/comfy/poll", { jobId, app, cancel: true }).catch(() => {}); return { id, ok: false, stage: "run", error: polled.json?.error ?? polled.text.slice(0, 200) }; }🤖 Prompt for 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. In `@scripts/comfy-smoke.mjs` around lines 317 - 323, Update the non-transient poll-failure branch in run() to cancel the active job before returning the run-stage error. Reuse the same cancellation behavior already used by the timeout path, ensuring cancellation is attempted for polled.res.ok failures where polled.json?.transient is false.src/lib/comfy/server/index.ts (1)
66-87: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
options.signalis accepted but never used.
getObjectInfodeclaressignal?: AbortSignaland never reads it. The doc comment on Line 77 explains why the signal must not reachengine.objectInfo(). That reasoning is correct for the shared fetch, but the parameter still advertises cancellation that the function cannot provide. Callers such assrc/app/api/comfy/status/route.tsLine 40 passrequest.signaland get no cancellation.Either remove the option, or honor it on the caller's side only by racing the shared promise against the signal. The second form cancels the caller's wait without aborting the shared fetch.
♻️ Option A — drop the unused option
export async function getObjectInfo( engine: ComfyEngine, - options: { force?: boolean; signal?: AbortSignal } = {} + options: { force?: boolean } = {} ): Promise<ComfyObjectInfo> {Then remove
signalfrom the call sites, for example insrc/app/api/comfy/status/route.ts:const catalog = await getObjectInfo(engine).catch(() => null);🤖 Prompt for 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. In `@src/lib/comfy/server/index.ts` around lines 66 - 87, Update getObjectInfo so options.signal is either removed from its API and all call sites stop passing it, or the caller’s wait races the shared catalog promise against the signal without aborting engine.objectInfo(). Preserve the shared catalogCache behavior and ensure cancellation only affects the requesting caller.src/app/api/comfy/poll/route.ts (1)
52-57: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winValidate
body.appalongsidebody.jobId.The route guards
jobIdbut never checksapp.nameFailedOutputon Line 83 andcollectRunon Line 97 both readbody.app. A request that omitsapp, or sends it malformed, therefore fails inside those helpers and returns a generic 500 throughcomfyErrorResponse.src/app/api/comfy/run/route.tsLine 53 validates the same field before use.Add a matching guard, placed after the cancel branch so a cancel still works without
app.♻️ Proposed guard
if (body.cancel) { await engine.cancel(body.jobId, request.signal); return NextResponse.json<ComfyPollResponse>({ success: true, polling: false, status: "cancelled", }); } + + if (!body.app?.outputs) { + return NextResponse.json( + { success: false, error: "This node has no ComfyUI workflow attached yet." }, + { status: 400 } + ); + }🤖 Prompt for 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. In `@src/app/api/comfy/poll/route.ts` around lines 52 - 57, Update the POST handler’s request validation to validate body.app in the same manner as the existing run route, placing the guard after the cancel branch so cancellation remains valid without app. Reject missing or malformed app values before nameFailedOutput and collectRun execute, while preserving the existing jobId validation and error response behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: a1a632db-960a-42fb-bdc7-7b80c1c2c9a1
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (88)
CLAUDE.mdREADME.mdpackage.jsonscripts/comfy-smoke.mjssrc/app/api/comfy/__tests__/poll.route.test.tssrc/app/api/comfy/blueprints/route.tssrc/app/api/comfy/inspect/route.tssrc/app/api/comfy/poll/route.tssrc/app/api/comfy/run/route.tssrc/app/api/comfy/shared.tssrc/app/api/comfy/status/route.tssrc/app/globals.csssrc/components/AnnotationModal.tsxsrc/components/ConnectionDropMenu.tsxsrc/components/GlobalImageHistory.tsxsrc/components/KeyboardShortcutsDialog.tsxsrc/components/ProjectSetupModal.tsxsrc/components/WorkflowCanvas.tsxsrc/components/__tests__/AnnotationModal.test.tsxsrc/components/__tests__/ComfyAppNode.test.tsxsrc/components/__tests__/ConnectionDropMenu.test.tsxsrc/components/icons/ComfyMark.tsxsrc/components/icons/ComfyWordmark.tsxsrc/components/modals/ComfyWorkflowImportModal.tsxsrc/components/nodes/ComfyAppNode.tsxsrc/components/nodes/ComfyAppParameters.tsxsrc/components/nodes/ComfyCurveEditor.tsxsrc/components/nodes/FloatingNodeHeader.tsxsrc/components/nodes/index.tssrc/components/settings/ComfySettingsTab.tsxsrc/lib/comfy/__tests__/catalog.test.tssrc/lib/comfy/__tests__/curve.test.tssrc/lib/comfy/__tests__/detect.test.tssrc/lib/comfy/__tests__/editor.test.tssrc/lib/comfy/__tests__/engineErrors.test.tssrc/lib/comfy/__tests__/fixtures/catalog/blueprints/canny_to_video_ltx_2_0.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/character_replacement_scail_2_base.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/color_curves.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/crop_images_3x3.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/image_captioning_gemini.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/image_edit_qwen_2509.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/image_outpainting_qwen_image.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/image_segmentation_sam3.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/image_to_gaussian_splat_triposplat.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/image_to_model_hunyuan3d_2_1.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/merge_videos.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/select_per_line_text_by_index.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/text_to_audio_ace_step_1_5.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/text_to_image_ernie_image.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/text_to_video_ltx_2_3.jsonsrc/lib/comfy/__tests__/fixtures/catalog/blueprints/text_to_video_wan_2_2.jsonsrc/lib/comfy/__tests__/fixtures/catalog/manifest.jsonsrc/lib/comfy/__tests__/fixtures/catalog/object-info.jsonsrc/lib/comfy/__tests__/graph.test.tssrc/lib/comfy/__tests__/inspect.test.tssrc/lib/comfy/__tests__/reconfigure.test.tssrc/lib/comfy/__tests__/run.test.tssrc/lib/comfy/__tests__/settings.test.tssrc/lib/comfy/buildApp.tssrc/lib/comfy/curve.tssrc/lib/comfy/detect.tssrc/lib/comfy/editor.tssrc/lib/comfy/graph.tssrc/lib/comfy/inspect.tssrc/lib/comfy/nodeSchema.tssrc/lib/comfy/reconfigure.tssrc/lib/comfy/server/connection.tssrc/lib/comfy/server/engine.tssrc/lib/comfy/server/fetch.tssrc/lib/comfy/server/import.tssrc/lib/comfy/server/index.tssrc/lib/comfy/server/legacyEngine.tssrc/lib/comfy/server/run.tssrc/lib/comfy/server/sdkEngine.tssrc/lib/comfy/settings.tssrc/lib/comfy/types.tssrc/lib/quickstart/validation.tssrc/store/execution/__tests__/comfyAppExecutor.test.tssrc/store/execution/comfyAppExecutor.tssrc/store/execution/executeNode.tssrc/store/execution/index.tssrc/store/utils/__tests__/comfyConnectedInputs.test.tssrc/store/utils/connectedInputs.tssrc/store/utils/nodeDefaults.tssrc/store/workflowStore.tssrc/types/nodes.tssrc/utils/downloadMedia.tssrc/utils/mediaStorage.ts
| <svg | ||
| ref={svgRef} | ||
| viewBox={`0 0 ${WIDTH} ${HEIGHT}`} | ||
| width={WIDTH} | ||
| height={HEIGHT} | ||
| className="nodrag nopan nowheel rounded-md bg-[#1a1a1a] touch-none select-none" | ||
| onPointerMove={handlePointerMove} | ||
| onPointerUp={endDrag} | ||
| onPointerLeave={endDrag} | ||
| onClick={handleBackgroundClick} | ||
| onContextMenu={(e) => e.preventDefault()} | ||
| > | ||
| {grid.map((line) => ( | ||
| <React.Fragment key={line.percent}> | ||
| <line x1={line.x} y1={0} x2={line.x} y2={HEIGHT} stroke="#2e2e2e" strokeWidth={1} /> | ||
| <line x1={0} y1={line.y} x2={WIDTH} y2={line.y} stroke="#2e2e2e" strokeWidth={1} /> | ||
| </React.Fragment> | ||
| ))} | ||
| {/* The untouched response, for comparison against the curve. */} | ||
| <line | ||
| x1={0} | ||
| y1={HEIGHT} | ||
| x2={WIDTH} | ||
| y2={0} | ||
| stroke="#3a3a3a" | ||
| strokeWidth={1} | ||
| strokeDasharray="3 3" | ||
| /> | ||
| <path d={path} fill="none" stroke="#e5e5e5" strokeWidth={1.75} strokeLinecap="round" /> | ||
| {curve.points.map((point, index) => { | ||
| const cx = point[0] * WIDTH; | ||
| const cy = (1 - point[1]) * HEIGHT; | ||
| const locked = index === 0 || index === curve.points.length - 1; | ||
| return ( | ||
| <circle | ||
| key={`${index}-${point[0]}`} | ||
| cx={cx} | ||
| cy={cy} | ||
| r={dragging === index ? 6 : 4.5} | ||
| fill={dragging === index ? "#ffffff" : "#d4d4d4"} | ||
| stroke="#1a1a1a" | ||
| strokeWidth={1.5} | ||
| className="cursor-grab" | ||
| onPointerDown={(e) => { | ||
| e.stopPropagation(); | ||
| setDragging(index); | ||
| e.currentTarget.setPointerCapture(e.pointerId); | ||
| }} | ||
| onClick={(e) => e.stopPropagation()} | ||
| onDoubleClick={(e) => { | ||
| e.stopPropagation(); | ||
| onChange(removeCurvePoint(curve, index)); | ||
| }} | ||
| onContextMenu={(e) => { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| onChange(removeCurvePoint(curve, index)); | ||
| }} | ||
| > | ||
| {locked && <title>Endpoint — moves vertically only</title>} | ||
| </circle> | ||
| ); | ||
| })} | ||
| </svg> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚖️ Poor tradeoff
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Look for any non-pointer editing path for curve parameters.
set -euo pipefail
rg -nP --type=tsx --type=ts -C3 '\b(curve|Curve)\b' src/components src/store | rg -n 'tabIndex|onKeyDown|role=|aria-' || echo "no keyboard/aria affordance found near curve editing"Repository: shrimbly/node-banana
Length of output: 243
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Locate ComfyCurveEditor and curve-related files =="
fd -a 'ComfyCurveEditor\.tsx|.*curve.*|.*Curve.*' src || true
echo
echo "== Search for curve text/tabindex/keydown/aria in ts/tsx without file type =="
rg -n --glob '*.{ts,tsx}' -C2 -i '\b(curve|Curve)\b|tabIndex|tabindex|tab index|tab_index|onKeyDown|keydown|aria-|role=' src || true
echo
echo "== Read ComfyCurveEditor outline and relevant source =="
wc -l src/components/nodes/ComfyCurveEditor.tsx
ast-grep outline src/components/nodes/ComfyCurveEditor.tsx --view expanded || true
sed -n '1,240p' src/components/nodes/ComfyCurveEditor.tsxRepository: shrimbly/node-banana
Length of output: 50377
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== ComfyCurveEditor source =="
sed -n '1,240p' src/components/nodes/ComfyCurveEditor.tsx
echo
echo "== EaseCurveNode source focused area =="
sed -n '1,220p' src/components/nodes/EaseCurveNode.tsx
echo
echo "== Precise curve + keyboard/focus affordances in src/components/nodes =="
rg -n --glob '*.{ts,tsx}' -C2 \
'(sourceType|type|nodeType).*easeCurve|easeCurve.*|ComfyCurveEditor|\\btabIndex\\b|tabIndex=|onKeyDown|onKeyPress|role="button"|role=\x27button\x27|aria-label|setDragging|removeCurvePoint|addCurvePoint|moveCurvePoint' \
src/components/nodes src/store src/lib || trueRepository: shrimbly/node-banana
Length of output: 50376
Add keyboard access for the CURVE editor.
ComfyCurveEditor only exposes pointer, double-click, and context-menu handlers; the container has no focus role, so keyboard-only users can reset the curve but cannot add, move, or remove points. Add a focusable editing path, such as making the selected point receive keyboard focus and nudge via arrow keys or provide x/y numeric controls for that point.
🤖 Prompt for 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.
In `@src/components/nodes/ComfyCurveEditor.tsx` around lines 108 - 171, Add
keyboard editing to the point controls rendered in ComfyCurveEditor: make points
focusable and handle arrow-key events to nudge the focused point, while
preserving endpoint restrictions and existing pointer removal behavior. Ensure
keyboard users can also remove the focused non-endpoint point through an
appropriate key action, and expose an accessible label or role describing each
point’s editable position.
There was a problem hiding this comment.
Skipping this one.
It is a fair accessibility gap, but adding keyboard editing to the curve widget — focusable points, arrow-key nudging, a key to remove one, plus the labels to describe them — is a feature, not a correction to something that is wrong. The brief for this pass was to fix defects without changing how the editor behaves, and this would change it.
What did get fixed there in #141 is the drag itself: the circle was keyed on its own moving x, so React remounted it on every frame and the browser released pointer capture with the removed element. A drag stopped tracking as soon as the pointer left the SVG.
There was a problem hiding this comment.
@shrimbly Acknowledged. The keyboard editing gap remains valid, but it is outside the scope of this defect-fix pass.
The key change in #141 addresses the separate drag defect. Keeping the circle mounted preserves pointer capture when its x-coordinate changes.
🐇
You are interacting with an AI system.
A workflow whose subgraph carries promoted widgets arrived with no inputs and no settings at all. Unpacking the subgraph in ComfyUI made them work, which is the tell: expansion is where they were being lost. Two independent faults, both silent. App Mode addresses a promoted widget by the *instance* node — `105:prompt`. Conversion expands that node away into `105:104`, so the id matched nothing and every such entry was dropped without a word. Worse, App Mode is followed exactly once present, so the heuristics did not fill in behind it and the node was proposed with an empty surface. The widget name is a boundary slot, and the control belongs to whatever that slot drives inside; resolving it that way also recovers the author's own name for it — `duration`, not `PrimitiveFloat · Value`. The second is worse than a missing control. An instance's `widgets_values` line up against the *definition's* widget-backed boundary slots, not against the instance's own `inputs`, which materialise only the slots the author wired or touched. On the reported workflow that is nine values against four inputs, and the mismatch was not visible as an error: `duration` was 768, the audio VAE filename was the number 5, and the prompt the user had typed was replaced by a stale default saved inside the subgraph. Also stops a curated surface that resolves to nothing from proposing an empty node. Detection is a poorer answer than the author's and a far better one than none, and the outputs are recorded separately so they stay honoured either way.
fix(comfy): keep the widgets an author promoted onto a subgraph node
A running node drew its blue outline around the body only, stopping above the settings panel; selecting the same node outlined all of it. The two states described the same node as two different shapes. Selection already handled this: it moves its ring onto the wrapper that encloses both the body and the panel, and leaves the body a plain border. The running outline never learned the trick — it was drawn on the body unconditionally, so with the panel open it traced an edge through the middle of the node. Reported on a Comfy app node, but BaseNode is shared, so every node type with a settings panel had it. Also stops a node that is both running and selected from drawing two rings.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/lib/comfy/__tests__/editor.test.ts (1)
873-878: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueStrengthen the off-by-one assertion.
.not.toBe(1344)passes whengraph["105:1"]isundefined, wheninputs.promptis absent, and for any wrong value other than that one number. Test 1 already assertspromptis"a red car", so this test adds little.An off-by-one from counting
first_frameshifts every value and leaves the last widget-backed slot unset. Assert that directly.♻️ Proposed change to assert the shift directly
it("does not spend a value on a slot that is fed by a link", () => { // `first_frame` reaches a socket, so it carries no widget value. Counting // it would shift every value after it by one. const graph = convertEditorGraph(promotedFile(), promotedCatalog); - expect(graph["105:1"]?.inputs.prompt).not.toBe(1344); + // Counting `first_frame` would shift every value one slot earlier and + // leave the final widget-backed slot without a promoted value. + expect(graph["105:1"]?.inputs.prompt).toBe("a red car"); + expect(graph["105:3"]?.inputs.vae_name).toBe("audio.safetensors"); });🤖 Prompt for 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. In `@src/lib/comfy/__tests__/editor.test.ts` around lines 873 - 878, Strengthen the assertion in the “does not spend a value on a slot that is fed by a link” test by verifying the last widget-backed slot is populated with its expected value, rather than only asserting prompt is not 1344. Use the existing graph structure and expected fixture value to ensure the test fails when counting first_frame shifts subsequent values and leaves the final slot unset.src/lib/comfy/editor.ts (1)
704-709: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueNested subgraph instances are still dropped.
promotedBindingssearches onlyfile.nodes. An App Mode entry that names an instance nested inside another subgraph definition finds no match, so the function returns[]and the control disappears without a message. That is the same silent drop this change removes for top-level instances.The fix is a recursive search across
definitions.subgraphs[*].nodes, with the resolved prefix carried intoresolveProxied. If nested App Mode promotion is out of scope for this PR, a short note here would record the limit.🤖 Prompt for 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. In `@src/lib/comfy/editor.ts` around lines 704 - 709, Update promotedBindings to recursively search nodes inside definitions.subgraphs[*].nodes when resolving an instance, not only file.nodes. Carry the resolved nested prefix through resolveProxied so nested subgraph instances are promoted instead of returning [] silently; preserve the existing top-level lookup behavior.
🤖 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.
Nitpick comments:
In `@src/lib/comfy/__tests__/editor.test.ts`:
- Around line 873-878: Strengthen the assertion in the “does not spend a value
on a slot that is fed by a link” test by verifying the last widget-backed slot
is populated with its expected value, rather than only asserting prompt is not
1344. Use the existing graph structure and expected fixture value to ensure the
test fails when counting first_frame shifts subsequent values and leaves the
final slot unset.
In `@src/lib/comfy/editor.ts`:
- Around line 704-709: Update promotedBindings to recursively search nodes
inside definitions.subgraphs[*].nodes when resolving an instance, not only
file.nodes. Carry the resolved nested prefix through resolveProxied so nested
subgraph instances are promoted instead of returning [] silently; preserve the
existing top-level lookup behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: af3990c5-8d38-46f0-b9de-d954dba41110
📒 Files selected for processing (4)
src/lib/comfy/__tests__/editor.test.tssrc/lib/comfy/__tests__/inspect.test.tssrc/lib/comfy/editor.tssrc/lib/comfy/inspect.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- src/lib/comfy/tests/inspect.test.ts
- src/lib/comfy/inspect.ts
fix(node): outline the whole node while it runs, settings included
The confirm step listed bindings and asked the user to picture the result. What they are actually deciding is the shape of a node — how many handles it carries, what those are called, which knobs sit under it — so the node is now drawn beside the list and follows every pick. The dialog widens only for this step and the picks column narrows into it; the preview sits in a recessed panel 8px from the dialog's edge, with the node at its real 260px width so the view is a measurement rather than an impression. Settings are drawn open, since collapsed they would be the one thing this exists to show, reduced to a chevron. A replica, not the node itself: the real one is a React Flow node wired to the workflow store and standing one up here would mean faking a canvas. The settings are the exception — already a plain controlled component, so the real one is used, seeded from the defaults exactly as the node seeds itself on attach. Values are editable but not saved; a control that cannot be moved reads as a broken one. Verified against the reported MiniMax workflow end to end: turning `prompt` from an input into a setting moves it from a handle to a text box in the preview, and exposing eight more widgets grows the node without clipping.
The scrollbar drew a filled grey track down the side of every panel — a hard vertical rule the layout never asked for, and most visible exactly where panels nest inside panels, which is most of this app. Now the track is transparent and only the thumb is drawn. It is inset by a transparent border rather than by narrowing the bar, so the visible mark is 4px while the thing you have to hit stays 8px. Its colour is translucent white instead of a fixed grey, so one rule reads correctly on all four of the darks the app stacks — canvas, panel, dialog, settings drawer — rather than being tuned to one and muddy on the rest. Firefox gets the same through `scrollbar-width`/`scrollbar-color` on `html`, which inherits, so it needs no per-element repetition. Also weights the widget-group headings' padding to the top. A heading belongs to what comes after it; evenly padded it floated between two groups and read as ending the one above.
The running highlight stopped at the body, so a node with its settings open was drawn as two different shapes depending on why it was lit. The first attempt moved a ring onto the wrapper that encloses both, but at ring-1/20% — invisible on the canvas, and the body kept its own blue border underneath, which read as a double outline down the sides that stopped where the settings began. Now the wrapper carries the only blue line, at full opacity, and the body's border goes transparent while it does. The test asserted merely that a ring enclosed the panel, which is why the bug survived it; it now asserts the weight, and that nothing inside draws a second one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A workflow you have confirmed and dialled in was worth keeping, and the only way to get it back was to import it again and re-pick everything. "Save as node" in the confirm step keeps the workflow, its contract and the values the node is running — seeds excluded, since they re-randomise per run, and pinning one would make every copy produce the same picture. Saved nodes then appear as ordinary nodes: in the canvas double-click search, in the connection-drop menus for any handle type their contract matches, and in the dialog's own tab. All three create a plain comfyApp node seeded from the entry, so there is no new node type to maintain. Saving is a snapshot. A node created from an entry records savedNodeId, so the dialog can offer to update that entry rather than leaving a pile of near-identical ones as the only way back; attaching a different workflow clears it. Entries live in localStorage on the node-banana-comfy-apps key, declared for this a while ago and unused until now. That caps the library at the browser's quota, so a failed write is reported where the button is — a save that silently did nothing is worse than one that refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A running node had the same spinner as everything else, while the engine was streaming pictures of the work in progress the whole time. `/api/comfy/preview` relays those frames from the v2 event stream, which the page cannot read itself: it needs an Authorization header, and EventSource cannot send one. They live in component state rather than in node data — a 50–80KB JPEG belongs to a run, and node data is what gets written into saved workflow files. The payload is not the bare JPEG the SDK's types describe. ComfyUI wraps it — [uint32 kind][uint32 jsonLength][JSON metadata][image bytes] — and taking the type at its word put a broken-image icon in the middle of the node. Only looking at a real render caught it; the tests I had written passed either way, and now encode the real shape. The frame's own node_id arrives empty too; the envelope's metadata carries it. Progress is deliberately not drawn, though it rides the same stream. Measured against a live Cloud render it reports no node name, no step counts, and a fraction computed against a node total that grows as the graph expands — so it reaches 100% four times before the job ends. The job record's own progress field stays null throughout, despite the spec documenting it. A picture is the honest half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five things the review found in the layer between Node Banana and a ComfyUI engine, all of them cases where the code did less than its own comment claimed. - `createEngineFetch` bounded connect failures by attempt count alone. `ETIMEDOUT` is a connect failure that costs a whole timeout, so five of them ran for 150s on a poll and 25 minutes on an asset route — past the "no more than two timeouts" the code says it enforces. The deadline now applies to both branches; a refused connection still gets all four retries, because it costs milliseconds. - `resilientFetch` abandoned the response it was retrying past without reading or cancelling its body, holding one socket per attempt. - `errorCode` stopped at a `cause` that carried no code, so an AggregateError holding both that and one coded entry per address reported nothing — and no code means no safe connect-phase retry. - The blueprint routes allowed a retry budget of 150s inside a 120s invocation, so a slow engine produced a platform timeout instead of the curated error the route works to build. - `collectOutputText` read `.text` off an output node directly, where `collectOutputFiles` guards the same value; a null node failed a run that had already finished. Also drops `getObjectInfo`'s `signal`, which it accepted and could never honour — the catalog fetch is shared between callers — and moves the job-timeout clamp into one exported helper instead of two copies that could drift.
The run route asked two different questions about the same value. The
required-input check treated anything but `undefined` as present; the
loop that patches the graph treated anything but a non-empty string as
absent. So `inputs: { prompt: "" }` for a required input passed
validation, was skipped by the loop, and submitted a job with nothing
patched in — the user got a render from the workflow author's saved
text instead of the 400 that exists to explain this. One predicate now
answers both, and it answers before any media is decoded and hashed.
`decodeDataUrl` rejected any data URL carrying a media-type parameter:
`(;base64)?` sat directly after the type, so `charset=utf-8` broke the
whole match and a valid input was reported as media that could not be
read. The filename built from that type is now held to the shape of an
extension, since it reaches the engine as one.
Alongside: the poll route validates `app` the way the run route does,
rather than failing inside `collectRun` as a bare 500 — placed after
the cancel branch, which needs no contract. The workflow size cap is
measured in bytes rather than UTF-16 code units, which a workflow of
three-byte characters passed at three times its stated size. And the
best-effort cancel gets its own 10s bound: cancelling is what unblocks
the node, so a stalled route must not hold Stop waiting on it.
A Blueprint's boundary slot can feed several inner nodes at once — one prompt reaching two text encoders, one `ckpt_name` reaching a checkpoint loader and a VAE loader. The author exposed one control, so the run has to write all of them from it, and `alsoBind` carries those extra bindings. It existed only on parameters. A `STRING` slot is declared connectable, so it becomes a handle instead, and `ComfyAppInput` had nowhere to put the carry-through — the secondary encoders kept the workflow's own saved text while the primary one got the user's, with nothing to show for it on the node. `ComfyAppInput` now carries `alsoBind` too, the run writes it, and the prune keeps the nodes it reaches so patching cannot land on a node that is no longer in the graph. Also: `hashSeed`'s modulo was a no-op — `| 0` already keeps the accumulator far inside the safe integer range — so it says that instead.
Two ways a Comfy node's own contract was not being read. Handle resolution reached the `comfyApp` branch only through a guard on a non-empty `inputSchema`, but a workflow's outputs are declared independently of its inputs. A text-to-image app with nothing to connect has an empty schema and a perfectly good image output — and dragging from another node's input onto it found nothing and dropped the wire. Outputs are resolved before that guard now. And every output was externalized through the image store while hydration reads video and audio out of the generation store, so a saved workflow reopened with those outputs empty and the node looking as if it had never run. Each type goes to the store it comes back from; a store that declines the value leaves it inline rather than writing a ref that points at nothing. Also: a dropped `.json` whose `loadWorkflow` rejected left the canvas unchanged and said nothing, unlike the parse failure beside it.
Dragging a curve point kept losing it. The circle was keyed on its own moving x, so every drag frame remounted it — and the browser releases pointer capture with the element it was set on. Tracking then depended on the SVG's own handler and stopped the moment the pointer left the box. Keyed by index instead; the count only changes on add and remove, where a remount is what should happen. `moveCurvePoint` could also invert its own clamp. Import only drops points closer than 1e-6, so a workflow can carry neighbours closer together than two `MIN_POINT_GAP`s — the valid range is then empty, and clamping into an empty range lands the point *below* its predecessor. `sampleCurve` and `curvePath` both read the list as ascending, so the drawn curve stopped matching the stored value. Such a point is x-locked now, and still free to move vertically. Reachability, in the same pass: - The import dialog's drop zone was a `div` with only `onClick`, so there was no way to open the file picker without a pointer. It is a button now, and its contents are phrasing-only to match. - Parameter labels named no control, so a screen reader announced those fields unnamed and clicking a label focused nothing. - A settings probe kept its green tick across changes to `remoteApiKey` and both API-v2 toggles — the credential and the routes it had validated. - A file the browser could not read at all left the drop silent: the rejection was unhandled and the dialog never opened. - Re-reading a workflow wrote its error after the dialog had closed, where the success path beside it already re-checked. Plus the notes the review was right about: `parseAppModeInputId` said the widget name inside a `WidgetId` outranked element `[1]`, and the code does the opposite for good reason, so the comment now says so; `bindingKey` is exported rather than defined twice; blueprint imports report only the node types the chosen blueprint uses; the smoke recorder writes its corpus as one piece so a failed catalog fetch cannot leave fresh blueprints beside a stale catalog; and a smoke run that gives up on a poll cancels the job it is abandoning, which is billed.
|
Worked through the review — fixes are on #141 ( Replied inline on the three major comments not taken. Here are the reasons for the minor and nitpick ones I left alone.
Everything else in the review is fixed. Thanks — the input-presence mismatch and the video/audio store mix-up were both real bugs with no test to catch them, and both have one now. |
fix(comfy): CodeRabbit review pass, plus saved nodes and live previews
… to a bound Second CodeRabbit pass, this one over the whole branch — including the saved-node and preview work the first review never saw, because CodeRabbit only auto-reviews PRs targeting the default branch. Budgets, again, and the same shape as last time: something documented a limit it did not enforce. - `/api/object_info` used 30s x 5 attempts on both engines. It is cached and shared, so its budget has to fit the *tightest* caller — `/api/comfy/status` at 60s — and 150s meant the platform killed the route before the fetch gave up. Now 20s x 2, which loses nothing: a catalog that does not answer in twenty seconds did not answer in a hundred and fifty either. Comfy Cloud's, measured hanging, went past ninety on all five tries. - `createEngineFetch` checked its deadline *before* sleeping, so a configured backoff could cross it and still start another full-length attempt. The backoff is clipped to the remaining budget, and each attempt is capped at what is left. - The smoke script's cancel had no timeout, so a stalled poll route swallowed the very error the run was reporting. And three things a node reads without asking whether they are there: - The saved-node shape check accepted an entry whose `app` had only `outputs` as an array, while the connection menu calls `app.inputs.some(...)` on every entry as it renders — so an entry from an older build threw inside a render, which is the case the check exists to prevent. - The preview route drained the engine as fast as it emitted. `enqueue` never blocks, so a browser reading slower than the render produces — a backgrounded tab — piled ~100KB frames in server memory on a route that stays open for minutes. It is pull-driven now. - That route also let a non-JSON body become a 500 and a non-string `jobId` reach the SDK. Two the eye would have caught eventually: - `border-transparent` and `border-blue-500` were both emitted for a node that is selected *and* running with settings open, and which won came down to the order Tailwind wrote them — half the time, the doubled outline that started all this. Running wins, explicitly. - A settings probe still in flight wrote its result after the user had changed the configuration, putting a green tick against an endpoint it never tested. Clearing `result` did not cover it; the call had already captured the old settings. Plus the small ones: `<p>` inside `<button>` in the saved-node rows, `seedFromSavedComfyNode` returning `Record<string, unknown>` and two call sites casting it back, a duplicated clear in `useComfyPreview`, a `setItem` spy that leaked on a failed assertion, a preview mock that dropped the arguments worth asserting, and the missing case for the frame's own `node_id`.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (4)
src/app/api/comfy/__tests__/run.route.test.ts (1)
100-106: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winProve the
uploadInputsmock is wired before asserting it was not called.No test asserts that
uploadInputsis ever called. If the mock path stops matching the import used bysrc/app/api/comfy/run/route.ts, this assertion still passes and the test proves nothing. Add one case that reaches the upload step, for example a valid run, and assert the call count there.♻️ Suggested addition
it("submits when the required input carries text", async () => { const body = await (await call({ prompt: "a dog" })).json(); expect(body).toMatchObject({ success: true, jobId: "job-1" }); expect(submit).toHaveBeenCalledOnce(); + // Also anchors the module mock: a path that no longer matches the route's + // import would leave the negative assertion below vacuously true. + expect(uploadInputs).toHaveBeenCalledOnce(); const graph = submit.mock.calls[0]![0] as Record<string, { inputs: Record<string, unknown> }>; expect(graph["3"]!.inputs.text).toBe("a dog"); });🤖 Prompt for 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. In `@src/app/api/comfy/__tests__/run.route.test.ts` around lines 100 - 106, Add a test in the run route suite that submits a valid run reaching the upload phase and asserts the uploadInputs mock is called, confirming the mock is wired to the route’s imported symbol. Keep the existing missing-input test and its not-called assertion unchanged.src/lib/comfy/library.ts (1)
95-106: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winReport the storage quota only for a quota failure.
writeaccesseswindow.localStoragewithout theisBrowser()guard thatlistSavedComfyNodesandsubscribeSavedComfyNodesuse. Any failure inside thetryblock, including a missingwindowduring server rendering or aJSON.stringifyfailure, becomes the message "There is no room left to save this node." That message directs the user to delete saved nodes, which cannot fix those causes. Guard for the browser and re-raise unexpected errors.🛠️ Proposed fix
function write(entries: SavedComfyNode[]): void { + if (!isBrowser()) return; + const payload = JSON.stringify(entries); try { - window.localStorage.setItem(COMFY_APPS_KEY, JSON.stringify(entries)); - } catch { + window.localStorage.setItem(COMFY_APPS_KEY, payload); + } catch (error) { // Almost always the storage quota. Nothing here can free space safely — // the other keys are the user's projects and costs — so it is reported. - throw new Error( - "There is no room left to save this node. Delete a saved node and try again." - ); + if (error instanceof DOMException) { + throw new Error( + "There is no room left to save this node. Delete a saved node and try again." + ); + } + throw error; } notify(); }🤖 Prompt for 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. In `@src/lib/comfy/library.ts` around lines 95 - 106, Update write to use the existing isBrowser() guard before accessing window.localStorage, and only convert genuine storage-quota failures into the user-facing “no room left” error. Re-raise unexpected errors, including server-rendering access failures and JSON.stringify errors, without replacing their original details; preserve notify() after a successful write.src/lib/comfy/reconfigure.ts (1)
11-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsolidate the duplicated binding id helper.
src/lib/comfy/inspect.tsandsrc/lib/comfy/reconfigure.tseach define the same${nodeId}:${inputKey}helper. Export one shared helper and use it from both modules so the binding id cannot drift between inspection, labels, and the import dialog’srolesmap.🤖 Prompt for 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. In `@src/lib/comfy/reconfigure.ts` around lines 11 - 18, Consolidate the duplicate binding-id helper by retaining the exported bindingKey function in reconfigure.ts as the single implementation, then update inspect.ts to import and use it instead of defining its own `${nodeId}:${inputKey}` helper. Preserve all existing callers and ensure inspection, labels, and the import dialog roles map use the shared result.src/lib/comfy/__tests__/previews.test.ts (1)
123-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a case for the frame-level
node_idfallback.
previewsresolves the node withenvelopeNodeId(payload) ?? String(data.node_id ?? ""). Every preview fixture here uses a metadata envelope, so the fallback branch is never exercised. A self-hosted engine sends the classic envelope, which carries no metadata, and only the frame field names the node. One case locks that path down.💚 Proposed test case
+ it("falls back to the frame's own node id when the envelope has no metadata", async () => { + const frames = await collect( + engineYielding([ + { event: "preview", data: { node_id: "9", data_base64: Buffer.from(classic(JPEG_BYTES)).toString("base64") } }, + ]) + ); + + expect(frames).toHaveLength(1); + expect(frames[0]!.nodeId).toBe("9"); + });🤖 Prompt for 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. In `@src/lib/comfy/__tests__/previews.test.ts` around lines 123 - 161, Add a test in the “previews” suite covering a preview whose envelope has no node metadata but whose frame data contains node_id. Assert that the yielded frame uses the frame-level node ID, while preserving the existing data URL expectations and other preview behavior.
🤖 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 `@src/app/api/comfy/preview/route.ts`:
- Around line 49-68: Track whether the ReadableStream has been cancelled in the
stream closure around the start and cancel methods, and have the finally block
call controller.close() only when cancellation has not already closed the
stream. Update the visible stream lifecycle logic without changing the existing
upstream cleanup through frames.return(undefined).
In `@src/components/__tests__/ComfyWorkflowImportModal.test.tsx`:
- Around line 124-134: Ensure the setItem spy created in the “reports a failed
save rather than losing it” test is restored even when assertions fail by adding
suite-level afterEach cleanup with vi.restoreAllMocks(), or by wrapping the test
actions and assertion in try/finally. Remove reliance on the trailing
setItem.mockRestore() alone.
In `@src/lib/comfy/library.ts`:
- Around line 48-57: Update the SavedComfyNode validation in the parse filter to
require candidate.app.outputs, candidate.app.params, and candidate.app.inputs
all be arrays, so incomplete entries are rejected before seedFromSavedComfyNode
or appToInputSchema consumes them.
In `@src/lib/comfy/settings.ts`:
- Around line 42-47: Update clampJobTimeoutMs to reject null and empty-string
inputs before converting with Number, so absent values return
COMFY_DEFAULT_JOB_TIMEOUT_MS instead of being clamped to the minimum. In
connectionFromRequest, pass the raw COMFY_HEADERS.jobTimeout header value to
clampJobTimeoutMs rather than pre-converting it.
In `@src/utils/__tests__/comfyOutputStorage.test.ts`:
- Around line 82-92: Update the generation stub in the save/load handlers to
store each generation’s output kind alongside its value, then have
/api/load-generation return only the matching video or audio field instead of
populating both. Adjust the written.generations assertions to remove any kind
prefix before comparing stored values, while preserving the existing save and
hydration test behavior.
---
Nitpick comments:
In `@src/app/api/comfy/__tests__/run.route.test.ts`:
- Around line 100-106: Add a test in the run route suite that submits a valid
run reaching the upload phase and asserts the uploadInputs mock is called,
confirming the mock is wired to the route’s imported symbol. Keep the existing
missing-input test and its not-called assertion unchanged.
In `@src/lib/comfy/__tests__/previews.test.ts`:
- Around line 123-161: Add a test in the “previews” suite covering a preview
whose envelope has no node metadata but whose frame data contains node_id.
Assert that the yielded frame uses the frame-level node ID, while preserving the
existing data URL expectations and other preview behavior.
In `@src/lib/comfy/library.ts`:
- Around line 95-106: Update write to use the existing isBrowser() guard before
accessing window.localStorage, and only convert genuine storage-quota failures
into the user-facing “no room left” error. Re-raise unexpected errors, including
server-rendering access failures and JSON.stringify errors, without replacing
their original details; preserve notify() after a successful write.
In `@src/lib/comfy/reconfigure.ts`:
- Around line 11-18: Consolidate the duplicate binding-id helper by retaining
the exported bindingKey function in reconfigure.ts as the single implementation,
then update inspect.ts to import and use it instead of defining its own
`${nodeId}:${inputKey}` helper. Preserve all existing callers and ensure
inspection, labels, and the import dialog roles map use the shared result.
🪄 Autofix
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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1e5a55c4-c2da-462e-99f3-5814a2b14d28
📒 Files selected for processing (58)
CLAUDE.mdREADME.mdscripts/comfy-smoke.mjssrc/app/api/comfy/__tests__/poll.route.test.tssrc/app/api/comfy/__tests__/run.route.test.tssrc/app/api/comfy/__tests__/shared.test.tssrc/app/api/comfy/blueprints/route.tssrc/app/api/comfy/inspect/route.tssrc/app/api/comfy/poll/route.tssrc/app/api/comfy/preview/route.tssrc/app/api/comfy/run/route.tssrc/app/api/comfy/shared.tssrc/app/api/comfy/status/route.tssrc/app/globals.csssrc/components/ConnectionDropMenu.tsxsrc/components/NodeSearchMenu.tsxsrc/components/WorkflowCanvas.tsxsrc/components/__tests__/BaseNode.test.tsxsrc/components/__tests__/ComfyAppPreview.test.tsxsrc/components/__tests__/ComfyNodePreview.test.tsxsrc/components/__tests__/ComfyWorkflowImportModal.test.tsxsrc/components/__tests__/ConnectionDropMenu.test.tsxsrc/components/__tests__/NodeSearchMenu.test.tsxsrc/components/modals/ComfyNodePreview.tsxsrc/components/modals/ComfyWorkflowImportModal.tsxsrc/components/nodes/BaseNode.tsxsrc/components/nodes/ComfyAppNode.tsxsrc/components/nodes/ComfyAppParameters.tsxsrc/components/nodes/ComfyCurveEditor.tsxsrc/components/settings/ComfySettingsTab.tsxsrc/hooks/useComfyPreview.tssrc/hooks/useSavedComfyNodes.tssrc/lib/comfy/__tests__/curve.test.tssrc/lib/comfy/__tests__/engineErrors.test.tssrc/lib/comfy/__tests__/inspect.test.tssrc/lib/comfy/__tests__/library.test.tssrc/lib/comfy/__tests__/previews.test.tssrc/lib/comfy/__tests__/run.test.tssrc/lib/comfy/curve.tssrc/lib/comfy/editor.tssrc/lib/comfy/inspect.tssrc/lib/comfy/library.tssrc/lib/comfy/nodeSchema.tssrc/lib/comfy/reconfigure.tssrc/lib/comfy/server/connection.tssrc/lib/comfy/server/engine.tssrc/lib/comfy/server/fetch.tssrc/lib/comfy/server/import.tssrc/lib/comfy/server/index.tssrc/lib/comfy/server/legacyEngine.tssrc/lib/comfy/server/run.tssrc/lib/comfy/server/sdkEngine.tssrc/lib/comfy/settings.tssrc/lib/comfy/types.tssrc/store/execution/comfyAppExecutor.tssrc/types/nodes.tssrc/utils/__tests__/comfyOutputStorage.test.tssrc/utils/mediaStorage.ts
🚧 Files skipped from review as they are similar to previous changes (29)
- src/components/tests/BaseNode.test.tsx
- README.md
- src/app/globals.css
- src/app/api/comfy/shared.ts
- src/lib/comfy/server/connection.ts
- src/app/api/comfy/status/route.ts
- src/utils/mediaStorage.ts
- src/lib/comfy/types.ts
- src/app/api/comfy/poll/route.ts
- src/app/api/comfy/inspect/route.ts
- src/lib/comfy/tests/curve.test.ts
- src/app/api/comfy/run/route.ts
- src/lib/comfy/server/import.ts
- src/components/settings/ComfySettingsTab.tsx
- src/components/nodes/ComfyAppParameters.tsx
- src/lib/comfy/server/legacyEngine.ts
- src/lib/comfy/server/index.ts
- src/lib/comfy/server/fetch.ts
- src/store/execution/comfyAppExecutor.ts
- src/types/nodes.ts
- src/lib/comfy/server/run.ts
- src/components/nodes/BaseNode.tsx
- src/lib/comfy/editor.ts
- src/lib/comfy/inspect.ts
- src/app/api/comfy/blueprints/route.ts
- src/components/nodes/ComfyAppNode.tsx
- src/lib/comfy/curve.ts
- scripts/comfy-smoke.mjs
- src/lib/comfy/server/engine.ts
`headers.get()` answers `null` for a header that is not there, and `Number(null)` is 0 — which is finite, so the clamp took it for a supplied value and brought it up to the *lower* bound. Any request without `X-Comfy-Job-Timeout` therefore ran on a one-minute job timeout instead of the thirty-minute default, and cancelling a render part-way is the expensive kind of wrong: the GPU time is spent and nothing comes back. The browser always sends the header, so this was reachable through the env-var path a headless deployment uses, and through any direct call to the routes. The clamp now separates "no value" from "the value 0", and an explicit zero still clamps up to the minimum. Also, from the same review: - The preview stream closed a controller the platform had already closed when the browser disconnected, throwing `Invalid state` out of a promise nothing awaits — one unhandled rejection per navigation away from a running job. - `write` in the saved-node library reported *any* failure inside its try as "there is no room left", including a missing `window` and a value that would not stringify, telling the user to delete saved nodes to fix something deleting cannot fix. And two tests that could not have caught the thing they were written for: the media stub answered every load with both `video` and `audio`, so hydration reading the wrong field would still have passed; and a "was not called" assertion had nothing establishing the mock is reached when the route does get that far.
Three follow-ons from the last two commits, one of them mine to answer for. The generation guard added to the settings probe skipped `setTesting(false)` once the configuration had moved on — which is every case it was written for. The old probe finished, declined to clear the flag, and nothing else could: the Test button stayed disabled for the rest of the session. Releasing it alongside the generation bump fixes that, and the guard still stops a stale probe stealing a newer one's spinner. Backoff was not abortable. A user pressing Stop during one waited it out — up to a couple of seconds of nothing happening — and the loop then opened another attempt with a signal that was already aborted. Both `resilientFetch` and `createEngineFetch` now wait on a sleep the caller can cut short; each already re-checks `aborted` immediately after, so the cancel lands as the error it should. And the preview stream's generator cleanup returns a promise, whose rejection on a disconnect would have been as unhandled as the double close fixed beside it.
fix(comfy): second and third CodeRabbit passes
The ComfyUI release: a ComfyUI workflow becomes a node on the canvas, running on Comfy Cloud, a local install, or one elsewhere on the network. The changelog entry notes that 1.7.0 and 1.8.0 shipped without one, and points at the releases page for those.
chore(release): v1.9.0
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@src/lib/comfy/server/fetch.ts`:
- Around line 229-237: Update engineFetch so the caller signal selection
preserves input Request signals: use init.signal when provided, otherwise use
input.signal before creating the combined timeout signal. Ensure fetch receives
a signal that still honors an input Request’s signal when engineFetch is called
without init.
🪄 Autofix
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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 7408ad2f-e963-4d36-9d34-3edf7453f3b8
📒 Files selected for processing (21)
scripts/comfy-smoke.mjssrc/app/api/comfy/__tests__/run.route.test.tssrc/app/api/comfy/preview/route.tssrc/components/WorkflowCanvas.tsxsrc/components/__tests__/ComfyAppPreview.test.tsxsrc/components/__tests__/ComfyWorkflowImportModal.test.tsxsrc/components/modals/ComfyWorkflowImportModal.tsxsrc/components/nodes/BaseNode.tsxsrc/components/settings/ComfySettingsTab.tsxsrc/hooks/useComfyPreview.tssrc/lib/comfy/__tests__/engineErrors.test.tssrc/lib/comfy/__tests__/library.test.tssrc/lib/comfy/__tests__/previews.test.tssrc/lib/comfy/__tests__/settings.test.tssrc/lib/comfy/library.tssrc/lib/comfy/server/connection.tssrc/lib/comfy/server/fetch.tssrc/lib/comfy/server/legacyEngine.tssrc/lib/comfy/server/sdkEngine.tssrc/lib/comfy/settings.tssrc/utils/__tests__/comfyOutputStorage.test.ts
🚧 Files skipped from review as they are similar to previous changes (12)
- src/components/nodes/BaseNode.tsx
- src/lib/comfy/tests/previews.test.ts
- src/components/tests/ComfyWorkflowImportModal.test.tsx
- src/utils/tests/comfyOutputStorage.test.ts
- src/lib/comfy/server/connection.ts
- src/hooks/useComfyPreview.ts
- src/lib/comfy/server/legacyEngine.ts
- src/lib/comfy/library.ts
- src/lib/comfy/tests/settings.test.ts
- src/lib/comfy/settings.ts
- src/components/WorkflowCanvas.tsx
- src/components/modals/ComfyWorkflowImportModal.tsx
| const caller = init?.signal ?? undefined; | ||
| // Never longer than the budget that is left. A first attempt gets the | ||
| // whole timeout; a later one gets whatever the earlier attempts and their | ||
| // backoff did not spend, so the last request cannot run past the deadline | ||
| // it was allowed to start before. | ||
| const attemptMs = Math.max(1, Math.min(timeoutMs, deadline - Date.now())); | ||
| const signal = caller | ||
| ? AbortSignal.any([caller, AbortSignal.timeout(attemptMs)]) | ||
| : AbortSignal.timeout(attemptMs); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C 4 'const caller = init\?\.signal|fetch\(input, \{ \.\.\.init, signal \}\)' \
src/lib/comfy/server/fetch.ts
rg -n -C 4 'createEngineFetch|AbortController|new Request' \
src/lib/comfy/__tests__/engineErrors.test.tsRepository: shrimbly/node-banana
Length of output: 6726
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the relevant function signature/body.
sed -n '180,260p' src/lib/comfy/server/fetch.ts
# Inspect nearby tests to see whether Request.signal is covered at all.
sed -n '300,390p' src/lib/comfy/__tests__/engineErrors.test.ts
# Check imports and type references around createEngineFetch.
sed -n '1,80p' src/lib/comfy/server/fetch.tsRepository: shrimbly/node-banana
Length of output: 10466
🌐 Web query:
MDN fetch Request constructor signal RequestInit signal overrides Request.signal
💡 Result:
When using the Request constructor, there is no conflict between a property named "Request.signal" and the "signal" property within the RequestInit options object, because "Request.signal" is not an input parameter for the constructor [1][2][3]. The Request constructor accepts two arguments: the resource (URL or Request object) and an optional RequestInit object [1][2]. The RequestInit object is where you define the signal property to associate an AbortSignal with the request [2]. Once a Request object is created, its "signal" property (Request.signal) becomes a read-only attribute that returns the AbortSignal associated with that specific request [4][3]. If you are concerned about precedence when combining requests and options: 1. When constructing a new Request from an existing Request object and a RequestInit object, the options provided in the RequestInit object override the corresponding options from the original Request [1]. 2. When passing a Request object to a fetch call along with a separate RequestInit object, the options in the RequestInit object passed to fetch take precedence over the options already set on the Request object [2]. In summary, Request.signal is a property of the resulting object, not a parameter of the constructor, so it cannot "override" an input signal; rather, the signal provided in the RequestInit object determines the signal that the resulting Request object will hold [4][2].
Citations:
- 1: https://developer.mozilla.org/en-US/docs/Web/API/Request/Request
- 2: https://developer.mozilla.org/en-US/docs/Web/API/RequestInit
- 3: https://developer.mozilla.org/en-US/docs/Web/API/Request
- 4: https://developer.mozilla.org/en-US/docs/Web/API/Request/signal
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search all usages of createEngineFetch and engineFetch to confirm whether callers can pass Request objects.
rg -n -C 3 'createEngineFetch\(|\belongingEngineFetch\b|engineFetch\(' src/lib compliant --exclude-dir=node_modules || true
# Search all tests that call createEngineFetch/test it with Request instances or check request.signal.
rg -n -C 4 'engineFetch\(|new Request|Request\(|\bsignal\b' src/lib/comfy/__tests__/engineErrors.test.ts src/lib/comfy/__tests__ src/lib/comfy/server || trueRepository: shrimbly/node-banana
Length of output: 50376
🌐 Web query:
Node.js fetch RequestInit signal overrides Request signal fetch passed Request plus options signal semantics
💡 Result:
In Node.js, as in the web Fetch specification, when you pass a Request object to the fetch function along with an additional RequestInit options object, the options provided in the second argument take precedence [1][2][3]. Regarding the AbortSignal, the fetch function does not simply override one signal with another; rather, the Fetch specification requires the resulting Request's signal to follow the signal provided in the fetch call's RequestInit [4][5]. Key Semantics: 1. Precedence: When fetch(request, options) is called, any properties defined in the options object (including signal) override the corresponding properties already present on the Request object [1][2][3]. 2. Signal Behavior: If a signal is provided in the RequestInit object passed to fetch, it effectively becomes the controlling signal for that fetch operation [2]. If the original Request object also had a signal, the implementation must ensure the final request respects the signal provided in the call [4][5]. 3. Implementation Note: In Node.js (via the undici-based fetch implementation), creating a new Request using new Request(request, init) or calling fetch(request, init) is the standard way to ensure these overrides are applied correctly [1][6]. Failing to merge these correctly—such as by ignoring the second argument—is a common source of bugs where headers, signals, or other configuration options are silently dropped [1][6]. If you are encountering issues with signal propagation, ensure you are not accidentally discarding the RequestInit object when wrapping or proxying fetch calls [1][6]. Furthermore, be aware that in newer Node.js versions, the runtime strictly enforces that the signal passed to RequestInit must be a native AbortSignal instance; non-native or polyfilled signals may require normalization to avoid type errors [7].
Citations:
- 1: fix(instrumentation-fetch): preserve init overrides when input is a Request object open-telemetry/opentelemetry-js#6525
- 2: https://developer.mozilla.org/en-US/docs/Web/API/RequestInit
- 3: https://developer.mozilla.org/en-US/docs/Web/API/Request/Request
- 4: Request object's AbortSignal does not abort with the expected reason nodejs/node#43874
- 5: https://fetch.spec.whatwg.org/
- 6: open-telemetry/opentelemetry-js@1b8a6a2
- 7: fix: normalise non-native AbortSignal for Node 24+ compatibility Vermonster/fhir-kit-client#205
Preserve the signal on an input Request.
engineFetch only uses init.signal, but line 239 passes a new signal to fetch. Because fetch(Request, RequestInit) lets RequestInit.signal take precedence, an engineFetch(request) call with no init loses request.signal. Use input.signal when init.signal is absent.
🤖 Prompt for 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.
In `@src/lib/comfy/server/fetch.ts` around lines 229 - 237, Update engineFetch so
the caller signal selection preserves input Request signals: use init.signal
when provided, otherwise use input.signal before creating the combined timeout
signal. Ensure fetch receives a signal that still honors an input Request’s
signal when engineFetch is called without init.
Promotes
developtomasteras v1.9.0. Merge #143 first — it carries the version bump and changelog.ComfyUI workflows as nodes
Drop a ComfyUI workflow onto the canvas and it becomes a node, wired to the rest of a Node Banana pipeline.
CURVEwidget, and a dialog that lets you revisit an attached node's inputs, settings and outputs without starting over.Also in this release
Two annotation-modal shortcut fixes (Delete no longer removes the node behind the modal; undo works in either case), one continuous outline around a running node including its settings panel, and a scrollbar restyle.
Review
Four CodeRabbit rounds, 33 findings triaged down to a clean pass. The four that mattered:
0Findings not taken were replied to inline with reasons; CodeRabbit withdrew several after.
Known, and deliberately not fixed here
appInputHandlesassigns positional handle ids (text-0), so reordering same-type inputs on an existing node can silently rebind an edge. The id is persisted inedge.targetHandlein every saved workflow, so changing it needs a load-time migration — its own change, not a release-eve edit.Verification
2603 tests across 121 files,
npm run buildclean.