mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
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:
@@ -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])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
785
src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts
Normal file
785
src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts
Normal 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'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
728
src/core/graph/subgraph/migration/proxyWidgetMigration.ts
Normal file
728
src/core/graph/subgraph/migration/proxyWidgetMigration.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user