refactor(migration): collapse subsystem to single entry point

Replaces planner/classifier/repair/quarantine helpers and their tests
with a single proxyWidgetMigration module exercised through black-box
round-trip tests. Hook registry indirection replaced with a static
LGraph.proxyWidgetMigrationFlush field assigned in main.ts.

Includes a real semantic fix: classifier now preserves surviving
primitive targets when other targets are dangling.

Net: -16 files, ~-2,300 LoC in src/core/graph/subgraph/migration/.
This commit is contained in:
DrJKL
2026-05-10 20:30:55 -07:00
parent eb569d5f2e
commit 5fa018ec0c
26 changed files with 1608 additions and 2860 deletions

View File

@@ -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<PromotedWidgetView>({
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<PromotedWidgetView>({
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<PromotedWidgetView>({
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])
)
})
})
})

View File

@@ -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
}
}
}

View File

@@ -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')
})
})

View File

@@ -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<typeof usePreviewExposureStore>
}
/**
* 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 }
}

View File

@@ -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<PromotedWidgetView>({
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'
})
])
})
})
})

View File

@@ -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<NodeId, PendingEntry[]>()
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<typeof l> => 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<typeof usePreviewExposureStore>
): 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)
}

View File

@@ -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<PromotedWidgetView>({
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)
})
})
})

View File

@@ -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<NodeId, PendingMigrationEntry[]>()
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
}

View File

@@ -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[]
}

View File

@@ -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<PromotedWidgetView>({
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'
})
})
})

View File

@@ -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 }
}

View File

@@ -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()
})
})

View File

@@ -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]
}

View File

@@ -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' })
})
})

View File

@@ -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
}
}

View File

@@ -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<PromotedWidgetView>({
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<PromotedWidgetView>({
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<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 1
})
const secondInput = host.addInput('second_seed', '*')
secondInput._widget = fromPartial<PromotedWidgetView>({
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<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 1
})
const secondInput = host.addInput('second_seed', '*')
secondInput._widget = fromPartial<PromotedWidgetView>({
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/
)
})
})
})

View File

@@ -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}`)
}

View File

@@ -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
})
})
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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()
})
})

View File

@@ -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
}
)
}
}

View File

@@ -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()