refactor(subgraph): drop disambiguatingSourceNodeId from canonical promoted widget shape

Treat each SubgraphNode as opaque: canonical PromotedWidgetView and
PromotedWidgetSource link parent levels to immediate child SubgraphNode
inputs rather than carrying deepest-leaf widget identity. The
disambiguator is retained only as legacy lookup metadata in migration
code via a dedicated LegacyProxyEntrySource shape.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0df9-abbb-73df-88d9-379128728306
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
DrJKL
2026-05-09 11:22:26 -07:00
parent d1c93c9f4f
commit 32b77ae13b
24 changed files with 158 additions and 328 deletions

View File

@@ -83,8 +83,7 @@ function isWidgetShownOnParents(
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
sourceWidgetName: widget.sourceWidgetName
})
}
return isWidgetPromotedOnSubgraphNode(parent, {

View File

@@ -63,8 +63,7 @@ function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName &&
a.disambiguatingSourceNodeId === b.disambiguatingSourceNodeId
a.sourceWidgetName === b.sourceWidgetName
)
}
@@ -124,10 +123,7 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
({ node: interiorNode, widget }) =>
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: getWidgetName(widget)
})
)
})

View File

@@ -46,12 +46,7 @@ const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(
node,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>

View File

@@ -206,7 +206,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
'10',
'prompt',
'value',
undefined,
'value'
)

View File

@@ -227,18 +227,15 @@ function safeWidgetMapper(
}
}
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
sourceWidgetName: resolvedTarget.widgetName
}
}
@@ -256,10 +253,9 @@ function safeWidgetMapper(
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource = {
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
@@ -306,8 +302,7 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
@@ -320,11 +315,7 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined

View File

@@ -105,7 +105,11 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('returns original entry when prefix cannot be resolved', () => {
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
// ADR 0009: each SubgraphNode is opaque, so legacy nested
// disambiguator-based lookup no longer reaches deep widgets. The
// prefix is preserved as `disambiguatingSourceNodeId` lookup metadata
// for migration tooling.
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
@@ -116,7 +120,8 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: '999: nonexistent_widget'
sourceWidgetName: 'nonexistent_widget',
disambiguatingSourceNodeId: '999'
})
})
})

View File

@@ -1,89 +1,54 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
function canResolve(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
widgetName: string
): boolean {
return (
resolveConcretePromotedWidget(
hostNode,
sourceNodeId,
widgetName,
disambiguator
).status === 'resolved'
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).status ===
'resolved'
)
}
function tryResolveCandidate(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): PromotedWidgetPatch | undefined {
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
return undefined
return {
sourceWidgetName: widgetName,
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
}
interface StrippedPrefix {
sourceWidgetName: string
/** Deepest legacy `n: ` prefix removed from the original widget name. */
deepestPrefixId?: string
}
function resolveLegacyPrefixedEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetPatch | undefined {
function stripLegacyPrefixes(sourceWidgetName: string): StrippedPrefix {
let remaining = sourceWidgetName
let deepestPrefixId: string | undefined
while (true) {
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
if (!match) return undefined
const [, legacySourceNodeId, unprefixed] = match
remaining = unprefixed
const disambiguators = [
legacySourceNodeId,
...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []),
undefined
]
for (const disambiguator of disambiguators) {
const resolved = tryResolveCandidate(
hostNode,
sourceNodeId,
remaining,
disambiguator
)
if (resolved) return resolved
}
if (!match) return { sourceWidgetName: remaining, deepestPrefixId }
deepestPrefixId = match[1]
remaining = match[2]
}
}
/**
* Normalize a legacy `proxyWidgets` entry.
*
* Under ADR 0009 each `SubgraphNode` is opaque, so the canonical state never
* resolves through deep nested identities. This helper still recognizes the
* legacy `"<id>: <name>"` prefix encoding and surfaces the deepest prefix as
* `disambiguatingSourceNodeId` so migration tooling can preserve it as
* lookup metadata. The bare entry is returned unchanged when it already
* resolves at the immediate level.
*/
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
if (
canResolve(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
) {
): LegacyProxyEntrySource {
if (canResolve(hostNode, sourceNodeId, sourceWidgetName)) {
return {
sourceNodeId,
sourceWidgetName,
@@ -91,19 +56,13 @@ export function normalizeLegacyProxyWidgetEntry(
}
}
const patch = resolveLegacyPrefixedEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
const stripped = stripLegacyPrefixes(sourceWidgetName)
const patchDisambiguatingSourceNodeId =
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
sourceWidgetName: stripped.sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})

View File

@@ -13,7 +13,7 @@ import {
import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry'
import type {
PromotedWidgetSource,
LegacyProxyEntrySource,
PromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetTypes'
@@ -41,7 +41,7 @@ function makeSource(
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
): LegacyProxyEntrySource {
return {
sourceNodeId,
sourceWidgetName,
@@ -79,28 +79,26 @@ describe(classifyProxyEntry, () => {
})
})
it('matches already-linked inputs by disambiguatingSourceNodeId when provided', () => {
it('quarantines as ambiguous when canonical inputs share the same identity, even if the legacy entry has a disambiguator', () => {
// ADR 0009: canonical PromotedWidgetView no longer carries a
// `disambiguatingSourceNodeId`, so two inputs sharing the same
// (sourceNodeId, sourceWidgetName) cannot be told apart by the
// classifier. The legacy entry's disambiguator is metadata only and
// does not break the tie.
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const firstInput = host.addInput('first_seed', '*')
firstInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'first'
})
const secondInput = host.addInput('second_seed', '*')
secondInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'second'
})
for (const inputName of ['first_seed', 'second_seed']) {
const input = host.addInput(inputName, '*')
input._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
}
const normalized = makeSource(String(innerNode.id), 'seed', 'second')
const result = classifyProxyEntry({
@@ -109,9 +107,9 @@ describe(classifyProxyEntry, () => {
cohort: [normalized]
})
expect(result.plan).toEqual({
kind: 'alreadyLinked',
subgraphInputName: 'second_seed'
expect(result).toEqual({
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
})
})

View File

@@ -3,7 +3,7 @@ import type {
PrimitiveBypassTargetRef,
ProxyEntryClassification
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getPromotableWidgets,
@@ -19,9 +19,9 @@ interface ClassificationResult {
interface ClassifyProxyEntryArgs {
hostNode: SubgraphNode
normalized: PromotedWidgetSource
normalized: LegacyProxyEntrySource
/** All proxy entries this planner pass is considering — needed to detect primitive fan-out. */
cohort: readonly PromotedWidgetSource[]
cohort: readonly LegacyProxyEntrySource[]
}
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
@@ -33,7 +33,7 @@ type LinkedInputMatch =
function findLinkedSubgraphInputMatch(
hostNode: SubgraphNode,
normalized: PromotedWidgetSource
normalized: LegacyProxyEntrySource
): LinkedInputMatch {
const matches: string[] = []
for (const input of hostNode.inputs) {
@@ -41,10 +41,7 @@ function findLinkedSubgraphInputMatch(
if (!widget || !isPromotedWidgetView(widget)) continue
if (
widget.sourceNodeId === normalized.sourceNodeId &&
widget.sourceWidgetName === normalized.sourceWidgetName &&
(!normalized.disambiguatingSourceNodeId ||
widget.disambiguatingSourceNodeId ===
normalized.disambiguatingSourceNodeId)
widget.sourceWidgetName === normalized.sourceWidgetName
) {
matches.push(input.name)
}
@@ -74,7 +71,7 @@ function collectPrimitiveTargets(
}
function cohortReferencesPrimitive(
cohort: readonly PromotedWidgetSource[],
cohort: readonly LegacyProxyEntrySource[],
primitiveNodeId: string
): boolean {
let count = 0

View File

@@ -16,7 +16,6 @@ import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxy
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -69,7 +68,7 @@ describe(flushProxyWidgetMigration, () => {
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
createNodeLocatorId(host.rootGraph.id, host.id)
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
@@ -152,10 +151,7 @@ describe(flushProxyWidgetMigration, () => {
expect(first.previewMigrated).toBe(1)
const exposuresAfterFirst = usePreviewExposureStore()
.getExposures(
host.rootGraph.id,
createNodeLocatorId(host.rootGraph.id, host.id)
)
.getExposures(host.rootGraph.id, String(host.id))
.map((e) => ({ ...e }))
const second = flushProxyWidgetMigration({ hostNode: host })
@@ -169,7 +165,7 @@ describe(flushProxyWidgetMigration, () => {
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
createNodeLocatorId(host.rootGraph.id, host.id)
String(host.id)
)
).toEqual(exposuresAfterFirst)
})

View File

@@ -8,7 +8,7 @@ import {
} from '@/core/graph/subgraph/migration/quarantineEntry'
import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout'
import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import type { ProxyWidgetErrorQuarantineEntry } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
@@ -37,7 +37,7 @@ const EMPTY_RESULT: FlushProxyWidgetMigrationResult = {
}
function toLegacyTuple(
source: PromotedWidgetSource
source: LegacyProxyEntrySource
): SerializedProxyWidgetTuple {
return source.disambiguatingSourceNodeId
? [

View File

@@ -1,4 +1,4 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
@@ -56,7 +56,7 @@ type MigrationPlanKind = MigrationPlan['kind']
* applying mutations.
*/
export interface PendingMigrationEntry {
normalized: PromotedWidgetSource
normalized: LegacyProxyEntrySource
legacyOrderIndex: number
hostValue: HostValue
classification: ProxyEntryClassification

View File

@@ -6,7 +6,7 @@ import type {
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
@@ -37,7 +37,7 @@ export function planProxyWidgetMigration(
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
if (tuples.length === 0) return { entries: [] }
const normalized: PromotedWidgetSource[] = tuples.map(
const normalized: LegacyProxyEntrySource[] = tuples.map(
([sourceNodeId, sourceWidgetName, disambiguator]) =>
normalizeLegacyProxyWidgetEntry(
hostNode,

View File

@@ -117,7 +117,12 @@ describe(repairValueWidget, () => {
expect(inputSlot._widget?.value).toBe(7)
})
it('applies host value to the linked input with the matching disambiguator', () => {
it('routes by subgraphInputName, ignoring legacy disambiguator metadata', () => {
// ADR 0009: canonical PromotedWidgetView no longer carries a
// `disambiguatingSourceNodeId`. Repair routes the host value to the
// input named by `subgraphInputName`; any disambiguator carried on the
// legacy entry is metadata only and does not affect the canonical
// match.
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
@@ -129,7 +134,6 @@ describe(repairValueWidget, () => {
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'first',
value: 1
})
const secondInput = host.addInput('second_seed', '*')
@@ -138,7 +142,6 @@ describe(repairValueWidget, () => {
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'second',
value: 2
})

View File

@@ -25,8 +25,7 @@ function findHostInputForLinkedSource(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
subgraphInputName: string | undefined,
disambiguatingSourceNodeId?: string
subgraphInputName: string | undefined
):
| { kind: 'none' }
| { kind: 'one'; input: INodeInputSlot }
@@ -40,9 +39,7 @@ function findHostInputForLinkedSource(
if (!widget || !isPromotedWidgetView(widget)) return false
return (
widget.sourceNodeId === sourceNodeId &&
widget.sourceWidgetName === sourceWidgetName &&
(!disambiguatingSourceNodeId ||
widget.disambiguatingSourceNodeId === disambiguatingSourceNodeId)
widget.sourceWidgetName === sourceWidgetName
)
})
if (matches.length === 0) return { kind: 'none' }
@@ -68,8 +65,7 @@ function repairAlreadyLinked(
entry.normalized.sourceWidgetName,
entry.plan.kind === 'alreadyLinked'
? entry.plan.subgraphInputName
: undefined,
entry.normalized.disambiguatingSourceNodeId
: undefined
)
if (hostInput.kind === 'ambiguous') {
return { ok: false, reason: 'ambiguousSubgraphInput' }

View File

@@ -10,20 +10,27 @@ export interface ResolvedPromotedWidget {
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
/**
* Legacy proxyWidget tuple shape carried through migration. The optional
* `disambiguatingSourceNodeId` is read from legacy `properties.proxyWidgets`
* payloads only — canonical runtime state never sets it. See ADR 0009.
*/
export interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
/**
* Identity of the immediate interior child whose widget (or input slot, for
* nested SubgraphNode children) this view exposes. Per ADR 0009 each
* SubgraphNode is opaque: the parent's promoted view references the
* immediate child only and does not flatten to deeper origins.
*/
readonly sourceNodeId: string
readonly sourceWidgetName: string
/**
* The original leaf-level source node ID, used to distinguish promoted
* widgets with the same name on the same intermediate node. Unlike
* `sourceNodeId` (the direct interior node), this traces to the deepest
* origin.
*/
readonly disambiguatingSourceNodeId?: string
}
export function isPromotedWidgetView(

View File

@@ -31,12 +31,7 @@ export { isPromotedWidgetView } from './promotedWidgetTypes'
export function getPromotedWidgetHostStateName(
widget: IPromotedWidgetView
): string {
return [
widget.name,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId ?? ''
].join(':')
return [widget.name, widget.sourceNodeId, widget.sourceWidgetName].join(':')
}
interface SubgraphSlotRef {
@@ -76,7 +71,6 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
@@ -84,7 +78,6 @@ export function createPromotedWidgetView(
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
)
}
@@ -120,7 +113,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
@@ -453,8 +445,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName,
this.disambiguatingSourceNodeId
this.sourceWidgetName
)
}
@@ -468,8 +459,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName,
this.disambiguatingSourceNodeId
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
@@ -509,9 +499,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}

View File

@@ -36,8 +36,7 @@ export function getWidgetName(w: IBaseWidget): string {
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
sourceWidgetName: string
): boolean {
return subgraphNode.inputs.some((input) => {
const w = input._widget
@@ -45,9 +44,7 @@ export function isLinkedPromotion(
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName &&
(!disambiguatingSourceNodeId ||
w.disambiguatingSourceNodeId === disambiguatingSourceNodeId)
w.sourceWidgetName === sourceWidgetName
)
})
}
@@ -155,14 +152,13 @@ function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName &&
left.disambiguatingSourceNodeId === right.disambiguatingSourceNodeId
left.sourceWidgetName === right.sourceWidgetName
)
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
return w.sourceNodeId
}
function isPreviewExposed(
@@ -201,8 +197,7 @@ export function isWidgetPromotedOnSubgraphNode(
isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName,
source.disambiguatingSourceNodeId
source.sourceWidgetName
) || isPreviewExposed(subgraphNode, source)
)
}
@@ -213,10 +208,7 @@ function toPromotionSource(
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: getWidgetName(widget)
}
}
@@ -346,9 +338,7 @@ export function demoteWidget(
linkedWidget &&
isPromotedWidgetView(linkedWidget) &&
linkedWidget.sourceNodeId === source.sourceNodeId &&
linkedWidget.sourceWidgetName === source.sourceWidgetName &&
linkedWidget.disambiguatingSourceNodeId ===
source.disambiguatingSourceNodeId
linkedWidget.sourceWidgetName === source.sourceWidgetName
)
})
if (linkedInput) {

View File

@@ -30,7 +30,6 @@ type PromotedWidgetStub = Pick<
> & {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
node?: SubgraphNode
}
@@ -52,8 +51,7 @@ function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode,
disambiguatingSourceNodeId?: string
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
@@ -63,7 +61,6 @@ function createPromotedWidget(
value: undefined,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId,
node
}
return promotedWidget as IBaseWidget
@@ -97,27 +94,6 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeUndefined()
})
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = [
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
]
const resolved = resolvePromotedWidgetAtHost(
host,
String(sourceNode.id),
'text',
'2'
)
expect(resolved).toBeDefined()
expect(
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})
describe('resolveConcretePromotedWidget', () => {

View File

@@ -20,8 +20,7 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
@@ -29,7 +28,6 @@ function traversePromotedWidgetChain(
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
@@ -39,7 +37,7 @@ function traversePromotedWidgetChain(
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
@@ -52,8 +50,7 @@ function traversePromotedWidgetChain(
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
currentWidgetName
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -73,7 +70,6 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
@@ -81,34 +77,20 @@ function traversePromotedWidgetChain(
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
widgetName: string
): IBaseWidget | undefined {
if (!widgets) return undefined
if (sourceNodeId) {
return widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
sourceNodeId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
)
}
return widgets.find((entry) => entry.name === widgetName)
return widgets?.find((entry) => entry.name === widgetName)
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
const widget = findWidgetByIdentity(node.widgets, widgetName)
if (!widget) return undefined
return { node, widget }
@@ -117,11 +99,10 @@ export function resolvePromotedWidgetAtHost(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}

View File

@@ -14,8 +14,7 @@ export function resolvePromotedWidgetSource(
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved

View File

@@ -1,12 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
sourceNodeId?: string
}
export function resolveSubgraphInputTarget(
@@ -17,29 +15,18 @@ export function resolveSubgraphInputTarget(
node,
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (inputNode.isSubgraphNode()) {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (isPromotedWidgetView(targetWidget)) {
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.sourceWidgetName,
sourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
// ADR 0009: each SubgraphNode is opaque. The promoted target is the
// child SubgraphNode's input slot, not a deeper leaf widget.
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name
}
}
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.name

View File

@@ -147,15 +147,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!targetWidget) continue
if (inputNode.isSubgraphNode()) {
if (isPromotedWidgetView(targetWidget)) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.sourceWidgetName,
disambiguatingSourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
// ADR 0009: each SubgraphNode is opaque. The promoted source on the
// parent host always references the immediate child's input slot, not
// the deeper leaf widget identity that the child internally exposes.
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
@@ -227,8 +221,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
entry.inputName
)
if (seenEntryKeys.has(entryKey)) return false
@@ -287,7 +280,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId,
entry.slotName
)
)
@@ -318,28 +310,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
({ inputKey, inputName, slotName, sourceNodeId, sourceWidgetName }) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: this._makePromotionViewKey(
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
inputName
)
})
)
@@ -354,8 +336,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
entry.inputName
),
entry.inputName
])
@@ -366,18 +347,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
inputKey: string,
sourceNodeId: string,
sourceWidgetName: string,
inputName = '',
disambiguatingSourceNodeId?: string
inputName = ''
): string {
return disambiguatingSourceNodeId
? JSON.stringify([
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
])
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
return JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
/** Manages lifecycle of all subgraph event listeners */
@@ -882,10 +854,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
const sourceNodeId =
interiorNode.isSubgraphNode() && isPromotedWidgetView(interiorWidget)
? interiorWidget.sourceNodeId
: undefined
const previousView = input._widget
@@ -911,15 +879,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
input.label ?? subgraphInput.name,
sourceNodeId,
subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name,
sourceNodeId
input.label ?? input.name
)
)
@@ -1094,8 +1060,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const resolved = resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName,
view.disambiguatingSourceNodeId
view.sourceWidgetName
)
if (resolved.status !== 'resolved') return
@@ -1124,8 +1089,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName,
view.disambiguatingSourceNodeId
inputName
)
)
}

View File

@@ -8,6 +8,7 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
@@ -356,7 +357,14 @@ describe('SubgraphWidgetPromotion', () => {
expect(widgetSourceIds).toContain(keptSamplerNodeId)
})
it('should normalize legacy prefixed proxyWidgets on configure', () => {
it('quarantines legacy prefixed proxyWidgets that target a deep leaf widget', () => {
// ADR 0009: each SubgraphNode is opaque. The legacy
// "<nestedId>: <leafId>: <leafWidgetName>" encoding referenced a deep
// leaf widget through nested chain traversal. Under the opaque model
// the migration cannot resolve that identity at the immediate level,
// so the entry is quarantined rather than reconstructed as a
// canonical promoted view. Users with this legacy state must
// re-promote through each subgraph level explicitly.
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
@@ -396,20 +404,16 @@ describe('SubgraphWidgetPromotion', () => {
}
hostNode.configure(serializedHostNode)
flushProxyWidgetMigration({ hostNode })
const promotedWidgets = hostNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
expect(promotedWidgets).toHaveLength(1)
expect(promotedWidgets[0].type).toBe('number')
expect(promotedWidgets[0].value).toBe(123)
expect(promotedWidgets[0].sourceWidgetName).toBe('noise_seed')
expect(promotedWidgets[0].disambiguatingSourceNodeId).toBe(
String(samplerNode.id)
)
// ADR 0009: configure() no longer rewrites properties.proxyWidgets.
// Normalization is observable on the synthetic widget surface above.
expect(promotedWidgets).toHaveLength(0)
expect(hostNode.properties.proxyWidgets).toBeUndefined()
const quarantine = hostNode.properties.proxyWidgetErrorQuarantine
expect(Array.isArray(quarantine) && quarantine.length).toBeGreaterThan(0)
})
it('should preserve promoted widget entries after cloning', () => {