Exposes the pick_independent instance field (engine resolution landed in 33b3600): instance schema + snapshot round-trip, TS instance type + _shell INSTANCE_FIELDS_PER_KIND registry (so reset-overrides + modified-state + the TS/Py parity gate all include it) + preview cache-key, and the canvas PoolSection 'Allow repeats' toggle (shown only while multi-pick is active; off = unique). Instance-only (per-node, canvas runtime), additive + tolerant — no community catalog change.
The engine handlers already enforce _IDENT_RE on output_var / var_binding
/ fixed values[].name via validate_payload, wired into every
create/update/import through _validate_payload_for_type (returns 400 on
ValueError). Existing tests cover leading-digit / dunder / too-long /
empty cases; add the explicit comma scenario the user reported so the
exact "no settle-delimiter in a var name" contract is regression-proof
for all three module types. No production change — server gate already
existed; this characterizes + locks it.
Workflow drift/missing now runs through the shared classifyOne verdict: a same-id, DIFFERENT-kind library row surfaces as a distinct 'clash' state (violet badge) instead of a misleading drift/missing dot, and Refresh is suppressed + guarded in mergeRefresh so it can never clobber an unrelated item. Hybrid content-key: the workflow path compares payload_hash (drift semantics unchanged) while the import picker keeps fingerprint; classifyOne is content-key agnostic. /wp/api/modules/hashes now returns {type, payload_hash} per id; drift-store map + AppLayout fingerprint + setLibraryHash updated to the object shape.
Introduces a persisted prompt-template library: migration 012 creates the
templates table (name/description/category_id FK/tags/is_favorite/
template_string), TemplateRepository provides CRUD with partial-update
sentinel semantics, and wp_api/templates.py exposes list/get/create/update/
delete/favorite with FK violations mapped to 400. Bumps the migration-head
version assertions to 12.
- Cross-node drag MOVE-becomes-COPY race: ContextWidget.onDrop sync-claims
consumedBy before any await so the source widget's dragend reads the
set value. dragend filters by _uid (idempotent) instead of stale
srcIdx — fixes the secondary issue where the backup pendingHandoffs
queue + dragend double-removed an adjacent target wildcard.
- pendingHandoffs queue in drag-store: belt-and-suspenders source-side
cleanup that runs even when dragend skips (constraint rows inside the
bundle frame were the trigger).
- Conflict scanner constraint_orphan_target slot allocator: per-target
sorted local positions + remaining downstream count. Each constraint
claims smallest local slot > own index, else a downstream slot. Fixes
duplicated constraints with target downstream cross-node + correctly
flags when only one local target sits BEFORE the constraint.
- collectDownstreamWildcardUuids: per-instance occurrences (not Set-
deduped) so duplicated downstream targets each count as a separate
claimable slot.
- RichTextPreview + ConstraintInstanceModal: cross-Context refs fall
back to preview-resolver cache (sub_categories, option values,
options_by_id, hasNullOption) so the canvas modal renders the actual
wildcard name, sub-cat list, and chip suffix instead of "source"/
"target" placeholders + stale matrix keys.
- Preview-resolver schema extended with subCategories / optionValues /
optionsById / hasNullOption populated from embed-bundle response.
- Constraint save hydrate: wp_api modules + bundles endpoints fill
exception source_value / target_value from source_id / target_id by
looking up the wildcard's option list before validation. Heals
library rows that stored only ids.
- TestRunner constraint table uses RichTextPreview for column headers
+ row values so nested @{uuid} refs render as chips.
- Inverted null filter UI: SubcategoryFilterPicker "Exclude null"
checkbox + RefChip "!null" suffix render.
- Bundle size budget bumped 330 KB to 340 KB.
- Drop stale CHECKPOINT-*.md docs.
Add two new POST handlers in wp_api/import_export.py wrapping the
engine.importer.commit_import / undo_import functions from Task 13.
- /wp/api/import/commit: body {adds, replaces, renames}; 200 with
{ok, undo_entry_id, summary} on success; 400 on contract/DB errors.
- /wp/api/import/undo: body {undo_entry_id}; 200 with {ok: True} on
success; 404 on unknown entry; 400 on shape/other errors.
- Both guard request body via validate_body_size (5 MB cap).
- Engine returns undo_id; handler renames to undo_entry_id at the
HTTP boundary to keep the locked endpoint contract decoupled from
the engine field name.
- Legacy export_bundle + import_bundle + new export_build handlers
remain unchanged.
Wraps engine.exporter.build_export_payload behind an HTTP route so the
SPA can drive a 7-bucket export with picker-selected UUID sets. Body
shape uses 7 keys (bundle_uuids, wildcard_uuids, fixed_values_uuids,
combine_uuids, derivation_uuids, constraint_uuids, category_uuids);
each key is optional and defaults to []. Mis-typed UUIDs are silently
dropped by the exporter (cross-bucket isolation guarantee). Response
is the exporter payload returned directly, matching the legacy
/wp/api/export shape convention.
The A-side write check (previous commit) prevents building A → B → C from A's perspective, but B can be updated unilaterally. Without a matching check on B's update, B could gain bundle children of its own and silently demote every parent A's reference into a tier-3 structure on read — the resolver would happily attach the new bundle children, the SPA pre-flatten would see two levels of bundles below A, and the tier-2 ceiling would be effectively gone.
Lock the ceiling from both sides:
- _find_parents_referencing scans the library for any bundle whose children contain a bundle ref pointing at the row being updated.
- _validate_parents_compatible runs only when the patch carries a key AND that new list contains at least one bundle entry — rename / recolor / leaf-only edits skip it entirely.
- 409 Conflict (not 400) because the failure is a state-vs-write conflict, not malformed input. Error body cites the offending parents (up to 3, then '… N more') so the SPA can route the user to detach references first.
5 new tests cover the 409 path, the no-parents allow path, the leaf-only-edit allow path, the metadata-only allow path, and the multi-parent listing in the error message. Full wp_api suite green (174/174).
Bundles can now contain other bundles as children — but stored as id-only POINTERS, not deep snapshots. The reference model sidesteps the drift cascade that a snapshot model would introduce (parent goes stale every time the inner bundle is edited).
Storage shape for a bundle child:
{ 'id': <ref-id>, 'type': 'bundle', 'name'?: str, 'color'?: str }
Display fields (name, color) are cached so the parent renders a sensible placeholder if the referenced bundle is later deleted; the actual children belong to the referenced row.
Three pieces wired into POST + PUT /wp/api/bundles:
- _normalise_bundle_children strips every field except the reference pointers off bundle-typed children before insert, so a SPA round-tripping the GET-expanded shape doesn't accidentally persist a stale snapshot.
- _validate_bundle_refs rejects three cases against the live repo state: self-include (A → A, PUT only), missing reference (id not in repo), and tier-3 (referenced bundle already contains a bundle child). Tier-2 cap is the architectural ceiling.
- _expand_bundle_children runs on the GET response: every bundle-typed child gets the referenced bundle's CURRENT children attached inline under the same 'children' key, plus _resolved_from / _missing_ref markers. The SPA pre-flatten path (next commit) reads these inline children verbatim — same shape as a snapshot model would have produced, just always fresh.
Headline benefit: an inner-bundle edit shows up in every parent's GET without any propagation pass. No _propagate_bundle_to_bundles, no recursive walk through children blobs. The reference is the source of truth.
11 new tests cover storage stripping, GET expansion, live propagation through reads, tier-2 rejection, missing-ref handling, and mixed leaf/ref children.
Lands the 15-bug catalogue from the 2026-05-19 creative-QA pass.
API layer (wp_api/):
- New _validators.py centralises meta + identifier + body-size checks
so modules and bundles share the same shape rules
- SPA catch-all now returns JSON 404 for unknown /wp/api/* paths
instead of the SPA shell (debugging hostile previously)
- create/update/import_from_workflow validate meta (name/desc/tags/
category_id) before hitting the repo; tag list now rejects non-str
entries (was silently stored, breaking the SPA type contract)
- IntegrityError on dangling category_id surfaces as 400, not 500
- Optional If-Match header on PUT enables opt-in optimistic
concurrency — stale version → 409 instead of silent overwrite
- Bundle name collisions auto-suffixed with (copy)/(copy N) mirroring
the modules-side forkModule rule
- Bundle children deduped by id at create + update time
- Deleting a module flags meta.orphaned=true on every bundle child
whose snapshot referenced it so the SPA can render a stale badge
Engine layer (engine/modules/*_handler.py):
- wildcard: reject empty + __dunder var_binding, cap at 64 chars,
reject duplicate option ids
- combine: reject empty template, cap template at 8000 chars, cap
output_var at 64, reject __dunder output_var
- fixed_values: cap value name length, reject __dunder names, reject
duplicate names across rows (was silent last-write-wins)
- derivation: reject duplicate rule ids (was nondeterministic for
disabled_rule_ids / rule_order_override lookups)
Tests:
- New test_qa_hardening.py exercises every endpoint-level fix
- Extended test_payload_validation.py parametrised rejection matrix
- New test_spa_fallback.py case for /wp/api/* JSON 404
655 pytest pass, 1665 vitest pass, ruff clean.
Phase D — alias removal:
- Dropped PUT /wp/api/modules/{id}/payload + library_save.py module.
PushToLibraryModal now calls the canonical PUT /wp/api/modules/{id}
with flattened meta fields (name, description, tags) instead of the
nested meta sub-object the alias accepted. Lifetime ended at one
workflow review cycle; no external consumers exist.
- Updated test_save_to_library_unified.py and test_payload_validation
.py to point at the canonical route. Deleted test_library_save_
endpoint.py.
Bug 3b — modal silent on description/tags:
- Workflow rows only carry meta.name + meta.library_name at insert
time, so opening PushToLibraryModal on an existing library-tracked
row left description/tags blank even when the library row had them.
- Added seedFromLibrary() that fetches /wp/api/modules/<id> on modal
open and fills in any blanks. Respects in-modal edits: only writes
to fields that are still empty when the response lands.
Bug 3c — wildcard binding override didn't surface as outer mod state:
- Combine already checked inst.variable_binding in isModified();
wildcard didn't. Brought the wildcard branch in line so renaming
the binding on a wildcard surfaces the same orange dot a combine
rename does. INSTANCE_FIELDS_PER_KIND already listed binding on
both kinds — this row brings the outer dot to match the modal's
mod gauge.
Bug 3a — reset-icon semantics after Update existing:
- The right-click push-to-library flow updated the library entry but
never refreshed the workflow row's meta.library_name; the per-field
reset-to-library button then offered to revert to the pre-save
name. Both right-click onPushSaved and the in-modal
onPushToLibrarySaved now stamp library_name alongside name +
payload_hash on Update mode so reset reflects the new library
state.
Tests: 1665 SPA + 59 Python passing. Ruff + lint clean.
Bundle size: 306499 / 307200 (701 bytes headroom).
Live QA pending: needs ComfyUI restart to pick up the route removal +
Python validator paths.
Lands the broader save-to-library rethink across the server + extension
surfaces. Closes Bugs 1-5 from the 2026-05-19 live QA report; Bug 6
(payload validation) was already plugged by the previous commit.
API contract (Phase B):
- PUT /wp/api/modules/{id} now accepts {payload, name, description,
tags, propagate_to_bundles} and persists all of them in one
transaction. Returns bundles_updated[] so the caller can surface
"this save touched N bundles" feedback.
- PUT /wp/api/modules/{id}/payload kept as a backwards-compat alias
that mirrors the same body shape (payload + meta + propagate). The
older route silently dropped meta; now it routes through the same
helpers as the canonical PUT.
- Bundle propagation: when a module write happens, every bundle whose
children[].id matches the updated module gets its frozen child
snapshot rewritten in lockstep (payload + meta) and its
payload_hash recomputed. Atomic via shared db_session.
- GET /wp/api/bundles?contains_module=<id> filters the list to bundles
that reference that module — drives the modal's "this save affects
N bundles" preview.
- 7 new pytest cases in test_save_to_library_unified.py covering meta
persistence on both PUT routes, opt-in/opt-out propagation, and the
bundles filter.
SPA (Phase C):
- New PushToLibraryModal.vue. Owns editable name/description/tags +
payload preview + bundles-affected list + propagate checkbox. Two
buttons: Save as new entry (always available — POST /modules) and
Update existing (greyed when payload_hash is empty). Replaces the
five per-kind onXxxSaveToLibraryClick handlers + their five copies
of the same PUT body that lived in ModuleEditModal.
- ContextWidget right-click menu entry becomes Push to library and
routes to the same modal. Predicate relaxed from
isMissingFromLibrary(m) to !!m.payload, so inline-created modules
(no payload_hash) can finally reach the library — they get the
fork-only path in the modal.
- The five instance modals canSaveToLibrary computeds drop the
isLibraryTracked + isModified gate. Button is visible whenever
there is a payload; the modal decides what to do with it. Tests
updated to match the new contract.
- 8 new vitest cases in PushToLibraryModal.test.ts covering field
seeding, button disabling, PUT/POST request shape, bundle list
rendering, and the propagate opt-out round-trip.
- Legacy saveToLibrary(m) function in ContextWidget is gone (no
callers since the menu predicate change). Its bypass POST to
import-from-workflow lost the meta-editing path the new modal
exposes, so deletion is net positive.
Size note: total extension bundle is now 306254 / 307200 bytes
gzipped — 946 bytes of headroom on the size gate. Future feature
work touching this surface needs to factor in budget.
Live QA pending: extension code needs ComfyUI restart to reload.
Bug 6 fix: wildcard + fixed_values handlers had no validate_payload
method, so the base no-op was silently accepting any shape on every
write boundary. Verified live: PUT /payload with {options: 'not-an-array'}
returned 200 and persisted the garbage; POST accepted {options: 42};
cross-type pollution (combine-shaped payload PUT to a wildcard) also
succeeded with 200.
Changes:
- engine/modules/wildcard_handler.py: validate_payload checks options
list shape + per-option value/weight types + non-negative weights +
optional sub_category strings + var_binding identifier + sub_categories
list-of-strings.
- engine/modules/fixed_values_handler.py: validate_payload checks values
list shape + per-value name/value types + identifier rule on non-empty
names.
- wp_api/modules.py: validates payload on POST /modules, PUT /modules/{id}
(when payload field is present), and POST /modules/import-from-workflow.
Shared _validate_payload_for_type helper.
- wp_api/bundles.py: validates every child snapshot's payload on POST
/bundles and PUT /bundles/{id} when children[] is in the body — closes
the side door that let bundle writes bypass per-module validation.
- tests/wp_api/test_payload_validation.py: 26 cases covering happy paths
+ malformed payloads per kind, cross-type pollution, import-from-
workflow, and bundle-children side door.
- tests/wp_api/test_bundles_crud.py: fixture combine child gained the
required output_var field (it was implicitly invalid before).
Plus docs/superpowers/specs/2026-05-19-save-to-library-rethink-design.md:
the broader four-phase plan that addresses Bugs 1-5 (meta drop, bundle
propagation, fork discoverability, modal button visibility, inline
modules can't save). Phase A (this commit) plugs the validation hole.
Phases B-D will land separately after design review.
Live QA reproduction order remains in the design doc.
Pipelines never paid off as a separate module kind, so peel out:
- Engine: drop pipeline_handler, remove "pipeline" from valid module
types in repositories + schema parity.
- Manager: delete Pipelines/PipelineEditor views, PipelineSteps +
PipelineStepPicker components and their tests. Router, sidebar,
topbar, dashboard, TestRunner, ImportExport, resolver and
bundleSelection repointed off the removed routes/types.
- wp_api: drop pipeline branch from modules.py + import_export.py
(and its test).
In its place, add a unified library view:
- src/manager/views/AllItems.vue — modules + bundles in one filter
panel with kind multi-select (OR across the 6 kinds). Reachable
via the Dashboard "View all" link.
- ModuleListView grew the slots/props AllItems needs; BundleAddChild
+ Dashboard + ImportExport updated alongside.
Misc polish along the way: kind-icons / theme tokens / display-prefs
touch-ups, ContextWidget + DebugViewer + AssemblerHelper small fixes,
MockModuleCard + ModulePickerModal tweaks, _shared widget cleanup.
Two CRITICAL/IMPORTANT findings from final review:
1. DisabledExceptionsSection + prune.ts read only Tier 2 `source_value`/
`target_value` while engine accepts both Tier 2 and legacy
`source`/`target` (constraint_handler.py:215). UI on a legacy-
shaped payload would emit encodeKey([undefined,undefined]) and
silently drop saved disable selections + falsely flag valid keys
as stale on refresh. Mirror the engine fallback chain in TS.
2. _fresh_instance() defaulted variable_binding to '' and internal to
False. The frontend modified-state predicate is value != null, so
any non-None sentinel falsely lights up the orange dot, forces the
modal to default to Instance tab, and tags the section as modified
on a pristine snapshot. Engine readers already coerce None at the
read site (wildcard_handler.py:216 falsy-or fallback, pipeline.py:160
bool() coercion), so we keep a single contract: None everywhere =
no override.
Also: prune.ts now uses encodeKey() helper instead of raw
JSON.stringify for consistency with the cross-language contract.
adds wp_api/library_save.py with a single route handler that validates the
incoming payload via the module type's registered handler, persists it through
ModuleRepository.update, and returns {"ok": true, "new_hash": "<sha256>"}.
covered by 5 new async tests in tests/wp_api/test_library_save_endpoint.py.
also fixes pre-existing ruff E501 violations in test_derivation_handler.py
and test_instance_schema_parity.py that were blocking the pre-commit hook.
Two bugs surfaced by manual QA after picker unblock:
1. Every freshly-picked non-wildcard module rendered a red "missing"
dot on the card. Root cause: GET /wp/api/modules/hashes hard-coded
`type="wildcard"` (5.5.5 spec §4.2 reasoned "catalog only contains
wildcards"), so when the in-graph WP_Context now embeds a combine /
derivation / constraint / fixed_values, the resulting payload_hash
never appeared in the libraryHashes map. The frontend predicate
`isMissingFromLibrary` then flagged every non-wildcard as missing.
Fix: drop the `type=` filter so the endpoint returns hashes for
every kind. Cost: ~50% larger payload for typical libraries; still
sub-10 KB for the largest libraries we've seen.
2. Picker enabled pipeline kind, contradicting the user-stated scope
that pipelines stay deferred until the modal grows a pipeline
preview. Fix: introduce a `HIDDEN_KINDS` set + `visibleModules`
computed that filters pipeline rows out of the picker entirely.
Pipeline tab dropped from the kind-tab strip. The defensive guard
in `isPickable` reads from the same set so any future programmatic
pick path inherits the gate.
Tests:
- Updated picker test to reflect 5 visible rows (pipeline filtered)
+ asserts the "Pipelines" tab and the literal pipeline row text are
absent from the rendered picker.
- Renamed + updated the backend hashes test to lock the new
every-kind contract; combine row now expected in the response map.
485 vitest + pytest green.
Dashboard count cards displayed "1" for every kind regardless of how
many rows the user actually had in the library. Root cause: the list
endpoint returned `total = len(items)` AFTER pagination, so any client
that passed `limit=1` for cheap polling (Dashboard does exactly this)
got `total=1` no matter what.
Adds `ModuleRepository.count(...)` that runs the same WHERE-clause
filters as `list()` but issues `SELECT COUNT(*)` with no LIMIT/OFFSET,
and reroutes both calls through a shared `_build_filter_clause` helper
so filter shape can never drift between the two paths.
Pinned by `test_list_total_ignores_limit` — seeds 3 wildcards, queries
`?type=wildcard&limit=1`, asserts items has 1 row but total is 3.
Wire walk_transitive_refs into _build_ctx_with_catalog so the SPA Test
Runner populates __wp_catalog__ from the live DB before resolution. Also
fixes _resolve_ref to support SnapshotEntry payload.options shape alongside
the legacy flat options shape, and updates db_session to accept an injected
connection from request.app["wp_db"] for test isolation.