The linkStore is now the single source for slot connectivity in both
directions. The NodeInputSlot.link field and all runtime write sites are
gone; a read-only prototype getter derives the id from the store and
fires warnDeprecated as extension migration telemetry (no setter, so
writes throw). Wire format unchanged: serialization derives
inputs[].link from the store.
The mirror-carried association machinery is reworked rather than
papered over: fixLinkInputSlots consumes the serialized graph data
(where node.configure reorders inputs in place), dynamicWidgets' group
rebuilds carry slot-to-link association in a WeakMap refreshed from the
store, reorderSubgraphInputs captures outer links by pre-reorder index,
and link deduplication selects survivors from the store registration -
repairInputLinks is deleted because the derived view cannot be
inconsistent.
Extension migration: read via node.isInputConnected(slot) /
node.getInputLink(slot); mutate via node.connect() /
node.disconnectInput().
The input query family (isInputConnected, getInputLink, getInputNode,
getInputData/DataType, getInputOrProperty), connect displacement,
disconnectInput, removeInput reindexing, bypass passes, canvas link
highlighting, subgraph boundary resolution, and ExecutableNodeDTO all
derive from the store via the slotLinks input helpers. Serialization
emits inputs[].link from the store; configure fires its callbacks from
the serialized argument. NodeInputSlot.isConnected and toJSON derive.
The slot-connectivity helpers move to module scope: private-field
methods break when nodes are wrapped in Vue reactive proxies.
Mirror still written; linkDeduplication keeps reading it (the store
cannot represent contested duplicate targets, which are its input).
App code reads input connectivity through node.isInputConnected /
node.getInputLink or the new slotLinks input helpers (inputHasLink,
inputLinkId, inputLink) instead of the input.link mirror. Serialized
operators (litegraphUtil compression, migrateReroute, linkFixer) and the
mirror-carried association shuffles (dynamicWidgets autogrow,
fixLinkInputSlots, useNodeReplacement transplant writes) stay on the
mirror until it is deleted.
The linkStore is the single source for output-side connectivity. The
NodeOutputSlot.links field and all nine write sites are gone; a
read-only prototype getter derives the array from the store and fires
warnDeprecated as migration telemetry for extensions (no setter, so
writes throw). INodeOutputSlot keeps links as deprecated readonly so
'links in slot' discriminants still compile. Wire format is unchanged:
serialization derives outputs[].links from the store, and serialized
operators (linkFixer serialized branch, migrateReroute, unpackSubgraph
strip) are untouched; linkFixer now completes a missing input mirror
instead of deleting a store-registered link.
Also fixes createTestSubgraphNode minting duplicate node ids (the
fixture never reserved the id it assigned) and promoted-widget labels
not inheriting the source slot label - both previously masked by the
mirror or the id collision.
Extension migration: read via node.isOutputConnected(slot) /
node.getOutputNodes(slot); mutate via node.connect() /
node.disconnectOutput(). Design record: docs/architecture/
output-slot-connectivity.md Decision 6.
All ~30 internal output.links read sites now go through the slotLinks
helpers or slot.isConnected; the mirror is write-only pending deletion.
Serialization derives outputs[].links from the store, sorted ascending
by id (equal to push order for organically built graphs) and null when
empty. configure() fires its output callbacks from the serialized
argument instead of the mirror copy.
Deliberate bugfix: disconnectInput passed the mirror-array index as the
OUTPUT slot number to onConnectionsChange; it now passes
link_info.origin_slot.
Link queries now return fully-assigned links only: buildOutputIndex skips
floating topologies, matching the output.links mirror they replace. The
domain rule lives in isFloatingTopology, which also replaces the
hand-rolled sentinel checks in rerouteStore, dynamicWidgets, and
widgetValuePropagation. The slot-drag disconnect check keeps its floating
awareness via slotFloatingLinks, its pre-store behavior.
New node/slotLinks helpers (outputHasLinks/outputLinkIds/outputLinks)
back the remaining app readers: rerouteNode, widgetInputs, groupNode,
proxyWidgetMigration, and useNodeReplacement now read the store instead
of output.links. Behavior change, deliberate: PrimitiveNode's
onLastDisconnect fires on disconnect-all, where the stale mirror
previously suppressed it.
getOutputSlotLinks now returns ReadonlySet<LinkTopology> instead of link
ids: floating links draw ids from a separate counter, so a bare id cannot
be resolved safely through graph.links, and the migrated readers need
endpoints anyway. Migrated: minimap link extraction, slot-drag disconnect
check (one store query replaces a mirror read plus a slotFloatingLinks
scan), widget value propagation, and matchType link revalidation.
rerouteNode/widgetInputs/groupNode stay on the mirror deliberately - they
read inside connection callbacks whose behavior depends on mid-mutation
mirror staleness - as do the serialized-workflow repair tools. Design
record updated.
Adds a derived per-graph reverse index over link origins to linkStore,
exposing isOutputSlotConnected / getOutputSlotLinks as the output-side
mirror of the input queries. Target and origin slot keys are branded
separately so a key built for one index cannot be looked up in the other.
NodeSlots now passes connected to InputSlot and OutputSlot from the store;
the lg-slot--connected class reaches the dot via CSS, so SlotConnectionDot
needs no new prop. Design record in docs/architecture.
Default IBaseWidget TValue to the canonical WidgetValue so a widget value
may be null. The Vue update handler now assigns the same normalized value
to the store, live widget .value, and callback instead of coercing .value
to undefined. Coerce null/void to undefined only at the persisted
NodeProperty boundary.
Amp-Thread-ID: https://ampcode.com/threads/T-019f2a75-8d64-7605-980d-7aca51a5f19c
Co-authored-by: Amp <amp@ampcode.com>
The promoted-widget side panel only ever has one host SubgraphNode, so
collapse the parents: SubgraphNode[] prop to host?: SubgraphNode across
SectionWidgets/WidgetItem/WidgetActions and drop the [node] wrapper in
TabSubgraphInputs.
Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
isLinkedPromotion has no production callers outside promotionUtils.
Drop its dedicated direct-unit describe block and rewrite the demoteWidget
tests that used it as an assertion oracle to check observable interior
link state instead.
Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
- demoteRow now removes the linked input via the host input slot we
already have, instead of re-resolving the source and calling
demotePromotedInput
- un-export demotePromotedInput (only internal callers remain)
- drop dead parents param from renameWidget/promptRenameWidget
Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
isShownOnParents never changed observable behavior: for promoted rows
parents[0] === node (so favoriteNode was unchanged) and the toggle was
suppressed by isLinked; for interior rows it was always false. Its
Hide-toggle branch and parent-redirect were unreachable.
- drop isShownOnParents prop from WidgetItem and WidgetActions
- favorites key directly off node (was favoriteNode)
- WidgetActions toggle becomes a one-way Show Input action; remove the
dead handleHideInput + demoteWidget usage and the hideInput locale key
- remove SectionWidgets.isWidgetShownOnParents
Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
SectionWidgets and WidgetActions identified promoted subgraph-input widgets
by walking live links back to the interior source. Per ADR0009 a promoted
widget is host-scoped (widget.widgetId = graphId:hostNodeId:inputName, also
carried on the host input slot), so reuse that instead:
- isLinked: inputForWidget(node, widget)?.widgetId != null
- isWidgetShownOnParents: match parent inputs by widget.widgetId
- handleHideInput: drop unreachable source-resolution branch, keep demoteWidget
- remove now-unused widgetPromotedSource export
Favorites keep their current (nodeLocatorId, widgetName) key; added a
TODO(ADR0009) to move to a pure WidgetId key (deferred: needs a persisted
format migration).
Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
The system's watch tracked only the current graph id's bucket key; a
workflow load buckets nodes under a brand-new graph id the watcher had
never observed, so it never fired again and no badges were written
after any configure. registeredNodeIds now also reads the bucket map
size, so bucket creation and deletion wake readers regardless of which
id they last saw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LGraph.clear() and configure() reassign the root graph id, so the badge
system now resolves it on every recompute instead of capturing it at
start; a captured id stranded the system on a dead bucket after
Clear Workflow, dropping every badge on nodes added afterwards.
LGraph.add registers the node in the badge store only after
onNodeAdded: the store write wakes the system watcher and queues Vue's
flush, which must not overtake the deferred paste scan that surfaces
missing-model errors before selection-scoped tabs recalculate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrapper aggregation counts inner api nodes (the old unconditional
per-api-node badge basis) rather than resolved credits rows, so a
pending async price still yields 'Partner Nodes x N'; the label is
localized via nodeBadge.partnerNodesCount. useNodePricing now caches
labels per signature: a leaf's own read and its wrapper's
promoted-override read previously shared one cache slot and evicted
each other, re-scheduling evaluations forever once both ran in eager
system effects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Legacy drawBadges renders the store rows through a per-node memoized
LGraphBadge cache (core parts joined id/lifecycle/source and truncated,
icons resolved via a badge icon registry) followed by the raw
node.badges extension surface, whose thunks still evaluate per frame.
The Vue partition reads the same rows — trimming lifecycle brackets and
swapping a built-in node's source row for the Comfy logo chip — plus the
raw extension surface.
useNodeBadge now bootstraps the badge system per root graph and forwards
the subgraph structure events; dynamic-pricing recalculation wiring is
kept. The usePriceBadge closures, the VueNodeData.badges mirror, its
property trigger, and node.badgePosition (no ecosystem uses; single
writer/reader) are deleted. node.badges itself stays: three custom-node
packs actively push to it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SubgraphNode wrappers project their credits row from the inner nodes'
store rows: several priced inner nodes collapse to 'Partner Nodes x N',
a single one shows its price with the wrapper's promoted widget values
overriding the inner node's own. The aggregation tracks inner rows, the
registered-node set, and an exported structure revision for the events
stores cannot observe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renderers own presentation: legacy joins id/lifecycle/source in its own
order with brackets intact, Vue trims and reorders. Rows carry the raw
projected text plus a part discriminator instead of pre-trimmed text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A per-node effect flushing between unregisterNode and its scope being
stopped could re-create the bucket key and resurrect a removed node's
registration. Row writes (registerBadge, setBadgesOfKind) now require
an existing registration; registerBadge returns undefined when refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
add/remove/clear (and subgraph-definition GC) now maintain each node's
nodeBadgeStore registration in the root bucket, following the
linkStore/rerouteStore chokepoint convention. Tests that construct
graphs without an active Pinia gain the standard testing-pinia setup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
startBadgeSystem watches the registered-node set of a root graph bucket
and runs one effect scope per node: a pure computeBadges projection of
nodeDef, badge-mode settings, palette, and pricing sources, written to
nodeBadgeStore as core/credits rows (node-badge-store.md decisions 3-4).
nodeBadgeStore gains the registerNode/unregisterNode/registeredNodeIds
registration surface the system consumes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root-graph-scoped buckets keyed by NodeId holding plain BadgeData rows,
per docs/architecture/node-badge-store.md decisions 1-2. Reads are
kind-ordered (core, credits, extension); the badge system rewrites its
kinds wholesale via setBadgesOfKind, extension rows use identity-checked
register/delete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Badges go straight to plain BadgeData rows in a dedicated store,
written by a reactive badge system; core badges materialize as rows
too, killing the dual derivation and positional slice(1). Adds badge
vocabulary to the domain glossary and repoints the node-data-store
badges row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-ups: drop comments that paraphrased the call, type the
graphId params as UUID, unify the undefined guard, hoist the mocked
graph id, and thread the shared pinia through renderUnified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decisions 1 (single NodeState object, proxy-returning registration) is
agreed; field set, slot scope, and consumer contract are drafted pending
review. Records the shipped inputs[].link reader migration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The refresh-all loop existed to poke inputs[].link readers after a
neighbor's links were torn down. Those readers now query linkStore,
which unregisters link state reactively during removal, so the loop and
refreshNodeInputs go. Verified against the vue-nodes legacy widget e2e
spec that motivated the loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NodeSlots.linkedWidgetedInputs, usePartitionedBadges, and trackNodePrice
queried the input.link slot mirror, which only stayed reactive through
the node:slot-links:changed -> refreshNodeInputs reprojection. All three
now query linkStore.isInputSlotConnected by root graph id and slot
index, so the slot-links handler and the emitter-less
node:slot-errors:changed handler are deleted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Link geometry is still removed from the layout store in the current
node-removal flow; the target-architecture matrix now marks the
SlotConnection write as future work; the proto-ECS inventory narrows
LayoutStore's link source to geometry.
Review findings: validate the proposed parent chain in Reroute.parentId
(cycles were checked against the current chain, and a created cycle then
blocked repair); snapshot per-link reroute validity before connecting in
dropOnReroute; remove pruned reroutes from the paste result; truncate
cyclic or broken reroute chains during subgraph unpack instead of
throwing mid-mutation or stitching dangling ids; make RerouteChain.id
readonly; reuse the computed input index in SubgraphInputNode.
Adds the missing link topology store design record, amends ADR 0008
(linkStore/rerouteStore amendment, Link and Slot component notes, new
supporting-document rows), and corrects the migration plan, target
architecture, proto-ECS store inventory, lifecycle scenarios, entity
interactions, and entity problems docs to match the shipped stores:
target-input-slot keying, root-graph-scoped buckets, derived reroute
membership, deleted layout-store connectivity mirror, and deleted
slot._floatingLinks sets.
connectToSubgraphOutput wrote the floating link's origin instead of its
target — a subgraph output is the consuming end (target_id ===
SUBGRAPH_OUTPUT_ID everywhere else); the deleted slot mirror had masked
the bad endpoints from readers. removeInput/removeOutput now renumber
floating-link slots alongside real links, fixing stale attachment
indices that the mirror's slot-object identity used to paper over at
runtime (and that persisted stale into serialized floating links).
Replace the three interleaved walk-and-write loops (externalFirst
branches with per-hop error handling and outer-loop breaks) with
collect-then-stitch: walk the internal (migrated) and external chain
segments, order them by externalFirst, and write the chain pointers in
one generic pass. A truncated segment stops stitching at the break, as
before. Drops the order-dependent 'Missing Parent ID' false-positive
check from the migration loop.
move() already forwards position changes to the layout store, but
snapToGrid mutated posInternal silently, leaving the Vue hit-testing
spatial index stale after a snapped drag.
A floating link has exactly one assigned endpoint, so its slot
attachment is fully encoded in its own origin/target fields. Replace
the hand-maintained slot._floatingLinks Sets (25+ write sites across
addFloatingLink/removeFloatingLink, setFloatingLinkOrigin, and every
FloatingRenderLink connect method) with the slotFloatingLinks(network,
side, nodeId, slot) derivation, and delete the property.
moveInputLink/moveOutputLink gain the node param their dragNewFrom*
siblings already take. FloatingRenderLink.connectToInput disconnects
the target input before re-targeting the floating link, since the
target slot's floating-link cleanup now sees the re-targeted link
immediately. The dead floating spread in hasRelevantOutputLinks
(LLink objects never passed its typeof-number filter) is removed.
The layout store wrote parentId and linkIds into its reroute Y.Maps but
only ever read position back — a third, dead copy of chain data now that
the reroute store owns the chain and membership is derived. Remove the
fields from CreateRerouteOperation, the mutation API, and the
useVueNodeLifecycle seeding. Also fold Reroute.removeFloatingLink into
removeAllFloatingLinks, its only caller.
Deduplicate the connect-path floating-chain cleanup (LGraphNode,
SubgraphInput, SubgraphOutput) into anchorRerouteChain, add
unregisterAllRerouteChains mirroring the link-side helper, splice
createReroute's live/floating loops into one pass over the actual link
objects (also removing the ambiguous cross-id-space lookup for a link
segment), inline the single-caller Reroute.update into setReroute, and
drop test pinia hooks shadowed by the file-level one.
Share EMPTY_MEMBERSHIP from the store, stop creating buckets inside the
membership computed, align registerRerouteChain's graph param with
registerLinkTopology, unnest the dedup id allocator, tighten store test
assertions, and clarify the design record (updateEndpoint guard,
load-time orphan drop).
Reroute.linkIds/floatingLinkIds become derived accessors over the
reroute store's membership index; the ~10 manual mirror write sites
(connect paths, disconnect, floating add/remove, createReroute, subgraph
unpack, subgraph IO connect, LinkConnector) and validateLinks are
deleted. Configure and paste prune reroutes by derived totalLinks
instead of repairing stored sets, and serialization emits membership in
ascending link-id order.
LLink.disconnect unregisters the link before pruning so derived
counts exclude it. dropOnReroute resolves the drop target's source
output once before connecting moved links: re-anchoring the first
link's chain changes derived membership immediately, so a per-link
lookup could pick a not-yet-moved link's origin.
Reroute ids must be unique within a root graph now that the reroute
store keys every reroute in one root bucket, but subgraph definitions
from older frontends or external tools may number reroutes from
scratch. Add deduplicateSubgraphRerouteIds alongside the node-id dedup:
remaps colliding ids and patches reroute.parentId and link.parentId
references, advancing the shared state.lastRerouteId.
New rerouteStore holds RerouteChain {id, parentId?, floating?} in
root-scoped buckets keyed by RerouteId, registered by reference; the
Reroute class adopts the store proxy as _chain so parentId/floating
writes are tracked. Link membership is derived from the links' parentId
chains via a per-graph computed reverse index (getMembership), split
into live and floating sets by unassigned endpoints.
LGraph._addReroute/_removeReroute are the map chokepoints (register,
unregister, layout cleanup); all reroute map mutation sites route
through them. delete reroute.floating sites become undefined
assignments since floating is now an accessor.
registerLink/updateEndpoint now return the store-held reactive state and
registerLinkTopology assigns it back to link._state (the BaseWidget
pattern), so field writes such as link.parentId are tracked by Vue
instead of silently mutating the raw object.