diff --git a/src/core/graph/subgraph/migration/classifyProxyEntry.test.ts b/src/core/graph/subgraph/migration/classifyProxyEntry.test.ts deleted file mode 100644 index f3b3c4733e..0000000000 --- a/src/core/graph/subgraph/migration/classifyProxyEntry.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { setActivePinia } from 'pinia' -import { fromPartial } from '@total-typescript/shoehorn' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry' -import type { - LegacyProxyEntrySource, - PromotedWidgetView -} from '@/core/graph/subgraph/promotedWidgetTypes' - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({}) -})) -vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: () => ({ updatePreviews: () => ({}) }) -})) - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() -}) - -function buildHost(): SubgraphNode { - const subgraph = createTestSubgraph() - const hostNode = createTestSubgraphNode(subgraph) - const graph = hostNode.graph! - graph.add(hostNode) - return hostNode -} - -function makeSource( - sourceNodeId: string, - sourceWidgetName: string, - disambiguatingSourceNodeId?: string -): LegacyProxyEntrySource { - return { - sourceNodeId, - sourceWidgetName, - ...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId }) - } -} - -describe(classifyProxyEntry, () => { - describe('alreadyLinked branch', () => { - it('returns alreadyLinked when an input already represents the entry', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - const inputSlot = host.addInput('seed_link', '*') - inputSlot._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed' - }) - - const normalized = makeSource(String(innerNode.id), 'seed') - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result.classification).toBe('value') - expect(result.plan).toEqual({ - kind: 'alreadyLinked', - subgraphInputName: 'seed_link' - }) - }) - - 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) - - 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({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result).toEqual({ - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' } - }) - }) - - it('quarantines ambiguous already-linked inputs without a disambiguator', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - 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') - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result).toEqual({ - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' } - }) - }) - }) - - describe('quarantine branches', () => { - it('quarantines when source node is missing', () => { - const host = buildHost() - const normalized = makeSource('999', 'seed') - - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result).toEqual({ - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'missingSourceNode' } - }) - }) - - it('quarantines when source widget is missing on the source node', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - host.subgraph.add(innerNode) - - const normalized = makeSource(String(innerNode.id), 'nonexistent') - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result).toEqual({ - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'missingSourceWidget' } - }) - }) - - it('quarantines an unlinked primitive node with no fan-out', () => { - const host = buildHost() - const primitive = new LGraphNode('Primitive') - primitive.type = 'PrimitiveNode' - primitive.addOutput('value', '*') - host.subgraph.add(primitive) - - const normalized = makeSource(String(primitive.id), 'value') - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result).toEqual({ - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' } - }) - }) - }) - - describe('preview branch', () => { - it('classifies $$-prefixed names as preview exposure', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('text', '$$canvas-image-preview', '', () => {}) - host.subgraph.add(innerNode) - - const normalized = makeSource( - String(innerNode.id), - '$$canvas-image-preview' - ) - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result.classification).toBe('preview') - expect(result.plan).toEqual({ - kind: 'previewExposure', - sourcePreviewName: '$$canvas-image-preview' - }) - }) - - it('classifies type:preview serialize:false widgets as preview exposure', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - const widget = innerNode.addWidget('text', 'videopreview', '', () => {}) - widget.type = 'preview' - widget.serialize = false - host.subgraph.add(innerNode) - - const normalized = makeSource(String(innerNode.id), 'videopreview') - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result.classification).toBe('preview') - expect(result.plan).toEqual({ - kind: 'previewExposure', - sourcePreviewName: 'videopreview' - }) - }) - }) - - describe('value-widget branch', () => { - it('plans a createSubgraphInput when the widget exists and is not linked', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 42, () => {}) - host.subgraph.add(innerNode) - - const normalized = makeSource(String(innerNode.id), 'seed') - const result = classifyProxyEntry({ - hostNode: host, - normalized, - cohort: [normalized] - }) - - expect(result).toEqual({ - classification: 'value', - plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' } - }) - }) - }) - - describe('primitive fanout branch', () => { - it('emits primitiveBypass with target list when cohort points at the same primitive', () => { - const host = buildHost() - - const primitive = new LGraphNode('Primitive') - primitive.type = 'PrimitiveNode' - primitive.addOutput('value', 'INT') - host.subgraph.add(primitive) - - const targetA = new LGraphNode('TargetA') - targetA.addInput('value', 'INT') - targetA.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(targetA) - - const targetB = new LGraphNode('TargetB') - targetB.addInput('value', 'INT') - targetB.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(targetB) - - primitive.connect(0, targetA, 0) - primitive.connect(0, targetB, 0) - - const sourceA = makeSource(String(primitive.id), 'seed') - // Cohort has 2 entries pointing at the primitive (one per target). - const cohort = [sourceA, sourceA] - - const result = classifyProxyEntry({ - hostNode: host, - normalized: sourceA, - cohort - }) - - expect(result.classification).toBe('primitiveFanout') - expect(result.plan.kind).toBe('primitiveBypass') - if (result.plan.kind !== 'primitiveBypass') return - expect(result.plan.primitiveNodeId).toBe(primitive.id) - expect(result.plan.sourceWidgetName).toBe('seed') - expect(result.plan.targets).toHaveLength(2) - expect(result.plan.targets.map((t) => t.targetNodeId)).toEqual( - expect.arrayContaining([targetA.id, targetB.id]) - ) - }) - }) -}) diff --git a/src/core/graph/subgraph/migration/classifyProxyEntry.ts b/src/core/graph/subgraph/migration/classifyProxyEntry.ts deleted file mode 100644 index 4d12a872e1..0000000000 --- a/src/core/graph/subgraph/migration/classifyProxyEntry.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { - MigrationPlan, - PrimitiveBypassTargetRef, - ProxyEntryClassification -} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes' -import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' -import { - getPromotableWidgets, - isPreviewPseudoWidget -} from '@/core/graph/subgraph/promotionUtils' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' - -interface ClassificationResult { - classification: ProxyEntryClassification - plan: MigrationPlan -} - -interface ClassifyProxyEntryArgs { - hostNode: SubgraphNode - normalized: LegacyProxyEntrySource - /** All proxy entries this planner pass is considering — needed to detect primitive fan-out. */ - cohort: readonly LegacyProxyEntrySource[] -} - -const PRIMITIVE_NODE_TYPE = 'PrimitiveNode' - -type LinkedInputMatch = - | { kind: 'none' } - | { kind: 'one'; inputName: string } - | { kind: 'ambiguous' } - -function findLinkedSubgraphInputMatch( - hostNode: SubgraphNode, - normalized: LegacyProxyEntrySource -): LinkedInputMatch { - const matches: string[] = [] - for (const input of hostNode.inputs) { - const widget = input._widget - if (!widget || !isPromotedWidgetView(widget)) continue - if ( - widget.sourceNodeId === normalized.sourceNodeId && - widget.sourceWidgetName === normalized.sourceWidgetName - ) { - matches.push(input.name) - } - } - if (matches.length === 0) return { kind: 'none' } - if (matches.length === 1) return { kind: 'one', inputName: matches[0] } - return { kind: 'ambiguous' } -} - -function collectPrimitiveTargets( - hostNode: SubgraphNode, - primitiveNode: LGraphNode -): PrimitiveBypassTargetRef[] { - const subgraph = hostNode.subgraph - const output = primitiveNode.outputs?.[0] - const linkIds = output?.links ?? [] - const targets: PrimitiveBypassTargetRef[] = [] - for (const linkId of linkIds) { - const link = subgraph.links.get(linkId) - if (!link) continue - targets.push({ - targetNodeId: link.target_id, - targetSlot: link.target_slot - }) - } - return targets -} - -function cohortReferencesPrimitive( - cohort: readonly LegacyProxyEntrySource[], - primitiveNodeId: string -): boolean { - let count = 0 - for (const entry of cohort) { - if (entry.sourceNodeId === primitiveNodeId) { - count += 1 - if (count >= 2) return true - } - } - return false -} - -export function classifyProxyEntry( - args: ClassifyProxyEntryArgs -): ClassificationResult { - const { hostNode, normalized, cohort } = args - - const linkedInput = findLinkedSubgraphInputMatch(hostNode, normalized) - if (linkedInput.kind === 'one') { - return { - classification: 'value', - plan: { kind: 'alreadyLinked', subgraphInputName: linkedInput.inputName } - } - } - if (linkedInput.kind === 'ambiguous') { - return { - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' } - } - } - - const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId) - if (!sourceNode) { - return { - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'missingSourceNode' } - } - } - - if (sourceNode.type === PRIMITIVE_NODE_TYPE) { - const targets = collectPrimitiveTargets(hostNode, sourceNode) - const cohortDuplicated = cohortReferencesPrimitive( - cohort, - normalized.sourceNodeId - ) - if (targets.length >= 1 || cohortDuplicated) { - return { - classification: 'primitiveFanout', - plan: { - kind: 'primitiveBypass', - primitiveNodeId: sourceNode.id, - sourceWidgetName: normalized.sourceWidgetName, - targets - } - } - } - return { - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' } - } - } - - const promotableWidgets = getPromotableWidgets(sourceNode) - const sourceWidget = promotableWidgets.find( - (w) => w.name === normalized.sourceWidgetName - ) - if (!sourceWidget) { - return { - classification: 'unknown', - plan: { kind: 'quarantine', reason: 'missingSourceWidget' } - } - } - - if ( - normalized.sourceWidgetName.startsWith('$$') || - isPreviewPseudoWidget(sourceWidget) - ) { - return { - classification: 'preview', - plan: { - kind: 'previewExposure', - sourcePreviewName: normalized.sourceWidgetName - } - } - } - - return { - classification: 'value', - plan: { - kind: 'createSubgraphInput', - sourceWidgetName: normalized.sourceWidgetName - } - } -} diff --git a/src/core/graph/subgraph/migration/migratePreviewExposure.test.ts b/src/core/graph/subgraph/migration/migratePreviewExposure.test.ts deleted file mode 100644 index afeed6445f..0000000000 --- a/src/core/graph/subgraph/migration/migratePreviewExposure.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure' -import type { ResolveNestedHostFn } from '@/stores/previewExposureStore' -import { usePreviewExposureStore } from '@/stores/previewExposureStore' - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({}) -})) -vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: () => ({ updatePreviews: () => ({}) }) -})) - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() -}) - -function buildHost(): SubgraphNode { - const subgraph = createTestSubgraph() - const hostNode = createTestSubgraphNode(subgraph) - hostNode.graph!.add(hostNode) - return hostNode -} - -function buildEntry(args: { - sourceNodeId: string - sourcePreviewName: string -}): PendingMigrationEntry { - return { - normalized: { - sourceNodeId: args.sourceNodeId, - sourceWidgetName: args.sourcePreviewName - }, - legacyOrderIndex: 0, - hostValue: HOST_VALUE_HOLE, - plan: { - kind: 'previewExposure', - sourcePreviewName: args.sourcePreviewName - } - } -} - -describe(migratePreviewExposure, () => { - it('adds an exposure for a $$-prefixed preview source', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - host.subgraph.add(innerNode) - - const store = usePreviewExposureStore() - const result = migratePreviewExposure({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourcePreviewName: '$$canvas-image-preview' - }), - store - }) - - expect(result).toEqual({ - ok: true, - previewName: '$$canvas-image-preview' - }) - const locator = String(host.id) - expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1) - }) - - it('produces a unique name on collision via nextUniqueName', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - host.subgraph.add(innerNode) - const otherInner = new LGraphNode('OtherInner') - host.subgraph.add(otherInner) - - const store = usePreviewExposureStore() - const locator = String(host.id) - store.addExposure(host.rootGraph.id, locator, { - sourceNodeId: String(innerNode.id), - sourcePreviewName: '$$canvas-image-preview' - }) - - const result = migratePreviewExposure({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(otherInner.id), - sourcePreviewName: '$$canvas-image-preview' - }), - store - }) - - expect(result.ok).toBe(true) - if (!result.ok) return - expect(result.previewName).toBe('$$canvas-image-preview_1') - expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(2) - }) - - it('reuses an existing exposure for the same source preview', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - host.subgraph.add(innerNode) - - const store = usePreviewExposureStore() - const locator = String(host.id) - store.addExposure(host.rootGraph.id, locator, { - sourceNodeId: String(innerNode.id), - sourcePreviewName: '$$canvas-image-preview' - }) - - const result = migratePreviewExposure({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourcePreviewName: '$$canvas-image-preview' - }), - store - }) - - expect(result).toEqual({ - ok: true, - previewName: '$$canvas-image-preview' - }) - expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1) - }) - - it('returns missingSourceNode when the source node is absent', () => { - const host = buildHost() - const store = usePreviewExposureStore() - - const result = migratePreviewExposure({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: '999', - sourcePreviewName: '$$canvas-image-preview' - }), - store - }) - - expect(result).toEqual({ ok: false, reason: 'missingSourceNode' }) - }) - - it('round-trips through resolveChain across an outer host into an inner host', () => { - // Set up an inner host with a leaf preview exposure, and a separate outer - // host whose interior contains a placeholder for the inner host. The - // chain walker is graph-agnostic, so we wire the nested-host edge via - // the resolver callback. - const innerSubgraph = createTestSubgraph({ name: 'Inner' }) - const innerHost = createTestSubgraphNode(innerSubgraph) - innerHost.graph!.add(innerHost) - const innerLeaf = new LGraphNode('Leaf') - innerSubgraph.add(innerLeaf) - - const outerSubgraph = createTestSubgraph({ name: 'Outer' }) - const outerHost = createTestSubgraphNode(outerSubgraph) - outerHost.graph!.add(outerHost) - - const placeholder = new LGraphNode('PlaceholderInnerHost') - outerSubgraph.add(placeholder) - - const store = usePreviewExposureStore() - const innerLocator = String(innerHost.id) - const outerLocator = String(outerHost.id) - - // Inner host: the leaf exposure (canonical $$ name) the outer chain - // ultimately resolves to. - store.addExposure(innerHost.rootGraph.id, innerLocator, { - sourceNodeId: String(innerLeaf.id), - sourcePreviewName: '$$inner-preview' - }) - - // Outer host: migrate an entry whose source points at the placeholder - // (representing the inner host inside outer's interior). - const result = migratePreviewExposure({ - hostNode: outerHost, - entry: { - normalized: { - sourceNodeId: String(placeholder.id), - sourceWidgetName: '$$inner-preview' - }, - legacyOrderIndex: 0, - hostValue: HOST_VALUE_HOLE, - plan: { - kind: 'previewExposure', - sourcePreviewName: '$$inner-preview' - } - }, - store - }) - expect(result.ok).toBe(true) - - const resolveNestedHost: ResolveNestedHostFn = ( - _rootGraphId, - _hostLocator, - sourceNodeId - ) => - sourceNodeId === String(placeholder.id) - ? { rootGraphId: innerHost.rootGraph.id, hostNodeLocator: innerLocator } - : undefined - - const chain = store.resolveChain( - outerHost.rootGraph.id, - outerLocator, - '$$inner-preview', - resolveNestedHost - ) - - expect(chain).toBeDefined() - expect(chain?.steps).toHaveLength(2) - expect(chain?.leaf.sourceNodeId).toBe(String(innerLeaf.id)) - expect(chain?.leaf.sourcePreviewName).toBe('$$inner-preview') - }) -}) diff --git a/src/core/graph/subgraph/migration/migratePreviewExposure.ts b/src/core/graph/subgraph/migration/migratePreviewExposure.ts deleted file mode 100644 index a7e2eeb2d7..0000000000 --- a/src/core/graph/subgraph/migration/migratePreviewExposure.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' -import type { usePreviewExposureStore } from '@/stores/previewExposureStore' - -type MigratePreviewExposureResult = - | { ok: true; previewName: string } - | { ok: false; reason: 'missingSourceNode' | 'missingSourceWidget' } - -interface MigratePreviewExposureArgs { - hostNode: SubgraphNode - entry: PendingMigrationEntry - /** Pinia store action — pass `usePreviewExposureStore()` from the caller. */ - store: ReturnType -} - -/** - * Project a single legacy preview-shaped proxy entry into the host-scoped - * {@link usePreviewExposureStore}. - * - * For canonical `$$`-prefixed preview names the source widget may be lazily - * created at first execution; we treat the exposure as metadata-only and do - * not require the concrete widget to be present yet. For non-`$$` previews - * (e.g. `videopreview`) the widget must already exist on the source node. - */ -export function migratePreviewExposure( - args: MigratePreviewExposureArgs -): MigratePreviewExposureResult { - const { hostNode, entry, store } = args - const { plan } = entry - - if (plan.kind !== 'previewExposure') { - throw new Error(`migratePreviewExposure: invalid plan kind ${plan.kind}`) - } - - const sourceNode = hostNode.subgraph.getNodeById( - entry.normalized.sourceNodeId - ) - if (!sourceNode) { - return { ok: false, reason: 'missingSourceNode' } - } - - const isCanonicalPseudo = plan.sourcePreviewName.startsWith('$$') - if (!isCanonicalPseudo) { - const widget = sourceNode.widgets?.find( - (w) => w.name === plan.sourcePreviewName - ) - if (!widget) { - return { ok: false, reason: 'missingSourceWidget' } - } - } - - const hostNodeLocator = String(hostNode.id) - const existing = store - .getExposures(hostNode.rootGraph.id, hostNodeLocator) - .find( - (exposure) => - exposure.sourceNodeId === entry.normalized.sourceNodeId && - exposure.sourcePreviewName === plan.sourcePreviewName - ) - if (existing) return { ok: true, previewName: existing.name } - - const added = store.addExposure(hostNode.rootGraph.id, hostNodeLocator, { - sourceNodeId: entry.normalized.sourceNodeId, - sourcePreviewName: plan.sourcePreviewName - }) - - return { ok: true, previewName: added.name } -} diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts b/src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts new file mode 100644 index 0000000000..8db9dfa110 --- /dev/null +++ b/src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts @@ -0,0 +1,785 @@ +import { createTestingPinia } from '@pinia/testing' +import { fromPartial } from '@total-typescript/shoehorn' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + LGraph, + LGraphNode, + LiteGraph, + SubgraphNode +} from '@/lib/litegraph/src/litegraph' +import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets' +import { + createTestSubgraph, + createTestSubgraphNode, + resetSubgraphFixtureState +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' + +import { + flushProxyWidgetMigration, + readHostQuarantine +} from '@/core/graph/subgraph/migration/proxyWidgetMigration' +import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' +import { usePreviewExposureStore } from '@/stores/previewExposureStore' + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({}) +})) +vi.mock('@/services/litegraphService', () => ({ + useLitegraphService: () => ({ updatePreviews: () => ({}) }) +})) + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + resetSubgraphFixtureState() + LGraph.proxyWidgetMigrationFlush = undefined +}) + +function buildHost(): SubgraphNode { + const subgraph = createTestSubgraph() + const hostNode = createTestSubgraphNode(subgraph) + hostNode.graph!.add(hostNode) + return hostNode +} + +function addInnerNode( + host: SubgraphNode, + type: string, + build: (node: LGraphNode) => void = () => {} +): LGraphNode { + const node = new LGraphNode(type) + build(node) + host.subgraph.add(node) + return node +} + +function addPromotedHostInput( + host: SubgraphNode, + args: { + inputName: string + promotedName: string + sourceNodeId: string + sourceWidgetName: string + initialValue?: TWidgetValue + } +): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } { + let widgetValue: TWidgetValue = args.initialValue ?? 0 + const slot = host.addInput(args.inputName, '*') + slot._widget = fromPartial({ + node: host, + name: args.promotedName, + sourceNodeId: args.sourceNodeId, + sourceWidgetName: args.sourceWidgetName, + get value() { + return widgetValue + }, + set value(v: TWidgetValue) { + widgetValue = v + } + }) + return { + setValue: (v) => { + widgetValue = v + }, + getValue: () => widgetValue + } +} + +function addPrimitiveWithTargets( + host: SubgraphNode, + args: { + primitiveType?: string + primitiveValue?: number + targetCount: number + outputType?: string + targetSlotType?: string + } +): { primitive: LGraphNode; targets: LGraphNode[] } { + const outputType = args.outputType ?? 'INT' + const targetSlotType = args.targetSlotType ?? outputType + const primitive = new LGraphNode('PrimitiveNode') + primitive.type = 'PrimitiveNode' + primitive.addOutput('value', outputType) + primitive.addWidget('number', 'value', args.primitiveValue ?? 42, () => {}) + host.subgraph.add(primitive) + + const targets: LGraphNode[] = [] + for (let i = 0; i < args.targetCount; i++) { + const target = new LGraphNode(`Target${i}`) + const slot = target.addInput('value', targetSlotType) + slot.widget = { name: 'value' } + target.addWidget('number', 'value', 0, () => {}) + host.subgraph.add(target) + primitive.connect(0, target, 0) + targets.push(target) + } + return { primitive, targets } +} + +describe('flushProxyWidgetMigration', () => { + describe('no-op cases', () => { + it('returns an empty result when no proxyWidgets are present', () => { + const host = buildHost() + + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toEqual({ + repaired: 0, + primitiveRepaired: 0, + previewMigrated: 0, + quarantined: 0 + }) + expect(host.properties.proxyWidgets).toBeUndefined() + }) + + it('tolerates a malformed proxyWidgets payload and returns empty', () => { + const host = buildHost() + host.properties.proxyWidgets = '{not json}' + + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toEqual({ + repaired: 0, + primitiveRepaired: 0, + previewMigrated: 0, + quarantined: 0 + }) + }) + }) + + describe('value-widget repair', () => { + it('alreadyLinked: applies host value to the matching promoted widget', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('number', 'seed', 0, () => {}) + }) + const handle = addPromotedHostInput(host, { + inputName: 'seed_link', + promotedName: 'seed', + sourceNodeId: String(inner.id), + sourceWidgetName: 'seed', + initialValue: 0 + }) + + host.properties.proxyWidgets = [[String(inner.id), 'seed']] + const result = flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: [99] + }) + + expect(result).toMatchObject({ repaired: 1, quarantined: 0 }) + expect(handle.getValue()).toBe(99) + expect(host.properties.proxyWidgets).toBeUndefined() + }) + + it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'seed', type: 'INT' }] + }) + const host = createTestSubgraphNode(subgraph) + host.graph!.add(host) + const inner = addInnerNode(host, 'Inner', (n) => { + const slot = n.addInput('seed', 'INT') + const innerWidget = n.addWidget('number', 'seed', 0, () => {}) + slot.widget = { name: innerWidget.name } + }) + subgraph.inputNode.slots[0].connect(inner.inputs[0], inner) + + host.properties.proxyWidgets = [[String(inner.id), 'seed']] + const result = flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: [99] + }) + + expect(result).toMatchObject({ repaired: 1, quarantined: 0 }) + expect(host.widgets[0].value).toBe(99) + const innerWidget = inner.widgets!.find((w) => w.name === 'seed')! + expect(innerWidget.value).toBe(0) + }) + + it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('number', 'seed', 0, () => {}) + }) + const handle = addPromotedHostInput(host, { + inputName: 'seed_link', + promotedName: 'seed', + sourceNodeId: String(inner.id), + sourceWidgetName: 'seed', + initialValue: 7 + }) + + host.properties.proxyWidgets = [[String(inner.id), 'seed']] + const sparse: unknown[] = [] + const result = flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: sparse + }) + + expect(result).toMatchObject({ repaired: 1, quarantined: 0 }) + expect(handle.getValue()).toBe(7) + }) + + it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('number', 'seed', 0, () => {}) + }) + const a = addPromotedHostInput(host, { + inputName: 'first_seed', + promotedName: 'seed', + sourceNodeId: String(inner.id), + sourceWidgetName: 'seed', + initialValue: 1 + }) + const b = addPromotedHostInput(host, { + inputName: 'second_seed', + promotedName: 'seed', + sourceNodeId: String(inner.id), + sourceWidgetName: 'seed', + initialValue: 2 + }) + + host.properties.proxyWidgets = [[String(inner.id), 'seed']] + const result = flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: [99] + }) + + expect(result).toMatchObject({ repaired: 0, quarantined: 1 }) + expect(a.getValue()).toBe(1) + expect(b.getValue()).toBe(2) + expect(readHostQuarantine(host)).toEqual([ + expect.objectContaining({ + originalEntry: [String(inner.id), 'seed'], + reason: 'ambiguousSubgraphInput' + }) + ]) + }) + + it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + const slot = n.addInput('seed', 'INT') + slot.widget = { name: 'seed' } + n.addWidget('number', 'seed', 0, () => {}) + }) + + const inputCountBefore = host.subgraph.inputs.length + host.properties.proxyWidgets = [[String(inner.id), 'seed']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ repaired: 1, quarantined: 0 }) + expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1) + const created = host.subgraph.inputs.at(-1) + expect(created?._widget).toBeDefined() + }) + + it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('number', 'seed', 0, () => {}) + }) + + const inputCountBefore = host.subgraph.inputs.length + host.properties.proxyWidgets = [[String(inner.id), 'seed']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ repaired: 0, quarantined: 1 }) + expect(host.subgraph.inputs).toHaveLength(inputCountBefore) + expect(readHostQuarantine(host)).toEqual([ + expect.objectContaining({ + originalEntry: [String(inner.id), 'seed'], + reason: 'missingSubgraphInput' + }) + ]) + }) + }) + + describe('primitive fan-out repair', () => { + it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => { + const host = buildHost() + const { primitive, targets } = addPrimitiveWithTargets(host, { + targetCount: 3 + }) + + const inputCountBefore = host.subgraph.inputs.length + host.properties.proxyWidgets = [[String(primitive.id), 'value']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ + primitiveRepaired: 1, + repaired: 0, + quarantined: 0 + }) + expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1) + for (const target of targets) { + const slot = target.inputs[0] + expect(slot.link).not.toBeNull() + const link = host.subgraph.links.get(slot.link!) + expect(link?.origin_id).not.toBe(primitive.id) + } + }) + + it('coalesces duplicate cohort entries pointing at the same primitive', () => { + const host = buildHost() + const { primitive, targets } = addPrimitiveWithTargets(host, { + targetCount: 2 + }) + + host.properties.proxyWidgets = [ + [String(primitive.id), 'value'], + [String(primitive.id), 'value'] + ] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ primitiveRepaired: 1, quarantined: 0 }) + for (const target of targets) { + const slot = target.inputs[0] + const link = host.subgraph.links.get(slot.link!) + expect(link?.origin_id).not.toBe(primitive.id) + } + }) + + it('host value wins over primitive widget value', () => { + const host = buildHost() + const { primitive } = addPrimitiveWithTargets(host, { + targetCount: 2, + primitiveValue: 11 + }) + + host.properties.proxyWidgets = [[String(primitive.id), 'value']] + const result = flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: [123] + }) + expect(result.primitiveRepaired).toBe(1) + + const created = host.subgraph.inputs.at(-1) + expect(created?._widget?.value).toBe(123) + }) + + it('seeds value from the primitive widget when no host value is supplied', () => { + const host = buildHost() + const { primitive } = addPrimitiveWithTargets(host, { + targetCount: 1, + primitiveValue: 11 + }) + + host.properties.proxyWidgets = [[String(primitive.id), 'value']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result.primitiveRepaired).toBe(1) + const created = host.subgraph.inputs.at(-1) + expect(created?._widget?.value).toBe(11) + }) + + it('quarantines an unlinked primitive node with no fan-out', () => { + const host = buildHost() + const primitive = new LGraphNode('Primitive') + primitive.type = 'PrimitiveNode' + primitive.addOutput('value', '*') + host.subgraph.add(primitive) + + host.properties.proxyWidgets = [[String(primitive.id), 'value']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ primitiveRepaired: 0, quarantined: 1 }) + expect(readHostQuarantine(host)).toEqual([ + expect.objectContaining({ + originalEntry: [String(primitive.id), 'value'], + reason: 'unlinkedSourceWidget' + }) + ]) + }) + + it('quarantines all cohort entries when a target slot type is incompatible', () => { + const host = buildHost() + const { primitive, targets } = addPrimitiveWithTargets(host, { + targetCount: 1 + }) + targets[0].inputs[0].type = 'STRING' + + const inputCountBefore = host.subgraph.inputs.length + host.properties.proxyWidgets = [[String(primitive.id), 'value']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ primitiveRepaired: 0, quarantined: 1 }) + expect(host.subgraph.inputs).toHaveLength(inputCountBefore) + expect(readHostQuarantine(host)).toEqual([ + expect.objectContaining({ + originalEntry: [String(primitive.id), 'value'], + reason: 'primitiveBypassFailed' + }) + ]) + }) + + it('keeps surviving primitive targets when one fan-out link is dangling', () => { + const host = buildHost() + const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 }) + + const danglingLinkId = 999_999 + expect(host.subgraph.links.has(danglingLinkId)).toBe(false) + primitive.outputs[0].links = [ + ...(primitive.outputs[0].links ?? []), + danglingLinkId + ] + + host.properties.proxyWidgets = [[String(primitive.id), 'value']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ primitiveRepaired: 0, quarantined: 1 }) + expect(readHostQuarantine(host)).toEqual([ + expect.objectContaining({ + originalEntry: [String(primitive.id), 'value'], + reason: 'primitiveBypassFailed' + }) + ]) + }) + }) + + describe('preview exposure migration', () => { + it('adds an exposure for a $$-prefixed preview source', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + + host.properties.proxyWidgets = [ + [String(inner.id), '$$canvas-image-preview'] + ] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ previewMigrated: 1, quarantined: 0 }) + const exposures = usePreviewExposureStore().getExposures( + host.rootGraph.id, + String(host.id) + ) + expect(exposures).toHaveLength(1) + expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview') + expect(exposures[0].sourceNodeId).toBe(String(inner.id)) + }) + + it('classifies type:preview serialize:false widgets as preview exposure', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + const widget = n.addWidget('text', 'videopreview', '', () => {}) + widget.type = 'preview' + widget.serialize = false + }) + + host.properties.proxyWidgets = [[String(inner.id), 'videopreview']] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ previewMigrated: 1, quarantined: 0 }) + const exposures = usePreviewExposureStore().getExposures( + host.rootGraph.id, + String(host.id) + ) + expect(exposures).toEqual([ + expect.objectContaining({ + sourceNodeId: String(inner.id), + sourcePreviewName: 'videopreview' + }) + ]) + }) + + it('produces a unique name on collision via nextUniqueName', () => { + const host = buildHost() + const innerA = addInnerNode(host, 'InnerA', (n) => { + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + const innerB = addInnerNode(host, 'InnerB', (n) => { + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + + const store = usePreviewExposureStore() + const locator = String(host.id) + store.addExposure(host.rootGraph.id, locator, { + sourceNodeId: String(innerA.id), + sourcePreviewName: '$$canvas-image-preview' + }) + + host.properties.proxyWidgets = [ + [String(innerB.id), '$$canvas-image-preview'] + ] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ previewMigrated: 1, quarantined: 0 }) + const exposures = store.getExposures(host.rootGraph.id, locator) + expect(exposures).toHaveLength(2) + const newExposure = exposures.find( + (e) => e.sourceNodeId === String(innerB.id) + ) + expect(newExposure?.name).toBe('$$canvas-image-preview_1') + }) + + it('reuses an existing exposure for the same source preview', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + + const store = usePreviewExposureStore() + const locator = String(host.id) + store.addExposure(host.rootGraph.id, locator, { + sourceNodeId: String(inner.id), + sourcePreviewName: '$$canvas-image-preview' + }) + + host.properties.proxyWidgets = [ + [String(inner.id), '$$canvas-image-preview'] + ] + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ previewMigrated: 1, quarantined: 0 }) + expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1) + }) + }) + + describe('quarantine accumulation', () => { + it('quarantines entries whose source node has disappeared', () => { + const host = buildHost() + host.properties.proxyWidgets = [['9999', 'seed']] + + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ quarantined: 1 }) + expect(readHostQuarantine(host)).toEqual([ + { + originalEntry: ['9999', 'seed'], + reason: 'missingSourceNode', + attemptedAtVersion: 1 + } + ]) + }) + + it('quarantines entries whose source widget is missing on the source node', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner') + host.properties.proxyWidgets = [[String(inner.id), 'nonexistent']] + + const result = flushProxyWidgetMigration({ hostNode: host }) + + expect(result).toMatchObject({ quarantined: 1 }) + expect(readHostQuarantine(host)).toEqual([ + expect.objectContaining({ + originalEntry: [String(inner.id), 'nonexistent'], + reason: 'missingSourceWidget' + }) + ]) + }) + + it('preserves the host value on the quarantine row when one was supplied', () => { + const host = buildHost() + host.properties.proxyWidgets = [['9999', 'seed']] + + flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: [42] + }) + + expect(readHostQuarantine(host)).toEqual([ + expect.objectContaining({ + originalEntry: ['9999', 'seed'], + reason: 'missingSourceNode', + hostValue: 42 + }) + ]) + }) + + it('round-trips appended entries via the public read helper', () => { + const host = buildHost() + host.properties.proxyWidgets = [['9999', 'seed']] + flushProxyWidgetMigration({ hostNode: host }) + const first = readHostQuarantine(host) + expect(first).toHaveLength(1) + + host.properties.proxyWidgets = [['9999', 'seed', 'inner-leaf']] + flushProxyWidgetMigration({ hostNode: host }) + + const after = readHostQuarantine(host) + expect(after).toHaveLength(2) + expect(after.map((e) => e.originalEntry)).toEqual([ + ['9999', 'seed'], + ['9999', 'seed', 'inner-leaf'] + ]) + }) + + it('deduplicates entries with identical originalEntry tuples on re-flush', () => { + const host = buildHost() + host.properties.proxyWidgets = [['9999', 'seed']] + flushProxyWidgetMigration({ hostNode: host }) + const firstQuarantine = readHostQuarantine(host) + expect(firstQuarantine).toHaveLength(1) + + host.properties.proxyWidgets = [['9999', 'seed']] + flushProxyWidgetMigration({ hostNode: host }) + + expect(readHostQuarantine(host)).toEqual(firstQuarantine) + }) + }) + + describe('idempotency', () => { + it('clears properties.proxyWidgets after a successful flush', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + host.properties.proxyWidgets = [ + [String(inner.id), '$$canvas-image-preview'] + ] + + flushProxyWidgetMigration({ hostNode: host }) + + expect(host.properties.proxyWidgets).toBeUndefined() + }) + + it('re-running flush over a fully migrated host produces no further mutations', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + host.properties.proxyWidgets = [ + [String(inner.id), '$$canvas-image-preview'] + ] + + const first = flushProxyWidgetMigration({ hostNode: host }) + expect(first.previewMigrated).toBe(1) + + const exposuresAfterFirst = usePreviewExposureStore() + .getExposures(host.rootGraph.id, String(host.id)) + .map((e) => ({ ...e })) + + const second = flushProxyWidgetMigration({ hostNode: host }) + + expect(second).toEqual({ + repaired: 0, + primitiveRepaired: 0, + previewMigrated: 0, + quarantined: 0 + }) + expect( + usePreviewExposureStore().getExposures( + host.rootGraph.id, + String(host.id) + ) + ).toEqual(exposuresAfterFirst) + }) + }) + + describe('mixed cohort', () => { + it('migrates a mixed value+preview cohort in one flush, preserving entry order', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + const slot = n.addInput('seed', 'INT') + slot.widget = { name: 'seed' } + n.addWidget('number', 'seed', 0, () => {}) + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + + const subgraphInputCountBefore = host.subgraph.inputs.length + host.properties.proxyWidgets = [ + [String(inner.id), 'seed'], + [String(inner.id), '$$canvas-image-preview'] + ] + const result = flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: [99] + }) + + expect(result).toMatchObject({ + repaired: 1, + previewMigrated: 1, + quarantined: 0 + }) + expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1) + expect(host.subgraph.inputs.find((i) => i.name === 'seed')).toBeDefined() + const exposures = usePreviewExposureStore().getExposures( + host.rootGraph.id, + String(host.id) + ) + expect(exposures).toHaveLength(1) + expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview') + }) + + it('preserves sparse holes when supplied widgets_values is missing an index', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + const slotA = n.addInput('a', 'INT') + slotA.widget = { name: 'a' } + n.addWidget('number', 'a', 0, () => {}) + const slotB = n.addInput('b', 'INT') + slotB.widget = { name: 'b' } + n.addWidget('number', 'b', 0, () => {}) + }) + + host.properties.proxyWidgets = [ + [String(inner.id), 'a'], + [String(inner.id), 'b'] + ] + const sparse: unknown[] = [] + sparse[1] = 'second-value' + const result = flushProxyWidgetMigration({ + hostNode: host, + hostWidgetValues: sparse + }) + + expect(result).toMatchObject({ repaired: 2, quarantined: 0 }) + expect(host.subgraph.inputs.find((i) => i.name === 'a')).toBeDefined() + expect(host.subgraph.inputs.find((i) => i.name === 'b')).toBeDefined() + }) + }) + + describe('integration with LGraph.configure', () => { + it('runs through LGraph.configure when the migration hook is wired', () => { + const host = buildHost() + const inner = addInnerNode(host, 'Inner', (n) => { + n.addWidget('text', '$$canvas-image-preview', '', () => {}) + }) + host.properties.proxyWidgets = [ + [String(inner.id), '$$canvas-image-preview'] + ] + + const serialized = host.rootGraph.serialize() + LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) => + flushProxyWidgetMigration({ + hostNode, + hostWidgetValues: nodeData?.widgets_values + }) + + const reloadedGraph = new LGraph() + const subgraph = host.subgraph + const instanceData = host.serialize() + LiteGraph.registerNodeType( + subgraph.id, + class TestSubgraphNode extends SubgraphNode { + constructor() { + super(reloadedGraph, subgraph, instanceData) + } + } + ) + try { + reloadedGraph.configure(serialized) + } finally { + LiteGraph.unregisterNodeType(subgraph.id) + } + + const reloadedHost = reloadedGraph.getNodeById(host.id) + expect(reloadedHost?.properties.proxyWidgets).toBeUndefined() + expect( + usePreviewExposureStore().getExposures( + host.rootGraph.id, + String(host.id) + ) + ).toEqual([ + expect.objectContaining({ + sourceNodeId: String(inner.id), + sourcePreviewName: '$$canvas-image-preview' + }) + ]) + }) + }) +}) diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigration.ts b/src/core/graph/subgraph/migration/proxyWidgetMigration.ts new file mode 100644 index 0000000000..64da264205 --- /dev/null +++ b/src/core/graph/subgraph/migration/proxyWidgetMigration.ts @@ -0,0 +1,728 @@ +import { isEqual } from 'es-toolkit/compat' + +import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization' +import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes' +import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' +import { + getPromotableWidgets, + isPreviewPseudoWidget +} from '@/core/graph/subgraph/promotionUtils' +import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema' +import { parseProxyWidgets } from '@/core/schemas/promotionSchema' +import type { + ProxyWidgetErrorQuarantineEntry, + ProxyWidgetQuarantineReason +} from '@/core/schemas/proxyWidgetQuarantineSchema' +import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema' +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph' +import { nextUniqueName } from '@/lib/litegraph/src/strings' +import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph' +import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' +import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import type { + IBaseWidget, + TWidgetValue +} from '@/lib/litegraph/src/types/widgets' +import { usePreviewExposureStore } from '@/stores/previewExposureStore' + +interface FlushArgs { + hostNode: SubgraphNode + hostWidgetValues?: readonly unknown[] +} + +interface FlushResult { + repaired: number + primitiveRepaired: number + previewMigrated: number + quarantined: number +} + +const EMPTY_RESULT: FlushResult = Object.freeze({ + repaired: 0, + primitiveRepaired: 0, + previewMigrated: 0, + quarantined: 0 +}) + +interface PrimitiveBypassTargetRef { + targetNodeId: NodeId + targetSlot: number +} + +type Plan = + | { kind: 'alreadyLinked'; subgraphInputName: string } + | { kind: 'createSubgraphInput'; sourceWidgetName: string } + | { + kind: 'primitiveBypass' + primitiveNodeId: NodeId + sourceWidgetName: string + targets: readonly PrimitiveBypassTargetRef[] + } + | { kind: 'previewExposure'; sourcePreviewName: string } + | { kind: 'quarantine'; reason: ProxyWidgetQuarantineReason } + +interface PendingEntry { + normalized: LegacyProxyEntrySource + hostValue: TWidgetValue | undefined + isHole: boolean + plan: Plan +} + +const PRIMITIVE_NODE_TYPE = 'PrimitiveNode' +const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine' +const QUARANTINE_VERSION = 1 + +export function flushProxyWidgetMigration(args: FlushArgs): FlushResult { + const { hostNode, hostWidgetValues } = args + + const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets) + if (tuples.length === 0) return EMPTY_RESULT + + const cohort: LegacyProxyEntrySource[] = tuples.map( + ([sourceNodeId, sourceWidgetName, disambiguator]) => + normalizeLegacyProxyWidgetEntry( + hostNode, + sourceNodeId, + sourceWidgetName, + disambiguator + ) + ) + + const pending: PendingEntry[] = cohort.map((normalized, index) => { + const { value, isHole } = pickHostValue(hostWidgetValues, index) + return { + normalized, + hostValue: value, + isHole, + plan: classify(hostNode, normalized, cohort) + } + }) + + const previewStore = usePreviewExposureStore() + const result: FlushResult = { ...EMPTY_RESULT } + const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = [] + const primitiveCohorts = new Map() + + for (const entry of pending) { + switch (entry.plan.kind) { + case 'primitiveBypass': { + const c = primitiveCohorts.get(entry.plan.primitiveNodeId) ?? [] + c.push(entry) + primitiveCohorts.set(entry.plan.primitiveNodeId, c) + break + } + case 'alreadyLinked': + case 'createSubgraphInput': { + const r = repairValue(hostNode, entry) + if (r.ok) result.repaired += 1 + else quarantineToAppend.push(quarantineFor(entry, r.reason)) + break + } + case 'previewExposure': { + const r = migratePreview(hostNode, entry, previewStore) + if (r.ok) result.previewMigrated += 1 + else quarantineToAppend.push(quarantineFor(entry, r.reason)) + break + } + case 'quarantine': + quarantineToAppend.push(quarantineFor(entry, entry.plan.reason)) + break + } + } + + for (const c of primitiveCohorts.values()) { + const r = repairPrimitive(hostNode, c) + if (r.ok) result.primitiveRepaired += 1 + else for (const e of c) quarantineToAppend.push(quarantineFor(e, r.reason)) + } + + if (quarantineToAppend.length > 0) { + appendQuarantine(hostNode, quarantineToAppend) + result.quarantined = quarantineToAppend.length + } + + delete hostNode.properties.proxyWidgets + + return result +} + +function pickHostValue( + hostWidgetValues: readonly unknown[] | undefined, + index: number +): { value: TWidgetValue | undefined; isHole: boolean } { + if ( + hostWidgetValues === undefined || + index < 0 || + index >= hostWidgetValues.length || + !Object.hasOwn(hostWidgetValues, index) + ) { + return { value: undefined, isHole: true } + } + return { value: hostWidgetValues[index] as TWidgetValue, isHole: false } +} + +function findHostInputForLinkedSource( + hostNode: SubgraphNode, + sourceNodeId: string, + sourceWidgetName: string, + subgraphInputName?: string +): INodeInputSlot | 'ambiguous' | undefined { + const candidates = subgraphInputName + ? hostNode.inputs.filter((input) => input.name === subgraphInputName) + : hostNode.inputs + const matches = candidates.filter((input) => { + const widget = input._widget + return ( + !!widget && + isPromotedWidgetView(widget) && + widget.sourceNodeId === sourceNodeId && + widget.sourceWidgetName === sourceWidgetName + ) + }) + if (matches.length === 0) return undefined + if (matches.length === 1) return matches[0] + return 'ambiguous' +} + +function collectTargetsStrict( + hostNode: SubgraphNode, + primitiveNode: LGraphNode +): PrimitiveBypassTargetRef[] | undefined { + const subgraph = hostNode.subgraph + const output = primitiveNode.outputs?.[0] + const linkIds = output?.links ?? [] + const targets: PrimitiveBypassTargetRef[] = [] + for (const linkId of linkIds) { + const link = subgraph.links.get(linkId) + if (!link) return undefined + targets.push({ + targetNodeId: link.target_id, + targetSlot: link.target_slot + }) + } + return targets +} + +function collectTargetsSkippingDangling( + hostNode: SubgraphNode, + primitiveNode: LGraphNode +): PrimitiveBypassTargetRef[] { + const subgraph = hostNode.subgraph + const output = primitiveNode.outputs?.[0] + const linkIds = output?.links ?? [] + const targets: PrimitiveBypassTargetRef[] = [] + for (const linkId of linkIds) { + const link = subgraph.links.get(linkId) + if (!link) continue + targets.push({ + targetNodeId: link.target_id, + targetSlot: link.target_slot + }) + } + return targets +} + +function cohortReferencesPrimitive( + cohort: readonly LegacyProxyEntrySource[], + primitiveNodeId: string +): boolean { + let count = 0 + for (const entry of cohort) { + if (entry.sourceNodeId === primitiveNodeId) { + count += 1 + if (count >= 2) return true + } + } + return false +} + +function classify( + hostNode: SubgraphNode, + normalized: LegacyProxyEntrySource, + cohort: readonly LegacyProxyEntrySource[] +): Plan { + const linkedInput = findHostInputForLinkedSource( + hostNode, + normalized.sourceNodeId, + normalized.sourceWidgetName + ) + if (linkedInput === 'ambiguous') { + return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' } + } + if (linkedInput) { + return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name } + } + + const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId) + if (!sourceNode) { + return { kind: 'quarantine', reason: 'missingSourceNode' } + } + + if (sourceNode.type === PRIMITIVE_NODE_TYPE) { + const targets = collectTargetsSkippingDangling(hostNode, sourceNode) + const cohortDuplicated = cohortReferencesPrimitive( + cohort, + normalized.sourceNodeId + ) + if (targets.length >= 1 || cohortDuplicated) { + return { + kind: 'primitiveBypass', + primitiveNodeId: sourceNode.id, + sourceWidgetName: normalized.sourceWidgetName, + targets + } + } + return { kind: 'quarantine', reason: 'unlinkedSourceWidget' } + } + + const promotableWidgets = getPromotableWidgets(sourceNode) + const sourceWidget = promotableWidgets.find( + (w) => w.name === normalized.sourceWidgetName + ) + if (!sourceWidget) { + return { kind: 'quarantine', reason: 'missingSourceWidget' } + } + + if ( + normalized.sourceWidgetName.startsWith('$$') || + isPreviewPseudoWidget(sourceWidget) + ) { + return { + kind: 'previewExposure', + sourcePreviewName: normalized.sourceWidgetName + } + } + + return { + kind: 'createSubgraphInput', + sourceWidgetName: normalized.sourceWidgetName + } +} + +function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void { + if (entry.isHole) return + if ( + isPromotedWidgetView(widget) && + typeof widget.hydrateHostValue === 'function' + ) { + widget.hydrateHostValue(entry.hostValue) + return + } + widget.value = entry.hostValue as TWidgetValue +} + +function addUniqueSubgraphInput( + subgraph: Subgraph, + baseName: string, + type: string +): SubgraphInput { + const existingNames = subgraph.inputs.map((input) => input.name) + const uniqueName = nextUniqueName(baseName, existingNames) + return subgraph.addInput(uniqueName, type) +} + +type RepairValueResult = + | { ok: true; subgraphInputName: string } + | { ok: false; reason: ProxyWidgetQuarantineReason } + +function repairValue( + hostNode: SubgraphNode, + entry: PendingEntry +): RepairValueResult { + if (entry.plan.kind === 'alreadyLinked') { + return repairAlreadyLinked(hostNode, entry, entry.plan.subgraphInputName) + } + if (entry.plan.kind === 'createSubgraphInput') { + return repairCreateSubgraphInput( + hostNode, + entry, + entry.plan.sourceWidgetName + ) + } + throw new Error(`repairValue: invalid plan kind ${entry.plan.kind}`) +} + +function repairAlreadyLinked( + hostNode: SubgraphNode, + entry: PendingEntry, + subgraphInputName: string +): RepairValueResult { + const hostInput = findHostInputForLinkedSource( + hostNode, + entry.normalized.sourceNodeId, + entry.normalized.sourceWidgetName, + subgraphInputName + ) + if (hostInput === 'ambiguous') { + return { ok: false, reason: 'ambiguousSubgraphInput' } + } + if (!hostInput || !hostInput._widget) { + return { ok: false, reason: 'missingSubgraphInput' } + } + applyHostValue(hostInput._widget, entry) + return { ok: true, subgraphInputName: hostInput.name } +} + +function repairCreateSubgraphInput( + hostNode: SubgraphNode, + entry: PendingEntry, + sourceWidgetName: string +): RepairValueResult { + const subgraph = hostNode.subgraph + const sourceNode: LGraphNode | null = subgraph.getNodeById( + entry.normalized.sourceNodeId + ) + if (!sourceNode) { + return { ok: false, reason: 'missingSourceNode' } + } + + const sourceWidget = sourceNode.widgets?.find( + (w) => w.name === sourceWidgetName + ) + if (!sourceWidget) { + return { ok: false, reason: 'missingSourceWidget' } + } + + const slot: INodeInputSlot | undefined = + sourceNode.getSlotFromWidget(sourceWidget) + if (!slot) { + // TODO(adr-0009): synthesize a backing input slot during the wiring slice. + console.warn( + '[proxyWidgetMigration] source widget has no backing input slot; quarantining', + { + sourceNodeId: entry.normalized.sourceNodeId, + sourceWidgetName + } + ) + return { ok: false, reason: 'missingSubgraphInput' } + } + + const slotType = String(slot.type ?? sourceWidget.type ?? '*') + const newSubgraphInput = addUniqueSubgraphInput( + subgraph, + sourceWidgetName, + slotType + ) + if (slot.label !== undefined) newSubgraphInput.label = slot.label + const link = newSubgraphInput.connect(slot, sourceNode) + if (!link) { + subgraph.removeInput(newSubgraphInput) + return { ok: false, reason: 'missingSubgraphInput' } + } + + const hostInput = hostNode.inputs.find( + (input) => input.name === newSubgraphInput.name + ) + if (!hostInput?._widget) { + return { ok: true, subgraphInputName: newSubgraphInput.name } + } + + applyHostValue(hostInput._widget, entry) + return { ok: true, subgraphInputName: newSubgraphInput.name } +} + +type RepairPrimitiveResult = + | { ok: true; subgraphInputName: string; reconnectCount: number } + | { ok: false; reason: 'primitiveBypassFailed' } + +const PRIMITIVE_FAILED: RepairPrimitiveResult = { + ok: false, + reason: 'primitiveBypassFailed' +} + +interface SnapshotLink { + primitiveSlot: number + targetNodeId: NodeId + targetSlot: number +} + +interface CohortValidationOk { + ok: true + primitiveNodeId: NodeId + sourceWidgetName: string + uniqueEntries: readonly PendingEntry[] +} + +function failPrimitive(message: string, ctx?: unknown): RepairPrimitiveResult { + console.warn(`[proxyWidgetMigration] ${message}`, ctx) + return PRIMITIVE_FAILED +} + +function userRenamedTitle(primitiveNode: LGraphNode): string | undefined { + const title = primitiveNode.title + return title && title !== PRIMITIVE_NODE_TYPE ? title : undefined +} + +function validateCohort( + cohort: readonly PendingEntry[] +): CohortValidationOk | { ok: false } { + const first = cohort[0] + if (!first || first.plan.kind !== 'primitiveBypass') return { ok: false } + const { primitiveNodeId, sourceWidgetName } = first.plan + for (const entry of cohort) { + if ( + entry.plan.kind !== 'primitiveBypass' || + entry.plan.primitiveNodeId !== primitiveNodeId || + entry.plan.sourceWidgetName !== sourceWidgetName + ) { + return { ok: false } + } + } + const uniqueEntries: PendingEntry[] = [] + for (const entry of cohort) { + if (!uniqueEntries.some((k) => isEqual(k.normalized, entry.normalized))) { + uniqueEntries.push(entry) + } + } + return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries } +} + +function rollback( + hostNode: SubgraphNode, + primitiveNode: LGraphNode, + newSubgraphInput: SubgraphInput | undefined, + snapshot: readonly SnapshotLink[] +): void { + if (newSubgraphInput) { + try { + hostNode.subgraph.removeInput(newSubgraphInput) + } catch (e) { + console.warn('[proxyWidgetMigration] rollback removeInput failed', e) + } + } + for (const link of snapshot) { + const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId) + if (!targetNode) continue + primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot) + } +} + +function repairPrimitive( + hostNode: SubgraphNode, + cohort: readonly PendingEntry[] +): RepairPrimitiveResult { + const validated = validateCohort(cohort) + if (!validated.ok) + return failPrimitive('cohort validation failed', { cohort }) + + const subgraph = hostNode.subgraph + const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId) + if (!primitiveNode) return failPrimitive('primitive node missing', validated) + if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) { + return failPrimitive('node is not a PrimitiveNode', primitiveNode.type) + } + + const targets = collectTargetsStrict(hostNode, primitiveNode) + if (!targets?.length) + return failPrimitive('no targets to reconnect', validated) + + const primitiveOutput = primitiveNode.outputs?.[0] + if (!primitiveOutput) return failPrimitive('primitive has no output') + const primitiveOutputType = String(primitiveOutput.type ?? '*') + + for (const target of targets) { + const targetNode = subgraph.getNodeById(target.targetNodeId) + if (!targetNode) return failPrimitive('target node missing', target) + const targetSlot = targetNode.inputs?.[target.targetSlot] + if (!targetSlot) return failPrimitive('target slot missing', target) + const targetType = String(targetSlot.type ?? '*') + if ( + targetType !== primitiveOutputType && + targetType !== '*' && + primitiveOutputType !== '*' + ) { + return failPrimitive('target slot type incompatible', { + target, + targetType, + primitiveOutputType + }) + } + } + + const baseName = userRenamedTitle(primitiveNode) ?? validated.sourceWidgetName + const snapshot: SnapshotLink[] = (primitiveOutput.links ?? []) + .map((id) => subgraph.links.get(id)) + .filter((l): l is NonNullable => l !== undefined) + .map((l) => ({ + primitiveSlot: l.origin_slot, + targetNodeId: l.target_id, + targetSlot: l.target_slot + })) + + let newSubgraphInput: SubgraphInput | undefined + try { + newSubgraphInput = addUniqueSubgraphInput( + subgraph, + baseName, + primitiveOutputType + ) + + for (const snap of snapshot) { + const targetNode = subgraph.getNodeById(snap.targetNodeId) + if (!targetNode) + throw new Error( + `target node ${snap.targetNodeId} disappeared mid-mutation` + ) + targetNode.disconnectInput(snap.targetSlot, false) + } + + for (const target of targets) { + const targetNode = subgraph.getNodeById(target.targetNodeId) + if (!targetNode) + throw new Error(`target node ${target.targetNodeId} disappeared`) + const targetSlot = targetNode.inputs?.[target.targetSlot] + if (!targetSlot) + throw new Error(`target slot ${target.targetSlot} disappeared`) + const link = newSubgraphInput.connect(targetSlot, targetNode) + if (!link) { + throw new Error('SubgraphInput.connect returned no link') + } + } + } catch (e) { + rollback(hostNode, primitiveNode, newSubgraphInput, snapshot) + return failPrimitive('mutation failed; rolled back', { error: e }) + } + + const valueEntry = validated.uniqueEntries.find((e) => !e.isHole) + if (valueEntry && newSubgraphInput._widget) { + applyHostValue(newSubgraphInput._widget, valueEntry) + } else if (!valueEntry) { + const primitiveValue = primitiveNode.widgets?.find( + (w) => w.name === validated.sourceWidgetName + )?.value as TWidgetValue | undefined + if (primitiveValue !== undefined && newSubgraphInput._widget) { + newSubgraphInput._widget.value = primitiveValue + } + } + + return { + ok: true, + subgraphInputName: newSubgraphInput.name, + reconnectCount: targets.length + } +} + +type MigratePreviewResult = + | { ok: true; previewName: string } + | { ok: false; reason: 'missingSourceNode' | 'missingSourceWidget' } + +function migratePreview( + hostNode: SubgraphNode, + entry: PendingEntry, + store: ReturnType +): MigratePreviewResult { + const plan = entry.plan + if (plan.kind !== 'previewExposure') { + throw new Error(`migratePreview: invalid plan kind ${plan.kind}`) + } + + const sourceNode = hostNode.subgraph.getNodeById( + entry.normalized.sourceNodeId + ) + if (!sourceNode) { + return { ok: false, reason: 'missingSourceNode' } + } + + const isCanonicalPseudo = plan.sourcePreviewName.startsWith('$$') + if (!isCanonicalPseudo) { + const widget = sourceNode.widgets?.find( + (w) => w.name === plan.sourcePreviewName + ) + if (!widget) { + return { ok: false, reason: 'missingSourceWidget' } + } + } + + const hostNodeLocator = String(hostNode.id) + const existing = store + .getExposures(hostNode.rootGraph.id, hostNodeLocator) + .find( + (exposure) => + exposure.sourceNodeId === entry.normalized.sourceNodeId && + exposure.sourcePreviewName === plan.sourcePreviewName + ) + if (existing) return { ok: true, previewName: existing.name } + + const added = store.addExposure(hostNode.rootGraph.id, hostNodeLocator, { + sourceNodeId: entry.normalized.sourceNodeId, + sourcePreviewName: plan.sourcePreviewName + }) + + return { ok: true, previewName: added.name } +} + +function quarantineFor( + entry: PendingEntry, + reason: ProxyWidgetQuarantineReason +): ProxyWidgetErrorQuarantineEntry { + const { sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId } = + entry.normalized + const originalEntry: SerializedProxyWidgetTuple = disambiguatingSourceNodeId + ? [sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId] + : [sourceNodeId, sourceWidgetName] + const result: ProxyWidgetErrorQuarantineEntry = { + originalEntry, + reason, + attemptedAtVersion: QUARANTINE_VERSION + } + if (!entry.isHole && entry.hostValue !== undefined) { + result.hostValue = entry.hostValue + } + return result +} + +function appendQuarantine( + hostNode: SubgraphNode, + entries: readonly ProxyWidgetErrorQuarantineEntry[] +): void { + if (entries.length === 0) return + const existing = parseProxyWidgetErrorQuarantine( + hostNode.properties[QUARANTINE_PROPERTY] + ) + const merged = [...existing] + for (const candidate of entries) { + if ( + !merged.some((e) => isEqual(e.originalEntry, candidate.originalEntry)) + ) { + merged.push(candidate) + } + } + if (merged.length === 0) delete hostNode.properties[QUARANTINE_PROPERTY] + else hostNode.properties[QUARANTINE_PROPERTY] = merged +} + +export function readHostQuarantine( + hostNode: SubgraphNode +): ProxyWidgetErrorQuarantineEntry[] { + return parseProxyWidgetErrorQuarantine( + hostNode.properties[QUARANTINE_PROPERTY] + ) +} + +interface MakeQuarantineEntryArgs { + originalEntry: SerializedProxyWidgetTuple + reason: ProxyWidgetQuarantineReason + hostValue?: TWidgetValue +} + +export function makeQuarantineEntry( + args: MakeQuarantineEntryArgs +): ProxyWidgetErrorQuarantineEntry { + const entry: ProxyWidgetErrorQuarantineEntry = { + originalEntry: args.originalEntry, + reason: args.reason, + attemptedAtVersion: QUARANTINE_VERSION + } + if (args.hostValue !== undefined) { + entry.hostValue = args.hostValue + } + return entry +} + +export function appendHostQuarantine( + hostNode: SubgraphNode, + entries: readonly ProxyWidgetErrorQuarantineEntry[] +): void { + appendQuarantine(hostNode, entries) +} diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.test.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.test.ts deleted file mode 100644 index 8e47ab34a5..0000000000 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { fromPartial } from '@total-typescript/shoehorn' -import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - LGraph, - LGraphNode, - LiteGraph, - SubgraphNode -} from '@/lib/litegraph/src/litegraph' -import { setSubgraphMigrationFlushHook } from '@/lib/litegraph/src/subgraph/subgraphMigrationHook' -import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets' -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush' -import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry' -import { wireProxyWidgetMigrationFlush } from '@/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush' -import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' -import { usePreviewExposureStore } from '@/stores/previewExposureStore' - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({}) -})) -vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: () => ({ updatePreviews: () => ({}) }) -})) - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() - setSubgraphMigrationFlushHook(undefined) -}) - -function buildHost(): SubgraphNode { - const subgraph = createTestSubgraph() - const hostNode = createTestSubgraphNode(subgraph) - const graph = hostNode.graph! - graph.add(hostNode) - return hostNode -} - -describe(flushProxyWidgetMigration, () => { - it('returns an empty result when no proxyWidgets are present', () => { - const host = buildHost() - - const result = flushProxyWidgetMigration({ hostNode: host }) - - expect(result).toEqual({ - repaired: 0, - primitiveRepaired: 0, - previewMigrated: 0, - quarantined: 0 - }) - }) - - it('migrates a preview-shaped entry into the PreviewExposureStore', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('text', '$$canvas-image-preview', '', () => {}) - host.subgraph.add(innerNode) - - host.properties.proxyWidgets = [ - [String(innerNode.id), '$$canvas-image-preview'] - ] - - const result = flushProxyWidgetMigration({ hostNode: host }) - - expect(result.previewMigrated).toBe(1) - expect(result.quarantined).toBe(0) - - const exposures = usePreviewExposureStore().getExposures( - host.rootGraph.id, - String(host.id) - ) - expect(exposures).toHaveLength(1) - expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview') - }) - - it('quarantines entries whose source node has disappeared', () => { - const host = buildHost() - host.properties.proxyWidgets = [['9999', 'seed']] - - const result = flushProxyWidgetMigration({ hostNode: host }) - - expect(result.quarantined).toBe(1) - expect(readHostQuarantine(host)).toEqual([ - expect.objectContaining({ - originalEntry: ['9999', 'seed'], - reason: 'missingSourceNode' - }) - ]) - }) - - it('counts already-linked entries as repaired and applies the host value', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - const inputSlot = host.addInput('seed_link', '*') - let widgetValue: TWidgetValue = 0 - inputSlot._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - get value() { - return widgetValue - }, - set value(v: TWidgetValue) { - widgetValue = v - } - }) - - host.properties.proxyWidgets = [[String(innerNode.id), 'seed']] - const result = flushProxyWidgetMigration({ - hostNode: host, - hostWidgetValues: [99] - }) - - expect(result.repaired).toBe(1) - expect(result.quarantined).toBe(0) - expect(widgetValue).toBe(99) - }) - - it('clears properties.proxyWidgets after a successful flush', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('text', '$$canvas-image-preview', '', () => {}) - host.subgraph.add(innerNode) - - host.properties.proxyWidgets = [ - [String(innerNode.id), '$$canvas-image-preview'] - ] - - flushProxyWidgetMigration({ hostNode: host }) - - expect(host.properties.proxyWidgets).toBeUndefined() - }) - - it('runs through LGraph.configure when the flush hook is wired', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('text', '$$canvas-image-preview', '', () => {}) - host.subgraph.add(innerNode) - host.properties.proxyWidgets = [ - [String(innerNode.id), '$$canvas-image-preview'] - ] - - const serialized = host.rootGraph.serialize() - wireProxyWidgetMigrationFlush() - const reloadedGraph = new LGraph() - const subgraph = host.subgraph - const instanceData = host.serialize() - LiteGraph.registerNodeType( - subgraph.id, - class TestSubgraphNode extends SubgraphNode { - constructor() { - super(reloadedGraph, subgraph, instanceData) - } - } - ) - try { - reloadedGraph.configure(serialized) - } finally { - LiteGraph.unregisterNodeType(subgraph.id) - } - - const reloadedHost = reloadedGraph.getNodeById(host.id) - expect(reloadedHost?.properties.proxyWidgets).toBeUndefined() - expect( - usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id)) - ).toEqual([ - expect.objectContaining({ - sourceNodeId: String(innerNode.id), - sourcePreviewName: '$$canvas-image-preview' - }) - ]) - }) - - describe('idempotency', () => { - it('re-running flush over a fully migrated host produces no further mutations', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('text', '$$canvas-image-preview', '', () => {}) - host.subgraph.add(innerNode) - - host.properties.proxyWidgets = [ - [String(innerNode.id), '$$canvas-image-preview'] - ] - - const first = flushProxyWidgetMigration({ hostNode: host }) - expect(first.previewMigrated).toBe(1) - - const exposuresAfterFirst = usePreviewExposureStore() - .getExposures(host.rootGraph.id, String(host.id)) - .map((e) => ({ ...e })) - - const second = flushProxyWidgetMigration({ hostNode: host }) - - expect(second).toEqual({ - repaired: 0, - primitiveRepaired: 0, - previewMigrated: 0, - quarantined: 0 - }) - expect( - usePreviewExposureStore().getExposures( - host.rootGraph.id, - String(host.id) - ) - ).toEqual(exposuresAfterFirst) - }) - - it('re-running flush over a quarantined host does not duplicate quarantine entries', () => { - const host = buildHost() - host.properties.proxyWidgets = [['9999', 'seed']] - flushProxyWidgetMigration({ hostNode: host }) - const firstQuarantine = readHostQuarantine(host) - expect(firstQuarantine).toHaveLength(1) - - // Reseed proxyWidgets to simulate a stale legacy reload of the same - // unresolved entry; flush must still produce no duplicates. - host.properties.proxyWidgets = [['9999', 'seed']] - flushProxyWidgetMigration({ hostNode: host }) - - expect(readHostQuarantine(host)).toEqual(firstQuarantine) - }) - }) -}) diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts deleted file mode 100644 index 368c1559e9..0000000000 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure' -import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner' -import { - appendHostQuarantine, - makeQuarantineEntry -} from '@/core/graph/subgraph/migration/quarantineEntry' -import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout' -import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget' -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' -import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' -import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets' -import { usePreviewExposureStore } from '@/stores/previewExposureStore' - -interface FlushProxyWidgetMigrationArgs { - hostNode: SubgraphNode - /** widgets_values from the host node at parse time. May be sparse. */ - hostWidgetValues?: readonly unknown[] -} - -interface FlushProxyWidgetMigrationResult { - repaired: number - primitiveRepaired: number - previewMigrated: number - quarantined: number -} - -const EMPTY_RESULT: FlushProxyWidgetMigrationResult = { - repaired: 0, - primitiveRepaired: 0, - previewMigrated: 0, - quarantined: 0 -} - -function toLegacyTuple( - source: LegacyProxyEntrySource -): SerializedProxyWidgetTuple { - return source.disambiguatingSourceNodeId - ? [ - source.sourceNodeId, - source.sourceWidgetName, - source.disambiguatingSourceNodeId - ] - : [source.sourceNodeId, source.sourceWidgetName] -} - -function unwrapHostValue( - hostValue: PendingMigrationEntry['hostValue'] -): TWidgetValue | undefined { - return hostValue === HOST_VALUE_HOLE ? undefined : (hostValue as TWidgetValue) -} - -function quarantineFor( - entry: PendingMigrationEntry, - reason: ProxyWidgetErrorQuarantineEntry['reason'] -): ProxyWidgetErrorQuarantineEntry { - return makeQuarantineEntry({ - originalEntry: toLegacyTuple(entry.normalized), - reason, - hostValue: unwrapHostValue(entry.hostValue) - }) -} - -/** - * Forward-ratchet a host SubgraphNode's legacy `properties.proxyWidgets` into - * canonical representations: - * - * - value-widget entries → linked SubgraphInput via {@link repairValueWidget}; - * - primitive-fanout cohorts → one SubgraphInput per primitive via - * {@link repairPrimitiveFanout}; - * - preview entries → host-scoped exposure via {@link migratePreviewExposure}; - * - unrepairable / quarantine plans → appended to - * `properties.proxyWidgetErrorQuarantine`. - * - * Idempotent: re-running flush over an already-migrated host produces no - * mutations and no duplicates because (a) the planner classifies migrated - * entries as `alreadyLinked` (a no-op apply), (b) preview/quarantine helpers - * dedup, and (c) the legacy `properties.proxyWidgets` is removed once flush - * succeeds so subsequent calls return early. - */ -export function flushProxyWidgetMigration( - args: FlushProxyWidgetMigrationArgs -): FlushProxyWidgetMigrationResult { - const { hostNode, hostWidgetValues } = args - - const plan = planProxyWidgetMigration({ hostNode, hostWidgetValues }) - if (plan.entries.length === 0) return EMPTY_RESULT - - const previewStore = usePreviewExposureStore() - const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = [] - const result: FlushProxyWidgetMigrationResult = { ...EMPTY_RESULT } - - // Group primitive-bypass entries per primitive node. Cohort flushed - // all-or-nothing through repairPrimitiveFanout. - const primitiveCohorts = new Map() - - for (const entry of plan.entries) { - const { plan: planEntry } = entry - - if (planEntry.kind === 'primitiveBypass') { - const cohort = primitiveCohorts.get(planEntry.primitiveNodeId) ?? [] - cohort.push(entry) - primitiveCohorts.set(planEntry.primitiveNodeId, cohort) - continue - } - - if ( - planEntry.kind === 'alreadyLinked' || - planEntry.kind === 'createSubgraphInput' - ) { - const repair = repairValueWidget({ hostNode, entry }) - if (repair.ok) { - result.repaired += 1 - } else { - quarantineToAppend.push(quarantineFor(entry, repair.reason)) - } - continue - } - - if (planEntry.kind === 'previewExposure') { - const repair = migratePreviewExposure({ - hostNode, - entry, - store: previewStore - }) - if (repair.ok) { - result.previewMigrated += 1 - } else { - quarantineToAppend.push(quarantineFor(entry, repair.reason)) - } - continue - } - - if (planEntry.kind === 'quarantine') { - quarantineToAppend.push(quarantineFor(entry, planEntry.reason)) - } - } - - for (const cohort of primitiveCohorts.values()) { - const repair = repairPrimitiveFanout({ hostNode, cohort }) - if (repair.ok) { - result.primitiveRepaired += 1 - } else { - for (const entry of cohort) { - quarantineToAppend.push(quarantineFor(entry, repair.reason)) - } - } - } - - if (quarantineToAppend.length > 0) { - appendHostQuarantine(hostNode, quarantineToAppend) - result.quarantined = quarantineToAppend.length - } - - // Idempotency anchor: once entries have been processed, drop the legacy - // payload so subsequent loads/configures take the no-op short-circuit. - // Canonical state now lives on linked SubgraphInputs, the - // PreviewExposureStore, and properties.proxyWidgetErrorQuarantine. - delete hostNode.properties.proxyWidgets - - return result -} diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes.ts deleted file mode 100644 index 41b856f2b7..0000000000 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes.ts +++ /dev/null @@ -1,66 +0,0 @@ -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' - -/** - * Sentinel marking a sparse hole in a `widgets_values` array. Distinct from - * `undefined` so that an explicitly-stored `undefined` host value can still be - * represented when needed. - */ -export const HOST_VALUE_HOLE = Symbol('proxyWidgetMigration.hostValueHole') -export type HostValueHole = typeof HOST_VALUE_HOLE - -export type HostValue = TWidgetValue | HostValueHole - -export type ProxyEntryClassification = - | 'value' - | 'preview' - | 'primitiveFanout' - | 'unknown' - -export interface PrimitiveBypassTargetRef { - targetNodeId: NodeId - targetSlot: number -} - -export type MigrationPlan = - | { kind: 'alreadyLinked'; subgraphInputName: string } - | { kind: 'createSubgraphInput'; sourceWidgetName: string } - | { - kind: 'primitiveBypass' - primitiveNodeId: NodeId - sourceWidgetName: string - targets: readonly PrimitiveBypassTargetRef[] - } - | { kind: 'previewExposure'; sourcePreviewName: string } - | { kind: 'quarantine'; reason: ProxyWidgetQuarantineReason } - -/** - * One pending migration entry produced by the planner. - * - * @remarks - * This is the input to the flush step. The planner does not mutate the graph; - * it walks legacy `properties.proxyWidgets` and `widgets_values`, classifies - * each entry, and emits a {@link PendingMigrationEntry} describing what the - * flush should do. Flush re-validates against the current graph before - * applying mutations. - */ -export interface PendingMigrationEntry { - normalized: LegacyProxyEntrySource - legacyOrderIndex: number - hostValue: HostValue - plan: MigrationPlan -} - -/** - * The full plan the planner returns for a single host SubgraphNode. - * - * Entries are ordered by `legacyOrderIndex` ascending. Idempotency: re-running - * the planner over a host whose canonical state already represents an entry - * yields a `'alreadyLinked'`/`'previewExposure'` plan that the flush step - * treats as a no-op. - */ -export interface ProxyWidgetMigrationPlan { - entries: readonly PendingMigrationEntry[] -} diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.test.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.test.ts deleted file mode 100644 index 6b0403e0cf..0000000000 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { setActivePinia } from 'pinia' -import { fromPartial } from '@total-typescript/shoehorn' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner' -import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({}) -})) -vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: () => ({ updatePreviews: () => ({}) }) -})) - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() -}) - -function buildHost(): SubgraphNode { - const subgraph = createTestSubgraph() - const hostNode = createTestSubgraphNode(subgraph) - const graph = hostNode.graph! - graph.add(hostNode) - return hostNode -} - -function findEntry( - entries: readonly PendingMigrationEntry[], - index: number -): PendingMigrationEntry { - const entry = entries.find((e) => e.legacyOrderIndex === index) - if (!entry) throw new Error(`Expected entry at legacyOrderIndex ${index}`) - return entry -} - -describe(planProxyWidgetMigration, () => { - it('returns an empty plan when properties.proxyWidgets is missing', () => { - const host = buildHost() - - const plan = planProxyWidgetMigration({ hostNode: host }) - - expect(plan.entries).toEqual([]) - }) - - it('tolerates a malformed proxyWidgets JSON string and returns empty', () => { - const host = buildHost() - host.properties.proxyWidgets = '{not json}' - - const plan = planProxyWidgetMigration({ hostNode: host }) - - expect(plan.entries).toEqual([]) - }) - - it('emits classified entries for a mixed value+preview cohort, preserving order', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - innerNode.addWidget('text', '$$canvas-image-preview', '', () => {}) - host.subgraph.add(innerNode) - - host.properties.proxyWidgets = [ - [String(innerNode.id), 'seed'], - [String(innerNode.id), '$$canvas-image-preview'] - ] - - const plan = planProxyWidgetMigration({ - hostNode: host, - hostWidgetValues: [99] - }) - - expect(plan.entries).toHaveLength(2) - const valueEntry = findEntry(plan.entries, 0) - expect(valueEntry.plan).toEqual({ - kind: 'createSubgraphInput', - sourceWidgetName: 'seed' - }) - expect(valueEntry.hostValue).toBe(99) - - const previewEntry = findEntry(plan.entries, 1) - expect(previewEntry.plan).toEqual({ - kind: 'previewExposure', - sourcePreviewName: '$$canvas-image-preview' - }) - expect(previewEntry.hostValue).toBe(HOST_VALUE_HOLE) - }) - - it('preserves sparse holes in widgets_values when they are missing', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'a', 0, () => {}) - innerNode.addWidget('number', 'b', 0, () => {}) - host.subgraph.add(innerNode) - - host.properties.proxyWidgets = [ - [String(innerNode.id), 'a'], - [String(innerNode.id), 'b'] - ] - - const sparse: unknown[] = [] - sparse[1] = 'second-value' - - const plan = planProxyWidgetMigration({ - hostNode: host, - hostWidgetValues: sparse - }) - - expect(findEntry(plan.entries, 0).hostValue).toBe(HOST_VALUE_HOLE) - expect(findEntry(plan.entries, 1).hostValue).toBe('second-value') - }) - - it('emits a primitiveBypass plan per cohort entry pointing at the same primitive', () => { - const host = buildHost() - const primitive = new LGraphNode('Primitive') - primitive.type = 'PrimitiveNode' - primitive.addOutput('value', 'INT') - host.subgraph.add(primitive) - - const targetA = new LGraphNode('TargetA') - targetA.addInput('value', 'INT') - targetA.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(targetA) - - const targetB = new LGraphNode('TargetB') - targetB.addInput('value', 'INT') - targetB.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(targetB) - - primitive.connect(0, targetA, 0) - primitive.connect(0, targetB, 0) - - host.properties.proxyWidgets = [ - [String(primitive.id), 'value'], - [String(primitive.id), 'value'] - ] - - const plan = planProxyWidgetMigration({ hostNode: host }) - - expect(plan.entries).toHaveLength(2) - for (const entry of plan.entries) { - expect(entry.plan.kind).toBe('primitiveBypass') - if (entry.plan.kind !== 'primitiveBypass') continue - expect(entry.plan.primitiveNodeId).toBe(primitive.id) - expect(entry.plan.targets).toHaveLength(2) - } - }) - - it('is idempotent: re-running on a host whose entries are already linked yields alreadyLinked plans', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - host.properties.proxyWidgets = [[String(innerNode.id), 'seed']] - const firstPass = planProxyWidgetMigration({ - hostNode: host, - hostWidgetValues: [42] - }) - - expect(findEntry(firstPass.entries, 0).plan).toEqual({ - kind: 'createSubgraphInput', - sourceWidgetName: 'seed' - }) - - // Simulate the flush step linking the input. - const inputSlot = host.addInput('seed', '*') - inputSlot._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed' - }) - - const secondPass = planProxyWidgetMigration({ - hostNode: host, - hostWidgetValues: [42] - }) - - expect(secondPass.entries).toHaveLength(1) - expect(findEntry(secondPass.entries, 0).plan).toEqual({ - kind: 'alreadyLinked', - subgraphInputName: 'seed' - }) - }) - - it('quarantines entries pointing at missing source nodes', () => { - const host = buildHost() - host.properties.proxyWidgets = [['9999', 'seed']] - - const plan = planProxyWidgetMigration({ hostNode: host }) - - expect(plan.entries).toHaveLength(1) - expect(findEntry(plan.entries, 0).plan).toEqual({ - kind: 'quarantine', - reason: 'missingSourceNode' - }) - }) -}) diff --git a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.ts b/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.ts deleted file mode 100644 index 02c6a73d9f..0000000000 --- a/src/core/graph/subgraph/migration/proxyWidgetMigrationPlanner.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry' -import type { - HostValue, - PendingMigrationEntry, - ProxyWidgetMigrationPlan -} 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 { 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' - -interface PlanProxyWidgetMigrationArgs { - hostNode: SubgraphNode - /** widgets_values from the host node at parse time. May be sparse. */ - hostWidgetValues?: readonly unknown[] -} - -function pickHostValue( - hostWidgetValues: readonly unknown[] | undefined, - index: number -): HostValue { - if (!hostWidgetValues) return HOST_VALUE_HOLE - if (index < 0 || index >= hostWidgetValues.length) return HOST_VALUE_HOLE - if (!Object.prototype.hasOwnProperty.call(hostWidgetValues, index)) { - return HOST_VALUE_HOLE - } - return hostWidgetValues[index] as TWidgetValue -} - -export function planProxyWidgetMigration( - args: PlanProxyWidgetMigrationArgs -): ProxyWidgetMigrationPlan { - const { hostNode, hostWidgetValues } = args - - const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets) - if (tuples.length === 0) return { entries: [] } - - const normalized: LegacyProxyEntrySource[] = tuples.map( - ([sourceNodeId, sourceWidgetName, disambiguator]) => - normalizeLegacyProxyWidgetEntry( - hostNode, - sourceNodeId, - sourceWidgetName, - disambiguator - ) - ) - - const entries: PendingMigrationEntry[] = normalized.map( - (entry, legacyOrderIndex) => { - const { plan } = classifyProxyEntry({ - hostNode, - normalized: entry, - cohort: normalized - }) - return { - normalized: entry, - legacyOrderIndex, - hostValue: pickHostValue(hostWidgetValues, legacyOrderIndex), - plan - } - } - ) - - entries.sort((a, b) => a.legacyOrderIndex - b.legacyOrderIndex) - - return { entries } -} diff --git a/src/core/graph/subgraph/migration/quarantineEntry.test.ts b/src/core/graph/subgraph/migration/quarantineEntry.test.ts deleted file mode 100644 index 201b04fb23..0000000000 --- a/src/core/graph/subgraph/migration/quarantineEntry.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import { - appendHostQuarantine, - clearHostQuarantine, - makeQuarantineEntry, - readHostQuarantine -} from '@/core/graph/subgraph/migration/quarantineEntry' -import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema' - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({}) -})) -vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: () => ({ updatePreviews: () => ({}) }) -})) - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() -}) - -function buildHost(): SubgraphNode { - const subgraph = createTestSubgraph() - const hostNode = createTestSubgraphNode(subgraph) - const graph = hostNode.graph! - graph.add(hostNode) - return hostNode -} - -describe(makeQuarantineEntry, () => { - it('builds an entry with attemptedAtVersion pinned to 1', () => { - const tuple: SerializedProxyWidgetTuple = ['7', 'seed'] - - const entry = makeQuarantineEntry({ - originalEntry: tuple, - reason: 'missingSourceNode' - }) - - expect(entry).toEqual({ - originalEntry: tuple, - reason: 'missingSourceNode', - attemptedAtVersion: 1 - }) - }) - - it('includes hostValue when provided', () => { - const tuple: SerializedProxyWidgetTuple = ['7', 'seed'] - - const entry = makeQuarantineEntry({ - originalEntry: tuple, - reason: 'missingSourceNode', - hostValue: 42 - }) - - expect(entry.hostValue).toBe(42) - }) -}) - -describe('host quarantine helpers', () => { - it('returns an empty array for an unconfigured host', () => { - const host = buildHost() - - expect(readHostQuarantine(host)).toEqual([]) - }) - - it('round-trips entries via append + read', () => { - const host = buildHost() - const entry = makeQuarantineEntry({ - originalEntry: ['7', 'seed'], - reason: 'missingSourceWidget', - hostValue: 'preserved' - }) - - appendHostQuarantine(host, [entry]) - - expect(readHostQuarantine(host)).toEqual([entry]) - }) - - it('deduplicates entries with identical originalEntry tuples', () => { - const host = buildHost() - const tuple: SerializedProxyWidgetTuple = ['7', 'seed'] - const first = makeQuarantineEntry({ - originalEntry: tuple, - reason: 'missingSourceWidget', - hostValue: 1 - }) - const duplicate = makeQuarantineEntry({ - originalEntry: tuple, - reason: 'unlinkedSourceWidget', - hostValue: 2 - }) - - appendHostQuarantine(host, [first]) - appendHostQuarantine(host, [duplicate]) - - const stored = readHostQuarantine(host) - expect(stored).toHaveLength(1) - expect(stored[0]).toEqual(first) - }) - - it('keeps entries that differ by disambiguator in the originalEntry tuple', () => { - const host = buildHost() - const baseEntry = makeQuarantineEntry({ - originalEntry: ['7', 'seed'], - reason: 'missingSourceWidget' - }) - const disambiguatedEntry = makeQuarantineEntry({ - originalEntry: ['7', 'seed', 'inner-leaf'], - reason: 'missingSourceWidget' - }) - - appendHostQuarantine(host, [baseEntry, disambiguatedEntry]) - - expect(readHostQuarantine(host)).toHaveLength(2) - }) - - it('clearHostQuarantine removes the property entirely', () => { - const host = buildHost() - appendHostQuarantine(host, [ - makeQuarantineEntry({ - originalEntry: ['7', 'seed'], - reason: 'missingSourceWidget' - }) - ]) - - clearHostQuarantine(host) - - expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined() - expect(readHostQuarantine(host)).toEqual([]) - }) - - it('appendHostQuarantine is a no-op when given an empty list', () => { - const host = buildHost() - - appendHostQuarantine(host, []) - - expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined() - }) -}) diff --git a/src/core/graph/subgraph/migration/quarantineEntry.ts b/src/core/graph/subgraph/migration/quarantineEntry.ts deleted file mode 100644 index eee7a5bdda..0000000000 --- a/src/core/graph/subgraph/migration/quarantineEntry.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { isEqual } from 'es-toolkit/compat' - -import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema' -import type { - ProxyWidgetErrorQuarantineEntry, - ProxyWidgetQuarantineReason -} from '@/core/schemas/proxyWidgetQuarantineSchema' -import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema' -import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' -import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets' - -const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine' -const QUARANTINE_VERSION = 1 - -interface MakeQuarantineEntryArgs { - originalEntry: SerializedProxyWidgetTuple - reason: ProxyWidgetQuarantineReason - hostValue?: TWidgetValue -} - -export function readHostQuarantine( - hostNode: SubgraphNode -): ProxyWidgetErrorQuarantineEntry[] { - return parseProxyWidgetErrorQuarantine( - hostNode.properties[QUARANTINE_PROPERTY] - ) -} - -export function makeQuarantineEntry( - args: MakeQuarantineEntryArgs -): ProxyWidgetErrorQuarantineEntry { - const entry: ProxyWidgetErrorQuarantineEntry = { - originalEntry: args.originalEntry, - reason: args.reason, - attemptedAtVersion: QUARANTINE_VERSION - } - if (args.hostValue !== undefined) { - entry.hostValue = args.hostValue - } - return entry -} - -export function appendHostQuarantine( - hostNode: SubgraphNode, - entries: readonly ProxyWidgetErrorQuarantineEntry[] -): void { - if (entries.length === 0) return - - const existing = readHostQuarantine(hostNode) - const merged = [...existing] - for (const candidate of entries) { - const isDuplicate = merged.some((existingEntry) => - isEqual(existingEntry.originalEntry, candidate.originalEntry) - ) - if (!isDuplicate) merged.push(candidate) - } - - if (merged.length === 0) { - delete hostNode.properties[QUARANTINE_PROPERTY] - return - } - hostNode.properties[QUARANTINE_PROPERTY] = merged -} - -export function clearHostQuarantine(hostNode: SubgraphNode): void { - delete hostNode.properties[QUARANTINE_PROPERTY] -} diff --git a/src/core/graph/subgraph/migration/repairPrimitiveFanout.test.ts b/src/core/graph/subgraph/migration/repairPrimitiveFanout.test.ts deleted file mode 100644 index 04732a63d9..0000000000 --- a/src/core/graph/subgraph/migration/repairPrimitiveFanout.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout' - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({}) -})) -vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: () => ({ updatePreviews: () => ({}) }) -})) - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() -}) - -interface PrimitiveScenario { - host: SubgraphNode - primitive: LGraphNode - targets: LGraphNode[] -} - -function buildPrimitiveScenario(targetCount: number): PrimitiveScenario { - const subgraph = createTestSubgraph() - const host = createTestSubgraphNode(subgraph) - host.graph!.add(host) - - const primitive = new LGraphNode('PrimitiveNode') - primitive.type = 'PrimitiveNode' - primitive.addOutput('value', 'INT') - primitive.addWidget('number', 'value', 42, () => {}) - subgraph.add(primitive) - - const targets: LGraphNode[] = [] - for (let i = 0; i < targetCount; i++) { - const target = new LGraphNode(`Target${i}`) - const slot = target.addInput('value', 'INT') - slot.widget = { name: 'value' } - target.addWidget('number', 'value', 0, () => {}) - subgraph.add(target) - primitive.connect(0, target, 0) - targets.push(target) - } - - return { host, primitive, targets } -} - -function buildCohort( - primitive: LGraphNode, - targets: readonly LGraphNode[], - options: { - hostValuePerEntry?: readonly PendingMigrationEntry['hostValue'][] - } = {} -): PendingMigrationEntry[] { - return targets.map((target, index) => ({ - normalized: { - sourceNodeId: String(primitive.id), - sourceWidgetName: 'value', - // Distinguish entries by the downstream target so coalesce keeps each. - disambiguatingSourceNodeId: String(target.id) - }, - legacyOrderIndex: index, - hostValue: Object.prototype.hasOwnProperty.call( - options.hostValuePerEntry ?? [], - index - ) - ? options.hostValuePerEntry![index] - : HOST_VALUE_HOLE, - plan: { - kind: 'primitiveBypass', - primitiveNodeId: primitive.id, - sourceWidgetName: 'value', - targets: targets.map((t) => ({ - targetNodeId: t.id, - targetSlot: 0 - })) - } - })) -} - -describe(repairPrimitiveFanout, () => { - it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => { - const { host, primitive, targets } = buildPrimitiveScenario(3) - const cohort = buildCohort(primitive, targets) - - const subgraphInputCountBefore = host.subgraph.inputs.length - const result = repairPrimitiveFanout({ hostNode: host, cohort }) - - expect(result.ok).toBe(true) - if (!result.ok) return - expect(result.reconnectCount).toBe(3) - expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1) - // After mutation each target's slot should no longer be linked to the primitive. - for (const target of targets) { - const slot = target.inputs[0] - expect(slot.link).not.toBeNull() - const link = host.subgraph.links.get(slot.link!) - expect(link?.origin_id).not.toBe(primitive.id) - } - }) - - it('host value (first by legacyOrderIndex) wins over primitive widget value', () => { - const { host, primitive, targets } = buildPrimitiveScenario(2) - const primitiveWidget = primitive.widgets!.find((w) => w.name === 'value')! - primitiveWidget.value = 11 - - const cohort = buildCohort(primitive, targets, { - hostValuePerEntry: [123, 456] - }) - - const result = repairPrimitiveFanout({ hostNode: host, cohort }) - - expect(result.ok).toBe(true) - if (!result.ok) return - const created = host.subgraph.inputs.find( - (i) => i.name === result.subgraphInputName - ) - expect(created?._widget?.value).toBe(123) - }) - - it('preserves an explicit undefined host value instead of falling back to primitive value', () => { - const { host, primitive, targets } = buildPrimitiveScenario(2) - const primitiveWidget = primitive.widgets!.find((w) => w.name === 'value')! - primitiveWidget.value = 11 - - const cohort = buildCohort(primitive, targets, { - hostValuePerEntry: [undefined, 456] - }) - - const result = repairPrimitiveFanout({ hostNode: host, cohort }) - - expect(result.ok).toBe(true) - if (!result.ok) return - const created = host.subgraph.inputs.find( - (i) => i.name === result.subgraphInputName - ) - expect(created?._widget?.value).toBeUndefined() - }) - - it('coalesces duplicate entries that share normalized source', () => { - const { host, primitive, targets } = buildPrimitiveScenario(2) - const cohort = buildCohort(primitive, targets) - - // Append an exact duplicate of the first cohort entry. - cohort.push({ ...cohort[0], legacyOrderIndex: 99 }) - - const result = repairPrimitiveFanout({ hostNode: host, cohort }) - - expect(result.ok).toBe(true) - if (!result.ok) return - // 2 unique targets → 2 reconnects regardless of duplicate cohort entries. - expect(result.reconnectCount).toBe(2) - }) - - it('returns primitiveBypassFailed when a target slot type is incompatible', () => { - const { host, primitive, targets } = buildPrimitiveScenario(1) - // Replace the existing target slot type with something incompatible. - targets[0].inputs[0].type = 'STRING' - - const cohort = buildCohort(primitive, targets) - const subgraphInputCountBefore = host.subgraph.inputs.length - - const result = repairPrimitiveFanout({ hostNode: host, cohort }) - - expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' }) - // No new SubgraphInput created. - expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore) - }) - - it('returns primitiveBypassFailed for an empty cohort', () => { - const { host } = buildPrimitiveScenario(0) - const result = repairPrimitiveFanout({ hostNode: host, cohort: [] }) - - expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' }) - }) -}) diff --git a/src/core/graph/subgraph/migration/repairPrimitiveFanout.ts b/src/core/graph/subgraph/migration/repairPrimitiveFanout.ts deleted file mode 100644 index 08abc50d5d..0000000000 --- a/src/core/graph/subgraph/migration/repairPrimitiveFanout.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { isEqual } from 'es-toolkit/compat' - -import type { - PendingMigrationEntry, - HostValue, - PrimitiveBypassTargetRef -} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' -import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph' -import { nextUniqueName } from '@/lib/litegraph/src/strings' -import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' -import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' -import type { - IBaseWidget, - TWidgetValue -} from '@/lib/litegraph/src/types/widgets' - -type RepairPrimitiveFanoutResult = - | { ok: true; subgraphInputName: string; reconnectCount: number } - | { ok: false; reason: 'primitiveBypassFailed' } - -interface RepairPrimitiveFanoutArgs { - hostNode: SubgraphNode - /** All cohort entries whose plan is `primitiveBypass` for this primitive. */ - cohort: readonly PendingMigrationEntry[] -} - -const PRIMITIVE_NODE_TYPE = 'PrimitiveNode' -const FAILED: RepairPrimitiveFanoutResult = { - ok: false, - reason: 'primitiveBypassFailed' -} - -interface SnapshotLink { - primitiveSlot: number - targetNodeId: NodeId - targetSlot: number -} - -function fail(message: string, context?: unknown): RepairPrimitiveFanoutResult { - console.warn(`[repairPrimitiveFanout] ${message}`, context) - return FAILED -} - -interface CohortValidationOk { - ok: true - primitiveNodeId: NodeId - sourceWidgetName: string - uniqueEntries: readonly PendingMigrationEntry[] -} - -function validateCohort( - cohort: readonly PendingMigrationEntry[] -): CohortValidationOk | { ok: false } { - if (cohort.length === 0) return { ok: false } - - const first = cohort[0] - if (first.plan.kind !== 'primitiveBypass') return { ok: false } - - const primitiveNodeId = first.plan.primitiveNodeId - const sourceWidgetName = first.plan.sourceWidgetName - - for (const entry of cohort) { - if (entry.plan.kind !== 'primitiveBypass') return { ok: false } - if (entry.plan.primitiveNodeId !== primitiveNodeId) return { ok: false } - if (entry.plan.sourceWidgetName !== sourceWidgetName) return { ok: false } - } - - // Coalesce exact duplicates by `normalized`. - const uniqueEntries: PendingMigrationEntry[] = [] - for (const entry of cohort) { - if ( - !uniqueEntries.some((kept) => isEqual(kept.normalized, entry.normalized)) - ) { - uniqueEntries.push(entry) - } - } - - return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries } -} - -function pickBaseName( - primitiveNode: LGraphNode, - sourceWidgetName: string -): string { - // Heuristic: a user-renamed PrimitiveNode title differs from its default - // 'PrimitiveNode' label. When unrenamed, fall back to the source widget name. - if (primitiveNode.title && primitiveNode.title !== PRIMITIVE_NODE_TYPE) { - return primitiveNode.title - } - return sourceWidgetName -} - -function collectTargets( - hostNode: SubgraphNode, - primitiveNode: LGraphNode -): PrimitiveBypassTargetRef[] | undefined { - const subgraph = hostNode.subgraph - const output = primitiveNode.outputs?.[0] - const linkIds = output?.links ?? [] - const targets: PrimitiveBypassTargetRef[] = [] - for (const linkId of linkIds) { - const link = subgraph.links.get(linkId) - if (!link) return undefined - targets.push({ - targetNodeId: link.target_id, - targetSlot: link.target_slot - }) - } - return targets -} - -function snapshotLinksForRollback( - hostNode: SubgraphNode, - primitiveNode: LGraphNode -): SnapshotLink[] { - const subgraph = hostNode.subgraph - const output = primitiveNode.outputs?.[0] - const linkIds = output?.links ?? [] - const snapshot: SnapshotLink[] = [] - for (const linkId of linkIds) { - const link = subgraph.links.get(linkId) - if (!link) continue - snapshot.push({ - primitiveSlot: link.origin_slot, - targetNodeId: link.target_id, - targetSlot: link.target_slot - }) - } - return snapshot -} - -function rollback( - hostNode: SubgraphNode, - primitiveNode: LGraphNode, - newSubgraphInput: SubgraphInput | undefined, - snapshot: readonly SnapshotLink[] -): void { - if (newSubgraphInput) { - try { - hostNode.subgraph.removeInput(newSubgraphInput) - } catch (e) { - console.warn('[repairPrimitiveFanout] rollback removeInput failed', e) - } - } - for (const link of snapshot) { - const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId) - if (!targetNode) continue - primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot) - } -} - -function pickHostValue( - uniqueEntries: readonly PendingMigrationEntry[] -): HostValue { - const ordered = [...uniqueEntries].sort( - (a, b) => a.legacyOrderIndex - b.legacyOrderIndex - ) - for (const entry of ordered) { - if (entry.hostValue !== HOST_VALUE_HOLE) return entry.hostValue - } - return HOST_VALUE_HOLE -} - -function applyHostValue(widget: IBaseWidget, hostValue: HostValue): void { - if (hostValue === HOST_VALUE_HOLE) return - if ( - isPromotedWidgetView(widget) && - typeof widget.hydrateHostValue === 'function' - ) { - widget.hydrateHostValue(hostValue) - return - } - widget.value = hostValue as TWidgetValue -} - -/** - * All-or-quarantine repair of one primitive's fan-out into a single - * SubgraphInput. - * - * Each call repairs ONE primitive node and the cohort of legacy entries that - * pointed at it. On any failure during validation or mutation, the helper - * rolls back any partial changes and returns - * `{ ok: false, reason: 'primitiveBypassFailed' }` so the caller can - * quarantine all cohort entries. - */ -export function repairPrimitiveFanout( - args: RepairPrimitiveFanoutArgs -): RepairPrimitiveFanoutResult { - const { hostNode, cohort } = args - - const validated = validateCohort(cohort) - if (!validated.ok) return fail('cohort validation failed', { cohort }) - - const subgraph = hostNode.subgraph - const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId) - if (!primitiveNode) { - return fail('primitive node missing', { - primitiveNodeId: validated.primitiveNodeId - }) - } - if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) { - return fail('node is not a PrimitiveNode', { - primitiveNodeId: validated.primitiveNodeId, - type: primitiveNode.type - }) - } - - const targets = collectTargets(hostNode, primitiveNode) - if (!targets || targets.length === 0) { - return fail('no targets to reconnect', { - primitiveNodeId: validated.primitiveNodeId - }) - } - - const primitiveOutput = primitiveNode.outputs?.[0] - if (!primitiveOutput) return fail('primitive has no output') - const primitiveOutputType = String(primitiveOutput.type ?? '*') - - // Pre-validate compatibility of every target before mutating. - for (const target of targets) { - const targetNode = subgraph.getNodeById(target.targetNodeId) - if (!targetNode) return fail('target node missing', target) - const targetSlot = targetNode.inputs?.[target.targetSlot] - if (!targetSlot) return fail('target slot missing', target) - const targetType = String(targetSlot.type ?? '*') - if ( - targetType !== primitiveOutputType && - targetType !== '*' && - primitiveOutputType !== '*' - ) { - return fail('target slot type incompatible', { - target, - targetType, - primitiveOutputType - }) - } - } - - const baseName = pickBaseName(primitiveNode, validated.sourceWidgetName) - const existingNames = subgraph.inputs.map((input) => input.name) - const uniqueName = nextUniqueName(baseName, existingNames) - - const snapshot = snapshotLinksForRollback(hostNode, primitiveNode) - - let newSubgraphInput: SubgraphInput | undefined - try { - newSubgraphInput = subgraph.addInput(uniqueName, primitiveOutputType) - - // Disconnect every former primitive→target link. - for (const snap of snapshot) { - const targetNode = subgraph.getNodeById(snap.targetNodeId) - if (!targetNode) - throw new Error( - `target node ${snap.targetNodeId} disappeared mid-mutation` - ) - targetNode.disconnectInput(snap.targetSlot, false) - } - - // Reconnect each target slot from the new SubgraphInput, in target order. - for (const target of targets) { - const targetNode = subgraph.getNodeById(target.targetNodeId) - if (!targetNode) - throw new Error(`target node ${target.targetNodeId} disappeared`) - const targetSlot = targetNode.inputs?.[target.targetSlot] - if (!targetSlot) - throw new Error(`target slot ${target.targetSlot} disappeared`) - const link = newSubgraphInput.connect(targetSlot, targetNode) - if (!link) { - throw new Error('SubgraphInput.connect returned no link') - } - } - } catch (e) { - rollback(hostNode, primitiveNode, newSubgraphInput, snapshot) - return fail('mutation failed; rolled back', { error: e }) - } - - // Apply value: prefer first-by-legacyOrderIndex non-hole host value; - // otherwise seed from the primitive's source widget value if present. - const hostValue = pickHostValue(validated.uniqueEntries) - if (hostValue !== HOST_VALUE_HOLE) { - if (newSubgraphInput._widget) - applyHostValue(newSubgraphInput._widget, hostValue) - } else { - const primitiveValue = primitiveNode.widgets?.find( - (w) => w.name === validated.sourceWidgetName - )?.value as TWidgetValue | undefined - if (primitiveValue !== undefined && newSubgraphInput._widget) { - newSubgraphInput._widget.value = primitiveValue - } - } - - return { - ok: true, - subgraphInputName: newSubgraphInput.name, - reconnectCount: targets.length - } -} diff --git a/src/core/graph/subgraph/migration/repairValueWidget.test.ts b/src/core/graph/subgraph/migration/repairValueWidget.test.ts deleted file mode 100644 index 0b6d159ec4..0000000000 --- a/src/core/graph/subgraph/migration/repairValueWidget.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { fromPartial } from '@total-typescript/shoehorn' -import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { SubgraphNode } from '@/lib/litegraph/src/litegraph' -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget' -import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({}) -})) -vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: () => ({ updatePreviews: () => ({}) }) -})) - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() -}) - -function buildHost(): SubgraphNode { - const subgraph = createTestSubgraph() - const hostNode = createTestSubgraphNode(subgraph) - const graph = hostNode.graph! - graph.add(hostNode) - return hostNode -} - -function buildEntry(args: { - sourceNodeId: string - sourceWidgetName: string - disambiguatingSourceNodeId?: string - plan: PendingMigrationEntry['plan'] - hostValue?: PendingMigrationEntry['hostValue'] -}): PendingMigrationEntry { - return { - normalized: { - sourceNodeId: args.sourceNodeId, - sourceWidgetName: args.sourceWidgetName, - ...(args.disambiguatingSourceNodeId && { - disambiguatingSourceNodeId: args.disambiguatingSourceNodeId - }) - }, - legacyOrderIndex: 0, - hostValue: args.hostValue ?? HOST_VALUE_HOLE, - plan: args.plan - } -} - -describe(repairValueWidget, () => { - describe('alreadyLinked plan', () => { - it('hydrates real promoted widget host state without mutating the interior widget', () => { - const subgraph = createTestSubgraph({ - inputs: [{ name: 'seed', type: 'INT' }] - }) - const host = createTestSubgraphNode(subgraph) - host.graph!.add(host) - const innerNode = new LGraphNode('Inner') - const slot = innerNode.addInput('seed', 'INT') - const innerWidget = innerNode.addWidget('number', 'seed', 0, () => {}) - slot.widget = { name: innerWidget.name } - subgraph.add(innerNode) - subgraph.inputNode.slots[0].connect(slot, innerNode) - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - plan: { kind: 'alreadyLinked', subgraphInputName: 'seed' }, - hostValue: 99 - }) - }) - - expect(result).toEqual({ ok: true, subgraphInputName: 'seed' }) - expect(host.widgets[0].value).toBe(99) - expect(innerWidget.value).toBe(0) - }) - - it('applies host value to the linked input widget (host wins over interior)', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - const inputSlot = host.addInput('seed_link', '*') - inputSlot._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - value: 7 - }) - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' }, - hostValue: 99 - }) - }) - - expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' }) - expect(inputSlot._widget?.value).toBe(99) - }) - - it('leaves widget value unchanged when hostValue is HOST_VALUE_HOLE', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - const inputSlot = host.addInput('seed_link', '*') - inputSlot._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - value: 7 - }) - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' } - }) - }) - - expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' }) - expect(inputSlot._widget?.value).toBe(7) - }) - - 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, () => {}) - host.subgraph.add(innerNode) - - const firstInput = host.addInput('first_seed', '*') - firstInput._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - value: 1 - }) - const secondInput = host.addInput('second_seed', '*') - secondInput._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - value: 2 - }) - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - disambiguatingSourceNodeId: 'second', - plan: { kind: 'alreadyLinked', subgraphInputName: 'second_seed' }, - hostValue: 99 - }) - }) - - expect(result).toEqual({ ok: true, subgraphInputName: 'second_seed' }) - expect(firstInput._widget?.value).toBe(1) - expect(secondInput._widget?.value).toBe(99) - }) - - it('does not apply host value when already-linked inputs are ambiguous', () => { - 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', - value: 1 - }) - const secondInput = host.addInput('second_seed', '*') - secondInput._widget = fromPartial({ - node: host, - name: 'seed', - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - value: 2 - }) - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - plan: { - kind: 'alreadyLinked', - subgraphInputName: undefined as never - }, - hostValue: 99 - }) - }) - - expect(result).toEqual({ ok: false, reason: 'ambiguousSubgraphInput' }) - expect(firstInput._widget?.value).toBe(1) - expect(secondInput._widget?.value).toBe(2) - }) - - it('returns missingSubgraphInput when the linked SubgraphInput is gone', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' } - }) - }) - - expect(result).toEqual({ ok: false, reason: 'missingSubgraphInput' }) - }) - }) - - describe('createSubgraphInput plan', () => { - it('creates exactly one new SubgraphInput linked to the source widget', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - const slot = innerNode.addInput('seed', 'INT') - slot.widget = { name: 'seed' } - innerNode.addWidget('number', 'seed', 0, () => {}) - host.subgraph.add(innerNode) - - const inputCountBefore = host.subgraph.inputs.length - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'seed', - plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' } - }) - }) - - expect(result.ok).toBe(true) - expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1) - const created = host.subgraph.inputs.at(-1) - expect(created?._widget).toBeDefined() - if (result.ok) { - expect(result.subgraphInputName).toBe(created?.name) - } - }) - - it('returns missingSourceNode when the source node is absent', () => { - const host = buildHost() - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: '999', - sourceWidgetName: 'seed', - plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' } - }) - }) - - expect(result).toEqual({ ok: false, reason: 'missingSourceNode' }) - }) - - it('returns missingSourceWidget when the widget is absent on the source node', () => { - const host = buildHost() - const innerNode = new LGraphNode('Inner') - host.subgraph.add(innerNode) - - const result = repairValueWidget({ - hostNode: host, - entry: buildEntry({ - sourceNodeId: String(innerNode.id), - sourceWidgetName: 'nonexistent', - plan: { - kind: 'createSubgraphInput', - sourceWidgetName: 'nonexistent' - } - }) - }) - - expect(result).toEqual({ ok: false, reason: 'missingSourceWidget' }) - }) - }) - - describe('invalid plan kind', () => { - it('throws on unsupported plan kinds', () => { - const host = buildHost() - const entry = buildEntry({ - sourceNodeId: '7', - sourceWidgetName: 'seed', - plan: { kind: 'quarantine', reason: 'missingSourceNode' } - }) - - expect(() => repairValueWidget({ hostNode: host, entry })).toThrow( - /invalid plan kind/ - ) - }) - }) -}) diff --git a/src/core/graph/subgraph/migration/repairValueWidget.ts b/src/core/graph/subgraph/migration/repairValueWidget.ts deleted file mode 100644 index 9efce466a9..0000000000 --- a/src/core/graph/subgraph/migration/repairValueWidget.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes' -import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' -import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { nextUniqueName } from '@/lib/litegraph/src/strings' -import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' -import type { - IBaseWidget, - TWidgetValue -} from '@/lib/litegraph/src/types/widgets' - -import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' - -type RepairValueWidgetResult = - | { ok: true; subgraphInputName: string } - | { ok: false; reason: ProxyWidgetQuarantineReason } - -interface RepairValueWidgetArgs { - hostNode: SubgraphNode - entry: PendingMigrationEntry -} - -function findHostInputForLinkedSource( - hostNode: SubgraphNode, - sourceNodeId: string, - sourceWidgetName: string, - subgraphInputName: string | undefined -): - | { kind: 'none' } - | { kind: 'one'; input: INodeInputSlot } - | { kind: 'ambiguous' } { - const candidates = subgraphInputName - ? hostNode.inputs.filter((input) => input.name === subgraphInputName) - : hostNode.inputs - - const matches = candidates.filter((input) => { - const widget = input._widget - if (!widget || !isPromotedWidgetView(widget)) return false - return ( - widget.sourceNodeId === sourceNodeId && - widget.sourceWidgetName === sourceWidgetName - ) - }) - if (matches.length === 0) return { kind: 'none' } - if (matches.length === 1) return { kind: 'one', input: matches[0] } - return { kind: 'ambiguous' } -} - -function applyHostValue( - widget: IBaseWidget, - hostValue: PendingMigrationEntry['hostValue'] -): void { - if (hostValue === HOST_VALUE_HOLE) return - if ( - isPromotedWidgetView(widget) && - typeof widget.hydrateHostValue === 'function' - ) { - widget.hydrateHostValue(hostValue) - return - } - widget.value = hostValue as TWidgetValue -} - -function repairAlreadyLinked( - hostNode: SubgraphNode, - entry: PendingMigrationEntry -): RepairValueWidgetResult { - const hostInput = findHostInputForLinkedSource( - hostNode, - entry.normalized.sourceNodeId, - entry.normalized.sourceWidgetName, - entry.plan.kind === 'alreadyLinked' - ? entry.plan.subgraphInputName - : undefined - ) - if (hostInput.kind === 'ambiguous') { - return { ok: false, reason: 'ambiguousSubgraphInput' } - } - if (hostInput.kind === 'none' || !hostInput.input._widget) { - return { ok: false, reason: 'missingSubgraphInput' } - } - - applyHostValue(hostInput.input._widget, entry.hostValue) - return { ok: true, subgraphInputName: hostInput.input.name } -} - -function repairCreateSubgraphInput( - hostNode: SubgraphNode, - entry: PendingMigrationEntry, - sourceWidgetName: string -): RepairValueWidgetResult { - const subgraph = hostNode.subgraph - const sourceNode: LGraphNode | null = subgraph.getNodeById( - entry.normalized.sourceNodeId - ) - if (!sourceNode) { - return { ok: false, reason: 'missingSourceNode' } - } - - const sourceWidget = sourceNode.widgets?.find( - (w) => w.name === sourceWidgetName - ) - if (!sourceWidget) { - return { ok: false, reason: 'missingSourceWidget' } - } - - const slot: INodeInputSlot | undefined = - sourceNode.getSlotFromWidget(sourceWidget) - if (!slot) { - // TODO(adr-0009): When the source widget has no backing input slot, - // promotion currently has no canonical path to wire it through a - // SubgraphInput without first synthesizing the slot. The wiring slice - // (slice 5) will reconcile this — for now we surface a quarantine reason - // so the entry is preserved and visible to the user. - console.warn( - '[repairValueWidget] source widget has no backing input slot; quarantining', - { - sourceNodeId: entry.normalized.sourceNodeId, - sourceWidgetName - } - ) - return { ok: false, reason: 'missingSubgraphInput' } - } - - const existingNames = subgraph.inputs.map((input) => input.name) - const desiredName = nextUniqueName(sourceWidgetName, existingNames) - const slotType = String(slot.type ?? sourceWidget.type ?? '*') - - const newSubgraphInput = subgraph.addInput(desiredName, slotType) - // Mirror LGraphNode.configure: input.label → widget.label propagation. - if (slot.label !== undefined) newSubgraphInput.label = slot.label - const link = newSubgraphInput.connect(slot, sourceNode) - if (!link) { - subgraph.removeInput(newSubgraphInput) - return { ok: false, reason: 'missingSubgraphInput' } - } - - const hostInput = hostNode.inputs.find( - (input) => input.name === newSubgraphInput.name - ) - if (!hostInput?._widget) { - return { ok: true, subgraphInputName: newSubgraphInput.name } - } - - applyHostValue(hostInput._widget, entry.hostValue) - return { ok: true, subgraphInputName: newSubgraphInput.name } -} - -/** - * Repair a single legacy proxy entry into its canonical linked SubgraphInput. - * - * Two valid plan kinds: `'alreadyLinked'` and `'createSubgraphInput'`. Any - * other plan kind is a programmer error (caller bug) and throws. Failures - * during repair return a quarantine reason; the caller is expected to - * append the entry to the host's quarantine via `appendHostQuarantine`. - */ -export function repairValueWidget( - args: RepairValueWidgetArgs -): RepairValueWidgetResult { - const { hostNode, entry } = args - const { plan } = entry - - if (plan.kind === 'alreadyLinked') { - return repairAlreadyLinked(hostNode, entry) - } - - if (plan.kind === 'createSubgraphInput') { - return repairCreateSubgraphInput(hostNode, entry, plan.sourceWidgetName) - } - - throw new Error(`repairValueWidget: invalid plan kind ${plan.kind}`) -} diff --git a/src/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush.ts b/src/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush.ts deleted file mode 100644 index 13479e332f..0000000000 --- a/src/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush' -import { setSubgraphMigrationFlushHook } from '@/lib/litegraph/src/subgraph/subgraphMigrationHook' - -/** - * Register the proxyWidget migration flush as the late-bound hook that - * `LGraph.configure()` calls for every host SubgraphNode it materializes. - * - * Called once during app initialization. Safe to call multiple times — the - * registry holds a single function reference. - */ -export function wireProxyWidgetMigrationFlush(): void { - setSubgraphMigrationFlushHook(({ hostNode, nodeData }) => { - flushProxyWidgetMigration({ - hostNode, - hostWidgetValues: nodeData?.widgets_values - }) - }) -} diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts index 3fe248e155..9954970e08 100644 --- a/src/lib/litegraph/src/LGraph.test.ts +++ b/src/lib/litegraph/src/LGraph.test.ts @@ -1,6 +1,6 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph' import { @@ -8,7 +8,8 @@ import { LGraphNode, LiteGraph, LLink, - Reroute + Reroute, + SubgraphNode } from '@/lib/litegraph/src/litegraph' import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' import type { UUID } from '@/lib/litegraph/src/utils/uuid' @@ -16,6 +17,7 @@ import { zeroUuid } from '@/lib/litegraph/src/utils/uuid' import { usePreviewExposureStore } from '@/stores/previewExposureStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' import { + createTestSubgraph, createTestSubgraphData, createTestSubgraphNode } from './subgraph/__fixtures__/subgraphHelpers' @@ -985,6 +987,54 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => { } }) + it('warns when configuring a host with legacy proxyWidgets and no migration hook is wired', () => { + // The migration hook is wired in app init via main.ts; in pure + // litegraph-level tests (and any other configure() path that runs without + // the hook) we expect a console.warn so the missed migration is visible. + // Build a host SubgraphNode programmatically (registering its type), + // serialise the root, then configure() a fresh LGraph from that payload + // with the migration hook unset. + const subgraph = createTestSubgraph() + const sourceHost = createTestSubgraphNode(subgraph) + sourceHost.graph!.add(sourceHost) + sourceHost.properties.proxyWidgets = [['9999', 'seed']] + const serialized = sourceHost.rootGraph.serialize() + const instanceData = sourceHost.serialize() + + const previous = LGraph.proxyWidgetMigrationFlush + LGraph.proxyWidgetMigrationFlush = undefined + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + LiteGraph.registerNodeType( + subgraph.id, + class TestSubgraphNode extends SubgraphNode { + constructor() { + super(new LGraph(), subgraph, instanceData) + } + } + ) + try { + const graph = new LGraph() + graph.configure(serialized) + + const migrationCall = warn.mock.calls.find((call) => + typeof call[0] === 'string' + ? call[0].includes('Legacy proxyWidgets were not migrated') + : false + ) + expect(migrationCall).toBeDefined() + expect(migrationCall![1]).toEqual( + expect.objectContaining({ + hostNodeId: expect.any(Number), + proxyWidgets: expect.anything() + }) + ) + } finally { + LGraph.proxyWidgetMigrationFlush = previous + LiteGraph.unregisterNodeType(subgraph.id) + warn.mockRestore() + } + }) + it('throws when node ID space is exhausted', () => { expect(() => { const graph = new LGraph() diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 670e5b3e70..d6baa72b7c 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -50,7 +50,6 @@ import type { Size } from './interfaces' import { LiteGraph, SubgraphNode } from './litegraph' -import { runSubgraphMigrationFlushHook } from './subgraph/subgraphMigrationHook' import { alignOutsideContainer, alignToContainer, @@ -183,6 +182,16 @@ export class LGraph static STATUS_STOPPED = 1 static STATUS_RUNNING = 2 + /** + * Late-bound migration hook. Set once during app init from the wiring layer + * to avoid a circular dependency through PreviewExposureStore. Left undefined + * in tests that exercise `configure()` without the migration pipeline. + */ + static proxyWidgetMigrationFlush?: ( + hostNode: SubgraphNode, + nodeData: ISerialisedNode | undefined + ) => void + /** List of LGraph properties that are manually handled by {@link LGraph.configure}. */ static readonly ConfigureProperties = new Set([ 'nodes', @@ -2663,7 +2672,21 @@ export class LGraph for (const node of this._nodes) { if (!(node instanceof SubgraphNode)) continue if (node.properties?.proxyWidgets === undefined) continue - runSubgraphMigrationFlushHook(node, nodeDataMap.get(node.id)) + const nodeData = nodeDataMap.get(node.id) + if (LGraph.proxyWidgetMigrationFlush) { + LGraph.proxyWidgetMigrationFlush(node, nodeData) + } else if ( + node.properties.proxyWidgets !== undefined && + (import.meta.env.DEV || import.meta.env.MODE === 'test') + ) { + console.warn( + '[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired', + { + hostNodeId: node.id, + proxyWidgets: node.properties.proxyWidgets + } + ) + } } this.onConfigure?.(data) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts index 4acbbb535a..7667aac5e5 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts @@ -14,20 +14,21 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ISlotType, TWidgetType } from '@/lib/litegraph/src/litegraph' +import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph' + import { appendHostQuarantine, makeQuarantineEntry -} from '@/core/graph/subgraph/migration/quarantineEntry' +} from '@/core/graph/subgraph/migration/proxyWidgetMigration' import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' -import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema' -import type { ISlotType, TWidgetType } from '@/lib/litegraph/src/litegraph' -import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph' -import { usePreviewExposureStore } from '@/stores/previewExposureStore' import { reorderSubgraphInputAtIndex, reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils' +import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema' import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker' +import { usePreviewExposureStore } from '@/stores/previewExposureStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager' import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets' diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index d2044085f3..1b69ca9a61 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -44,8 +44,8 @@ import { CANVAS_IMAGE_PREVIEW_WIDGET, supportsVirtualCanvasImagePreview } from '@/composables/node/canvasImagePreviewTypes' -import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry' import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema' +import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { usePreviewExposureStore } from '@/stores/previewExposureStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' @@ -1216,7 +1216,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { delete serializedProperties.previewExposures } - const quarantine = readHostQuarantine(this) + const quarantine = parseProxyWidgetErrorQuarantine( + this.properties.proxyWidgetErrorQuarantine + ) if (quarantine.length === 0) { delete serializedProperties.proxyWidgetErrorQuarantine } else { diff --git a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts index 46a8a0f75e..38acae90ed 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts @@ -8,7 +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 { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration' import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' import { diff --git a/src/lib/litegraph/src/subgraph/subgraphMigrationHook.test.ts b/src/lib/litegraph/src/subgraph/subgraphMigrationHook.test.ts deleted file mode 100644 index 099955aed5..0000000000 --- a/src/lib/litegraph/src/subgraph/subgraphMigrationHook.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { setActivePinia } from 'pinia' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - createTestSubgraph, - createTestSubgraphNode -} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' - -import { - runSubgraphMigrationFlushHook, - setSubgraphMigrationFlushHook -} from './subgraphMigrationHook' - -describe('subgraph migration hook registry', () => { - beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - }) - - afterEach(() => { - setSubgraphMigrationFlushHook(undefined) - vi.restoreAllMocks() - }) - - it('warns in tests when legacy proxyWidgets exist but no flush hook is wired', () => { - const hostNode = createTestSubgraphNode(createTestSubgraph()) - hostNode.properties.proxyWidgets = [['1', 'seed']] - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - runSubgraphMigrationFlushHook(hostNode, undefined) - - expect(warnSpy).toHaveBeenCalledWith( - '[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired', - expect.objectContaining({ - hostNodeId: hostNode.id, - proxyWidgets: [['1', 'seed']] - }) - ) - }) - - it('uses the wired flush hook instead of warning', () => { - const hostNode = createTestSubgraphNode(createTestSubgraph()) - hostNode.properties.proxyWidgets = [['1', 'seed']] - const hook = vi.fn() - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - setSubgraphMigrationFlushHook(hook) - - runSubgraphMigrationFlushHook(hostNode, undefined) - - expect(hook).toHaveBeenCalledWith({ hostNode, nodeData: undefined }) - expect(warnSpy).not.toHaveBeenCalled() - }) -}) diff --git a/src/lib/litegraph/src/subgraph/subgraphMigrationHook.ts b/src/lib/litegraph/src/subgraph/subgraphMigrationHook.ts deleted file mode 100644 index d529e3af1d..0000000000 --- a/src/lib/litegraph/src/subgraph/subgraphMigrationHook.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation' - -import type { SubgraphNode } from './SubgraphNode' - -/** - * Late-bound hook that runs after a host graph has finished configuring all - * its nodes and links. Wired in app initialization to {@link - * flushProxyWidgetMigration}; left undefined in tests that exercise - * `LGraph.configure` without the migration pipeline. - * - * The hook is intentionally untyped at the LGraph layer because importing - * the flush directly from LGraph would create a circular dependency through - * the PreviewExposureStore. - */ -type SubgraphMigrationFlushHook = (args: { - hostNode: SubgraphNode - nodeData: ISerialisedNode | undefined -}) => void - -interface SubgraphMigrationRegistry { - flush?: SubgraphMigrationFlushHook -} - -const registry: SubgraphMigrationRegistry = {} - -export function setSubgraphMigrationFlushHook( - hook: SubgraphMigrationFlushHook | undefined -): void { - registry.flush = hook -} - -export function runSubgraphMigrationFlushHook( - hostNode: SubgraphNode, - nodeData: ISerialisedNode | undefined -): void { - if (registry.flush) { - registry.flush({ hostNode, nodeData }) - return - } - - if (hostNode.properties.proxyWidgets === undefined) return - - if (import.meta.env.DEV || import.meta.env.MODE === 'test') { - console.warn( - '[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired', - { - hostNodeId: hostNode.id, - proxyWidgets: hostNode.properties.proxyWidgets - } - ) - } -} diff --git a/src/main.ts b/src/main.ts index a43044dc19..e9741ff83d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,8 @@ import { createApp } from 'vue' import { VueFire, VueFireAuth } from 'vuefire' import { getFirebaseConfig } from '@/config/firebase' -import { wireProxyWidgetMigrationFlush } from '@/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush' +import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration' +import { LGraph } from '@/lib/litegraph/src/litegraph' import { configValueOrDefault, remoteConfig @@ -111,7 +112,11 @@ app // ADR 0009: hook the proxyWidget migration flush into LGraph.configure. // Late-bound so the LGraph layer doesn't import the PreviewExposureStore. -wireProxyWidgetMigrationFlush() +LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) => + flushProxyWidgetMigration({ + hostNode, + hostWidgetValues: nodeData?.widgets_values + }) const bootstrapStore = useBootstrapStore(pinia) void bootstrapStore.startStoreBootstrap()