From 32b77ae13b4beca302d1eb02054b6cdbaf3e607e Mon Sep 17 00:00:00 2001 From: DrJKL Date: Sat, 9 May 2026 11:22:26 -0700 Subject: [PATCH] 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 --- .../parameters/SectionWidgets.vue | 3 +- .../parameters/TabSubgraphInputs.vue | 8 +- .../parameters/WidgetActions.vue | 7 +- .../graph/useGraphNodeManager.test.ts | 1 - src/composables/graph/useGraphNodeManager.ts | 25 ++--- .../legacyProxyWidgetNormalization.test.ts | 9 +- .../legacyProxyWidgetNormalization.ts | 97 ++++++------------- .../migration/classifyProxyEntry.test.ts | 42 ++++---- .../subgraph/migration/classifyProxyEntry.ts | 15 ++- .../proxyWidgetMigrationFlush.test.ts | 10 +- .../migration/proxyWidgetMigrationFlush.ts | 4 +- .../proxyWidgetMigrationPlanTypes.ts | 4 +- .../migration/proxyWidgetMigrationPlanner.ts | 4 +- .../migration/repairValueWidget.test.ts | 9 +- .../subgraph/migration/repairValueWidget.ts | 10 +- .../graph/subgraph/promotedWidgetTypes.ts | 21 ++-- src/core/graph/subgraph/promotedWidgetView.ts | 20 +--- src/core/graph/subgraph/promotionUtils.ts | 24 ++--- .../resolveConcretePromotedWidget.test.ts | 26 +---- .../subgraph/resolveConcretePromotedWidget.ts | 37 ++----- .../subgraph/resolvePromotedWidgetSource.ts | 3 +- .../subgraph/resolveSubgraphInputTarget.ts | 23 +---- .../litegraph/src/subgraph/SubgraphNode.ts | 60 +++--------- .../subgraph/SubgraphWidgetPromotion.test.ts | 24 +++-- 24 files changed, 158 insertions(+), 328 deletions(-) diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue index eb6c905e57..33c25174f4 100644 --- a/src/components/rightSidePanel/parameters/SectionWidgets.vue +++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue @@ -83,8 +83,7 @@ function isWidgetShownOnParents( return isWidgetPromotedOnSubgraphNode(parent, { sourceNodeId: interiorNodeId, - sourceWidgetName: widget.sourceWidgetName, - disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId + sourceWidgetName: widget.sourceWidgetName }) } return isWidgetPromotedOnSubgraphNode(parent, { diff --git a/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue b/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue index 6abfb4d369..630c137a4d 100644 --- a/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue +++ b/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue @@ -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) }) ) }) diff --git a/src/components/rightSidePanel/parameters/WidgetActions.vue b/src/components/rightSidePanel/parameters/WidgetActions.vue index f27654d7df..72b45b4d95 100644 --- a/src/components/rightSidePanel/parameters/WidgetActions.vue +++ b/src/components/rightSidePanel/parameters/WidgetActions.vue @@ -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(() => diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 35b8a1d181..3cc8e1f9e8 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -206,7 +206,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { '10', 'prompt', 'value', - undefined, 'value' ) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index efcf059518..e1f7516db5 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -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 diff --git a/src/core/graph/subgraph/legacyProxyWidgetNormalization.test.ts b/src/core/graph/subgraph/legacyProxyWidgetNormalization.test.ts index 589670fd91..1e22bb3d73 100644 --- a/src/core/graph/subgraph/legacyProxyWidgetNormalization.test.ts +++ b/src/core/graph/subgraph/legacyProxyWidgetNormalization.test.ts @@ -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' }) }) }) diff --git a/src/core/graph/subgraph/legacyProxyWidgetNormalization.ts b/src/core/graph/subgraph/legacyProxyWidgetNormalization.ts index 0b56019dd3..e9c1f9f1d6 100644 --- a/src/core/graph/subgraph/legacyProxyWidgetNormalization.ts +++ b/src/core/graph/subgraph/legacyProxyWidgetNormalization.ts @@ -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 - 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 `": "` 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 }) diff --git a/src/core/graph/subgraph/migration/classifyProxyEntry.test.ts b/src/core/graph/subgraph/migration/classifyProxyEntry.test.ts index 5a7df49085..f3b3c4733e 100644 --- a/src/core/graph/subgraph/migration/classifyProxyEntry.test.ts +++ b/src/core/graph/subgraph/migration/classifyProxyEntry.test.ts @@ -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({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - disambiguatingSourceNodeId: 'first' - }) - const secondInput = host.addInput('second_seed', '*') - secondInput._widget = fromPartial({ - 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({ + 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' } }) }) diff --git a/src/core/graph/subgraph/migration/classifyProxyEntry.ts b/src/core/graph/subgraph/migration/classifyProxyEntry.ts index af472e1607..4d12a872e1 100644 --- a/src/core/graph/subgraph/migration/classifyProxyEntry.ts +++ b/src/core/graph/subgraph/migration/classifyProxyEntry.ts @@ -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 diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.test.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.test.ts index 554f189b3c..c6cca32e00 100644 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.test.ts +++ b/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.test.ts @@ -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) }) diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts index 8c60816cd1..368c1559e9 100644 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts +++ b/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts @@ -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 ? [ diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes.ts index 456b757b0a..a24b04c58d 100644 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes.ts +++ b/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes.ts @@ -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 diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.ts index 3a19c97ff0..435c5de36d 100644 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.ts +++ b/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.ts @@ -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, diff --git a/src/core/graph/subgraph/migration/repairValueWidget.test.ts b/src/core/graph/subgraph/migration/repairValueWidget.test.ts index 25090a6a60..512923e5bb 100644 --- a/src/core/graph/subgraph/migration/repairValueWidget.test.ts +++ b/src/core/graph/subgraph/migration/repairValueWidget.test.ts @@ -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 }) diff --git a/src/core/graph/subgraph/migration/repairValueWidget.ts b/src/core/graph/subgraph/migration/repairValueWidget.ts index 72aa5ed799..344cc23018 100644 --- a/src/core/graph/subgraph/migration/repairValueWidget.ts +++ b/src/core/graph/subgraph/migration/repairValueWidget.ts @@ -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' } diff --git a/src/core/graph/subgraph/promotedWidgetTypes.ts b/src/core/graph/subgraph/promotedWidgetTypes.ts index 69f28879d9..b68e177438 100644 --- a/src/core/graph/subgraph/promotedWidgetTypes.ts +++ b/src/core/graph/subgraph/promotedWidgetTypes.ts @@ -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( diff --git a/src/core/graph/subgraph/promotedWidgetView.ts b/src/core/graph/subgraph/promotedWidgetView.ts index 5b065985b7..9ddec2df21 100644 --- a/src/core/graph/subgraph/promotedWidgetView.ts +++ b/src/core/graph/subgraph/promotedWidgetView.ts @@ -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 ) } diff --git a/src/core/graph/subgraph/promotionUtils.ts b/src/core/graph/subgraph/promotionUtils.ts index bd4f81043f..0aac3878c4 100644 --- a/src/core/graph/subgraph/promotionUtils.ts +++ b/src/core/graph/subgraph/promotionUtils.ts @@ -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) { diff --git a/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts b/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts index f9cd77a661..ca934ecc0b 100644 --- a/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts +++ b/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts @@ -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', () => { diff --git a/src/core/graph/subgraph/resolveConcretePromotedWidget.ts b/src/core/graph/subgraph/resolveConcretePromotedWidget.ts index d59aa9b01d..d306fdf1f3 100644 --- a/src/core/graph/subgraph/resolveConcretePromotedWidget.ts +++ b/src/core/graph/subgraph/resolveConcretePromotedWidget.ts @@ -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() const hostUidByObject = new WeakMap() @@ -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) } diff --git a/src/core/graph/subgraph/resolvePromotedWidgetSource.ts b/src/core/graph/subgraph/resolvePromotedWidgetSource.ts index 3eaa565d45..2b9ff55e7e 100644 --- a/src/core/graph/subgraph/resolvePromotedWidgetSource.ts +++ b/src/core/graph/subgraph/resolvePromotedWidgetSource.ts @@ -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 diff --git a/src/core/graph/subgraph/resolveSubgraphInputTarget.ts b/src/core/graph/subgraph/resolveSubgraphInputTarget.ts index f5242c0302..7777dd042f 100644 --- a/src/core/graph/subgraph/resolveSubgraphInputTarget.ts +++ b/src/core/graph/subgraph/resolveSubgraphInputTarget.ts @@ -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 diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index a97dac2fd3..482577cc28 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -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 ) ) } diff --git a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts index 1f7a65e7a6..46a8a0f75e 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts @@ -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 + // ": : " 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', () => {