Audit triggered while debugging the v0.2.0 release surfaced drift in the maintainer docs vs current code: dead curated_defaults.json references (file replaced by starter_preset.json in PR #72), the v1_0_1 fork example (removed in v0.1.5), and a "tooling we don't have yet" entry that's now covered by tools/preflight_release.py. Adds a new conventions.md for cross-cutting rules — currently the Mermaid-for-diagrams convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
7.8 KiB
Markdown
178 lines
7.8 KiB
Markdown
# Node versioning — how to change a node without breaking saved workflows
|
|
|
|
This is a hard-won list. ComfyUI's workflow format silently breaks in
|
|
several non-obvious ways when you change a node's inputs, outputs, or
|
|
class name. The rules below are what to follow whenever you touch
|
|
`INPUT_TYPES`, `RETURN_TYPES`, or `NODE_CLASS_MAPPINGS`.
|
|
|
|
## How ComfyUI loads a saved workflow
|
|
|
|
When the frontend opens a workflow JSON, it reconstructs each node by:
|
|
|
|
1. **Looking up the node by its registered ID** (the key in
|
|
`NODE_CLASS_MAPPINGS`). If the lookup fails, the node renders as a
|
|
red "missing" stub and the workflow won't run.
|
|
2. **Mapping connections by input/output name.** Most cases.
|
|
3. **Mapping widget values by *position* in the widget array** — this is
|
|
the source of most breakage.
|
|
4. **Filling missing inputs with their default**, if they're declared in
|
|
`INPUT_TYPES["optional"]`. Missing *required* inputs → validation
|
|
error.
|
|
|
|
## What breaks vs what doesn't
|
|
|
|
| Change | Breaks saved workflows? | Why |
|
|
|---|---|---|
|
|
| Add a new input to `INPUT_TYPES["required"]` | **YES** | Old workflow has no value for it → validation fails |
|
|
| Add a new input to `INPUT_TYPES["optional"]` *at the end* | **safe** | Missing inputs use defaults |
|
|
| Add a new widget *between* existing widgets | **YES** | Widget array indices shift → values land in wrong widgets |
|
|
| Add a new widget *at the end* | mostly safe | Old workflows just don't have that index → uses default |
|
|
| Rename an existing input | **YES** | Saved value can't map by name |
|
|
| Rename the class / node ID | **YES** | Lookup fails entirely |
|
|
| Reorder existing combo options | **YES** for indexed serialization | Some workflows store combo as index, not string |
|
|
| Add new combo option *at the end* | **safe** | Existing string values still match |
|
|
| Remove an input | soft break | Saved value silently dropped, may lose semantic meaning |
|
|
| Change `RETURN_TYPES` order or types | **YES** | Downstream connections targeting `output[1]` now hit wrong type |
|
|
| Change a default value | "stealth break" | Old workflows have the old default baked in; new workflows get the new default — silent behavior divergence |
|
|
|
|
> "*Field gets messed up*" almost always = **widget position shift**.
|
|
> ComfyUI serializes widgets as `widgets_values: [val0, val1, val2]`.
|
|
> Insert a new widget at index 1 → every subsequent value shifts one
|
|
> slot → wrong values land in wrong widgets.
|
|
|
|
## The five rules
|
|
|
|
1. **Never rename a registered node ID.** When you really must, register
|
|
both old and new IDs pointing to the same class for a few releases
|
|
(deprecation alias), then remove the old one with a CHANGELOG note.
|
|
2. **Append new inputs/widgets to the END.** Never insert in the middle.
|
|
Order in `INPUT_TYPES` dict matters because Python preserves insertion
|
|
order and the frontend uses it.
|
|
3. **New inputs go in `INPUT_TYPES["optional"]` with a default**, not in
|
|
`required`. Old workflows that don't have the field will use the
|
|
default; new workflows can wire it up.
|
|
4. **Never reorder combo options.** Append new options. If you need to
|
|
remove one, leave it as a hidden alias or deprecation stub for a
|
|
release first.
|
|
5. **Treat `RETURN_TYPES` as immutable.** If you need to change outputs,
|
|
make a new node ID — `MyNodeV2`. The Comfy ecosystem (KJ Nodes, Was
|
|
Node Suite, Crystools) does this constantly.
|
|
|
|
## When you can't avoid a breaking change
|
|
|
|
| Pattern | Example |
|
|
|---|---|
|
|
| **Suffix-version a new node** | `EasyAIPipeline` → `EasyAIPipelineV2`. Both register, both work. |
|
|
| **Mark old as deprecated in display name** | Old: `Easy AI Pipeline (deprecated, use V2)` — display name change is fine, ID is what matters |
|
|
| **Document migration in CHANGELOG** | "Old `EasyAIPipeline` retained; new pipeline work should use `EasyAIPipelineV2` which adds X/Y/Z" |
|
|
| **Keep deprecated nodes for ≥2 minor versions** before removing |
|
|
|
|
This is exactly the pattern used for `EasyResize` → `EasyResize_Koolook`
|
|
in v0.1.6 (see [`../../CHANGELOG.md`](../../CHANGELOG.md)). Both IDs
|
|
register, both load, the bare-name one's display says "deprecated", and
|
|
the bare-name one will be removed in a future major release once the
|
|
deprecation has had time to propagate.
|
|
|
|
## Concrete pattern — the rename + alias
|
|
|
|
```python
|
|
# In whichever k_*.py file holds the class:
|
|
|
|
class MyNode:
|
|
""" ... existing implementation, unchanged ... """
|
|
# ...
|
|
|
|
# Register BOTH IDs against the same class:
|
|
NODE_CLASS_MAPPINGS = {
|
|
"MyNode_Koolook": MyNode, # new canonical ID
|
|
"MyNode": MyNode, # legacy alias (deprecated)
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"MyNode_Koolook": "My Node (Koolook)",
|
|
"MyNode": "My Node (deprecated, use 'My Node (Koolook)')",
|
|
}
|
|
```
|
|
|
|
That's it. Saved workflows that reference `MyNode` keep loading. New
|
|
workflows pick `MyNode_Koolook` from the search. The bare-name display
|
|
nudges users to switch.
|
|
|
|
## Concrete pattern — adding a new optional input
|
|
|
|
Suppose `EasyAIPipeline` currently has these inputs:
|
|
|
|
```python
|
|
"required": {
|
|
"shot_duration": ("INT", {...}),
|
|
"seed": ("INT", {...}),
|
|
"base_path": ("STRING", {...}),
|
|
"shot_name": ("STRING", {...}),
|
|
"ai_method": ("STRING", {...}),
|
|
"version": ("INT", {...}),
|
|
},
|
|
```
|
|
|
|
You want to add a new `pass_name` field. **Do not** add it to `required`
|
|
between existing fields — that breaks every saved workflow. Instead:
|
|
|
|
```python
|
|
"required": { ... unchanged ... },
|
|
"optional": {
|
|
"pass_name": ("STRING", {"default": "", "tooltip": "..."}),
|
|
},
|
|
```
|
|
|
|
- Old workflows that don't have `pass_name` use the default `""`.
|
|
- New workflows that do have it use the value the user sets.
|
|
- Output behavior must remain backward-compatible when `pass_name=""`
|
|
(i.e. equivalent to the pre-change behavior). If `pass_name=""` would
|
|
change the output filename, you need a `MyNodeV2` instead.
|
|
|
|
## Concrete pattern — output change
|
|
|
|
Suppose `EasyAIPipeline` currently returns `(output_path,)` and you want
|
|
to add a `(output_path, output_dir)` tuple. That's a `RETURN_TYPES`
|
|
change → downstream connections targeting `output[1]` would hit the new
|
|
output unexpectedly (or just exist where they didn't before).
|
|
|
|
Don't modify the existing class. Make a new one:
|
|
|
|
```python
|
|
class EasyAIPipeline_V2(EasyAIPipeline):
|
|
"""Same as EasyAIPipeline, plus emits the resolved output directory."""
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("output_path", "output_dir")
|
|
# ... reuse or override the execute method as needed ...
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"EasyAIPipeline": EasyAIPipeline, # unchanged
|
|
"EasyAIPipeline_V2": EasyAIPipeline_V2, # new
|
|
}
|
|
```
|
|
|
|
## Anti-patterns to refuse
|
|
|
|
- ❌ "I'll just add the field in the middle for clarity, the dict order
|
|
doesn't matter." It does. Append.
|
|
- ❌ "I'll rename the input to be clearer." Don't. Add a new optional
|
|
input with the clearer name; deprecate the old one in display only.
|
|
- ❌ "It's a small change, no need to bump the node version." If the
|
|
input/output surface changes in a way that could break a saved
|
|
workflow → new node ID. Period.
|
|
- ❌ "Nobody's using the old node yet." You don't actually know. Default
|
|
to alias-then-deprecate; cost is one extra dict entry.
|
|
|
|
## Tooling — what we have, what's still missing
|
|
|
|
- ✅ **Workflow-fixture smoke test** — `tools/preflight_release.py`
|
|
check `workflows` walks `tests/workflows/*.json`, extracts node IDs
|
|
by both heuristic and AST-extracted set, and reports any reference
|
|
that no longer resolves. The `preflight-release` skill in
|
|
`.claude/skills/` runs all four checks before a release.
|
|
- ⏳ **`node-api-change` agent skill** — a check that would run before
|
|
any edit to `INPUT_TYPES` / `RETURN_TYPES` / class names, diff
|
|
against the rules above, and refuse unsafe changes. Same shape as
|
|
`license-pre-check`. Not built yet; scoped for the next time someone
|
|
changes a node's input/output surface.
|