diff --git a/src/platform/workflow/validation/schemas/workflowSchema.test.ts b/src/platform/workflow/validation/schemas/workflowSchema.test.ts index b075e3ebe2..1d687455c5 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.test.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.test.ts @@ -1,7 +1,11 @@ import fs from 'fs' import { describe, expect, it } from 'vitest' -import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' +import { + buildSubgraphExecutionPaths, + validateComfyWorkflow +} from '@/platform/workflow/validation/schemas/workflowSchema' +import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema' import { defaultGraph } from '@/scripts/defaultGraph' const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__' @@ -206,3 +210,67 @@ describe('parseComfyWorkflow', () => { }) }) }) + +function node(id: number, type: string): ComfyNode { + return { id, type } as ComfyNode +} + +function subgraphDef(id: string, nodes: ComfyNode[]) { + return { id, name: id, nodes, inputNode: {}, outputNode: {} } +} + +describe('buildSubgraphExecutionPaths', () => { + it('returns empty map when there are no subgraph definitions', () => { + expect(buildSubgraphExecutionPaths([node(5, 'SomeNode')], [])).toEqual( + new Map() + ) + }) + + it('returns empty map when no root node matches a subgraph type', () => { + const def = subgraphDef('def-A', []) + expect( + buildSubgraphExecutionPaths([node(5, 'UnrelatedNode')], [def]) + ).toEqual(new Map()) + }) + + it('maps a single subgraph instance to its execution path', () => { + const def = subgraphDef('def-A', []) + const result = buildSubgraphExecutionPaths([node(5, 'def-A')], [def]) + expect(result.get('def-A')).toEqual(['5']) + }) + + it('collects multiple instances of the same subgraph type', () => { + const def = subgraphDef('def-A', []) + const result = buildSubgraphExecutionPaths( + [node(5, 'def-A'), node(10, 'def-A')], + [def] + ) + expect(result.get('def-A')).toEqual(['5', '10']) + }) + + it('builds nested execution paths for subgraphs within subgraphs', () => { + const innerDef = subgraphDef('def-B', []) + const outerDef = subgraphDef('def-A', [node(70, 'def-B')]) + const result = buildSubgraphExecutionPaths( + [node(5, 'def-A')], + [outerDef, innerDef] + ) + expect(result.get('def-A')).toEqual(['5']) + expect(result.get('def-B')).toEqual(['5:70']) + }) + + it('does not recurse infinitely on self-referential subgraph definitions', () => { + const cyclicDef = subgraphDef('def-A', [node(70, 'def-A')]) + expect(() => + buildSubgraphExecutionPaths([node(5, 'def-A')], [cyclicDef]) + ).not.toThrow() + }) + + it('does not recurse infinitely on mutually cyclic subgraph definitions', () => { + const defA = subgraphDef('def-A', [node(70, 'def-B')]) + const defB = subgraphDef('def-B', [node(80, 'def-A')]) + expect(() => + buildSubgraphExecutionPaths([node(5, 'def-A')], [defA, defB]) + ).not.toThrow() + }) +}) diff --git a/src/platform/workflow/validation/schemas/workflowSchema.ts b/src/platform/workflow/validation/schemas/workflowSchema.ts index a6a6ee4ff7..e697181ed1 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.ts @@ -541,3 +541,47 @@ const zNodeData = z.object({ const zComfyApiWorkflow = z.record(zNodeId, zNodeData) export type ComfyApiWorkflow = z.infer + +/** + * Builds a map from subgraph definition ID to all execution path prefixes + * where that definition is instantiated in the workflow. + * + * "def-A" → ["5", "10"] for each container node instantiating that subgraph definition. + * @knipIgnoreUsedByStackedPR + */ +export function buildSubgraphExecutionPaths( + rootNodes: ComfyNode[], + allSubgraphDefs: unknown[] +): Map { + const subgraphDefMap = new Map( + allSubgraphDefs.filter(isSubgraphDefinition).map((s) => [s.id, s]) + ) + const pathMap = new Map() + const visited = new Set() + + const build = (nodes: ComfyNode[], parentPrefix: string) => { + for (const n of nodes ?? []) { + if (typeof n.type !== 'string' || !subgraphDefMap.has(n.type)) continue + const path = parentPrefix ? `${parentPrefix}:${n.id}` : String(n.id) + const existing = pathMap.get(n.type) + if (existing) { + existing.push(path) + } else { + pathMap.set(n.type, [path]) + } + + if (visited.has(n.type)) continue + visited.add(n.type) + + const innerDef = subgraphDefMap.get(n.type) + if (innerDef) { + build(innerDef.nodes, path) + } + + visited.delete(n.type) + } + } + + build(rootNodes, '') + return pathMap +} diff --git a/src/types/nodeIdentification.test.ts b/src/types/nodeIdentification.test.ts index 39288a9c05..063b8f6e0d 100644 --- a/src/types/nodeIdentification.test.ts +++ b/src/types/nodeIdentification.test.ts @@ -4,6 +4,8 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche import { createNodeExecutionId, createNodeLocatorId, + getAncestorExecutionIds, + getParentExecutionIds, isNodeExecutionId, isNodeLocatorId, parseNodeExecutionId, @@ -204,4 +206,30 @@ describe('nodeIdentification', () => { expect(parsed).toEqual(nodeIds) }) }) + + describe('getAncestorExecutionIds', () => { + it('returns only itself for a root node', () => { + expect(getAncestorExecutionIds('65')).toEqual(['65']) + }) + + it('returns all ancestors including self for nested IDs', () => { + expect(getAncestorExecutionIds('65:70')).toEqual(['65', '65:70']) + expect(getAncestorExecutionIds('65:70:63')).toEqual([ + '65', + '65:70', + '65:70:63' + ]) + }) + }) + + describe('getParentExecutionIds', () => { + it('returns empty for a root node', () => { + expect(getParentExecutionIds('65')).toEqual([]) + }) + + it('returns all ancestors excluding self for nested IDs', () => { + expect(getParentExecutionIds('65:70')).toEqual(['65']) + expect(getParentExecutionIds('65:70:63')).toEqual(['65', '65:70']) + }) + }) }) diff --git a/src/types/nodeIdentification.ts b/src/types/nodeIdentification.ts index d9a299c398..6ee14c4695 100644 --- a/src/types/nodeIdentification.ts +++ b/src/types/nodeIdentification.ts @@ -121,3 +121,30 @@ export function parseNodeExecutionId(id: string): NodeId[] | null { export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId { return nodeIds.join(':') } + +/** + * Returns all ancestor execution IDs for a given execution ID, including itself. + * + * Example: "65:70:63" → ["65", "65:70", "65:70:63"] + * @knipIgnoreUsedByStackedPR + */ +export function getAncestorExecutionIds( + executionId: string | NodeExecutionId +): NodeExecutionId[] { + const parts = executionId.split(':') + return Array.from({ length: parts.length }, (_, i) => + parts.slice(0, i + 1).join(':') + ) +} + +/** + * Returns all ancestor execution IDs for a given execution ID, excluding itself. + * + * Example: "65:70:63" → ["65", "65:70"] + * @knipIgnoreUsedByStackedPR + */ +export function getParentExecutionIds( + executionId: string | NodeExecutionId +): NodeExecutionId[] { + return getAncestorExecutionIds(executionId).slice(0, -1) +} diff --git a/src/utils/executionIdUtil.test.ts b/src/utils/executionIdUtil.test.ts deleted file mode 100644 index 9d275311df..0000000000 --- a/src/utils/executionIdUtil.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema' -import { - buildSubgraphExecutionPaths, - getAncestorExecutionIds, - getParentExecutionIds -} from '@/utils/executionIdUtil' - -function node(id: number, type: string): ComfyNode { - return { id, type } as ComfyNode -} - -function subgraphDef(id: string, nodes: ComfyNode[]) { - return { id, name: id, nodes, inputNode: {}, outputNode: {} } -} - -describe('getAncestorExecutionIds', () => { - it('returns only itself for a root node', () => { - expect(getAncestorExecutionIds('65')).toEqual(['65']) - }) - - it('returns all ancestors including self for nested IDs', () => { - expect(getAncestorExecutionIds('65:70')).toEqual(['65', '65:70']) - expect(getAncestorExecutionIds('65:70:63')).toEqual([ - '65', - '65:70', - '65:70:63' - ]) - }) -}) - -describe('getParentExecutionIds', () => { - it('returns empty for a root node', () => { - expect(getParentExecutionIds('65')).toEqual([]) - }) - - it('returns all ancestors excluding self for nested IDs', () => { - expect(getParentExecutionIds('65:70')).toEqual(['65']) - expect(getParentExecutionIds('65:70:63')).toEqual(['65', '65:70']) - }) -}) - -describe('buildSubgraphExecutionPaths', () => { - it('returns empty map when there are no subgraph definitions', () => { - expect(buildSubgraphExecutionPaths([node(5, 'SomeNode')], [])).toEqual( - new Map() - ) - }) - - it('returns empty map when no root node matches a subgraph type', () => { - const def = subgraphDef('def-A', []) - expect( - buildSubgraphExecutionPaths([node(5, 'UnrelatedNode')], [def]) - ).toEqual(new Map()) - }) - - it('maps a single subgraph instance to its execution path', () => { - const def = subgraphDef('def-A', []) - const result = buildSubgraphExecutionPaths([node(5, 'def-A')], [def]) - expect(result.get('def-A')).toEqual(['5']) - }) - - it('collects multiple instances of the same subgraph type', () => { - const def = subgraphDef('def-A', []) - const result = buildSubgraphExecutionPaths( - [node(5, 'def-A'), node(10, 'def-A')], - [def] - ) - expect(result.get('def-A')).toEqual(['5', '10']) - }) - - it('builds nested execution paths for subgraphs within subgraphs', () => { - const innerDef = subgraphDef('def-B', []) - const outerDef = subgraphDef('def-A', [node(70, 'def-B')]) - const result = buildSubgraphExecutionPaths( - [node(5, 'def-A')], - [outerDef, innerDef] - ) - expect(result.get('def-A')).toEqual(['5']) - expect(result.get('def-B')).toEqual(['5:70']) - }) - - it('does not recurse infinitely on self-referential subgraph definitions', () => { - const cyclicDef = subgraphDef('def-A', [node(70, 'def-A')]) - expect(() => - buildSubgraphExecutionPaths([node(5, 'def-A')], [cyclicDef]) - ).not.toThrow() - }) - - it('does not recurse infinitely on mutually cyclic subgraph definitions', () => { - const defA = subgraphDef('def-A', [node(70, 'def-B')]) - const defB = subgraphDef('def-B', [node(80, 'def-A')]) - expect(() => - buildSubgraphExecutionPaths([node(5, 'def-A')], [defA, defB]) - ).not.toThrow() - }) -}) diff --git a/src/utils/executionIdUtil.ts b/src/utils/executionIdUtil.ts deleted file mode 100644 index 17fcb8f10d..0000000000 --- a/src/utils/executionIdUtil.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { NodeExecutionId } from '@/types/nodeIdentification' -import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema' -import { isSubgraphDefinition } from '@/platform/workflow/validation/schemas/workflowSchema' - -/** - * Returns all ancestor execution IDs for a given execution ID, including itself. - * - * Example: "65:70:63" → ["65", "65:70", "65:70:63"] - * @knipIgnoreUsedByStackedPR - */ -export function getAncestorExecutionIds( - executionId: string | NodeExecutionId -): NodeExecutionId[] { - const parts = executionId.split(':') - return Array.from({ length: parts.length }, (_, i) => - parts.slice(0, i + 1).join(':') - ) -} - -/** - * Returns all ancestor execution IDs for a given execution ID, excluding itself. - * - * Example: "65:70:63" → ["65", "65:70"] - * @knipIgnoreUsedByStackedPR - */ -export function getParentExecutionIds( - executionId: string | NodeExecutionId -): NodeExecutionId[] { - return getAncestorExecutionIds(executionId).slice(0, -1) -} - -/** - * "def-A" → ["5", "10"] for each container node instantiating that subgraph definition. - * @knipIgnoreUsedByStackedPR - */ -export function buildSubgraphExecutionPaths( - rootNodes: ComfyNode[], - allSubgraphDefs: unknown[] -): Map { - const subgraphDefMap = new Map( - allSubgraphDefs.filter(isSubgraphDefinition).map((s) => [s.id, s]) - ) - const pathMap = new Map() - const visited = new Set() - - const build = (nodes: ComfyNode[], parentPrefix: string) => { - for (const n of nodes ?? []) { - if (typeof n.type !== 'string' || !subgraphDefMap.has(n.type)) continue - const path = parentPrefix ? `${parentPrefix}:${n.id}` : String(n.id) - const existing = pathMap.get(n.type) - if (existing) { - existing.push(path) - } else { - pathMap.set(n.type, [path]) - } - - if (visited.has(n.type)) continue - visited.add(n.type) - - const innerDef = subgraphDefMap.get(n.type) - if (innerDef) { - build(innerDef.nodes, path) - } - - visited.delete(n.type) - } - } - - build(rootNodes, '') - return pathMap -} diff --git a/src/workbench/extensions/manager/components/manager/ManagerDialog.vue b/src/workbench/extensions/manager/components/manager/ManagerDialog.vue index 138108b8fd..22e19a562d 100644 --- a/src/workbench/extensions/manager/components/manager/ManagerDialog.vue +++ b/src/workbench/extensions/manager/components/manager/ManagerDialog.vue @@ -165,7 +165,7 @@