diff --git a/src/main/frontend/app/hooks/use-handle-types.spec.ts b/src/main/frontend/app/hooks/use-handle-types.spec.ts new file mode 100644 index 00000000..e7d2d3d4 --- /dev/null +++ b/src/main/frontend/app/hooks/use-handle-types.spec.ts @@ -0,0 +1,25 @@ +import { getHandleTypes } from './use-handle-types' + +describe('getHandleTypes', () => { + it('returns empty array when typesAllowed is undefined', () => { + expect(getHandleTypes()).toEqual([]) + }) + + it('returns empty array when typesAllowed is empty', () => { + expect(getHandleTypes({})).toEqual([]) + }) + + it('returns all forwards as-is', () => { + const result = getHandleTypes({ success: {}, exception: {}, failure: {} }) + expect(result).toContain('success') + expect(result).toContain('exception') + expect(result).toContain('failure') + }) + + it('maps wildcard * to custom', () => { + const result = getHandleTypes({ '*': {}, exception: {} }) + expect(result).toContain('custom') + expect(result).toContain('exception') + expect(result).not.toContain('*') + }) +}) diff --git a/src/main/frontend/app/hooks/use-handle-types.ts b/src/main/frontend/app/hooks/use-handle-types.ts index 6713efce..4fce4ee9 100644 --- a/src/main/frontend/app/hooks/use-handle-types.ts +++ b/src/main/frontend/app/hooks/use-handle-types.ts @@ -1,23 +1,12 @@ import { useMemo } from 'react' import type { ElementProperty } from '@frankframework/doc-library-core' -export function useHandleTypes(typesAllowed?: Record) { - return useMemo(() => { - // Always include the 'success' handle, using a Set to avoid duplicates - const handles = new Set(['success']) - - if (!typesAllowed) return [...handles] - - if ('*' in typesAllowed) { - handles.add('custom') - } +export function getHandleTypes(typesAllowed?: Record): string[] { + if (!typesAllowed) return [] - for (const type of Object.keys(typesAllowed)) { - if (type !== '*') { - handles.add(type) - } - } + return Object.keys(typesAllowed).flatMap((type) => (type === '*' ? ['custom'] : [type])) +} - return [...handles] - }, [typesAllowed]) +export function useHandleTypes(typesAllowed?: Record) { + return useMemo(() => getHandleTypes(typesAllowed), [typesAllowed]) } diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index becde7dc..b3adb837 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -35,12 +35,17 @@ import { logApiError } from '~/utils/logger' import { NodeContextMenuContext, useNodeContextMenu } from './node-context-menu-context' import StickyNoteComponent, { type StickyNote } from '~/routes/studio/canvas/nodetypes/sticky-note' import useTabStore, { type TabData } from '~/stores/tab-store' -import { convertAdapterXmlToJson, getAdapterFromConfiguration } from '~/routes/studio/xml-to-json-parser' +import { + convertAdapterXmlToJson, + getAdapterFromConfiguration, + type ResolveForwards, +} from '~/routes/studio/xml-to-json-parser' import { exportFlowToXml, replaceAdapterInXml } from '~/routes/studio/flow-to-xml-parser' import useNodeContextStore from '~/stores/node-context-store' import CreateNodeModal from '~/components/flow/create-node-modal' import { useFFDoc } from '@frankframework/doc-library-react' import type { ElementDetails } from '@frankframework/doc-library-core' +import { getDefaultSourceHandles, resolveForwardsWithInheritance } from '~/utils/frankdoc-utils' import { useProjectStore } from '~/stores/project-store' import { clearConfigurationFileCache, @@ -992,6 +997,14 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { [elements], ) + const resolveForwards = useCallback( + (subtype: string) => resolveForwardsWithInheritance(lookupFrankElement(subtype)), + [lookupFrankElement], + ) + + const importForwardsResolverRef = useRef(resolveForwards) + importForwardsResolverRef.current = resolveForwards + const deselectOtherNodes = useCallback( (nodeId: string) => { const flowNodes = reactFlow.getNodes() @@ -1241,6 +1254,8 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { const width = nodeType === 'exitNode' ? FlowConfig.EXIT_DEFAULT_WIDTH : FlowConfig.NODE_DEFAULT_WIDTH const height = nodeType === 'exitNode' ? FlowConfig.EXIT_DEFAULT_HEIGHT : FlowConfig.NODE_MIN_HEIGHT + const sourceHandles = getDefaultSourceHandles(resolveForwards(elementName)) + const newNode: FrankNodeType = { id: newId.toString(), position: { @@ -1251,7 +1266,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { subtype: elementName, type: elementType, name: ``, - sourceHandles: [{ type: 'success', index: 1 }], + sourceHandles, children: [], }, type: nodeType, @@ -1487,7 +1502,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { tab.adapterPosition, ) if (!adapter) return - const adapterJson = await convertAdapterXmlToJson(adapter) + const adapterJson = await convertAdapterXmlToJson(adapter, importForwardsResolverRef.current) flowStore.setEdges(adapterJson.edges) flowStore.setNodes(adapterJson.nodes) @@ -1733,10 +1748,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { position={pendingCompactConnection.position} onClose={() => setPendingCompactConnection(null)} onSelect={handleCompactHandleSelect} - typesAllowed={ - (elements as Record | null)?.[pendingCompactConnection.sourceNodeSubtype] - ?.forwards - } + typesAllowed={resolveForwards(pendingCompactConnection.sourceNodeSubtype)} /> )} @@ -1746,9 +1758,7 @@ function FlowCanvas({ onOpenInEditor }: { onOpenInEditor: () => void }) { position={pendingEdgeDrop.position} onClose={() => setPendingEdgeDrop(null)} onSelect={handleEdgeDropHandleSelect} - typesAllowed={ - (elements as Record | null)?.[pendingEdgeDrop.sourceNodeSubtype]?.forwards - } + typesAllowed={resolveForwards(pendingEdgeDrop.sourceNodeSubtype)} /> )} diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx index b8979921..c44e4615 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/handle.tsx @@ -13,25 +13,34 @@ interface HandleProperties { typesAllowed?: Record } -const HANDLE_TYPE_COLOURS: Record = { +const SEMANTIC_COLOURS: Record = { success: '#68D250', failure: '#E84E4E', exception: '#424242', timeout: '#F2A900', error: '#ff7605ff', - default: '#1B97D1', +} + +const GREEN_BAND_START = 95 +const GREEN_BAND_SIZE = 70 + +function colourFromName(type: string): string { + let hash = 0 + for (const character of type) { + hash = (hash * 31 + (character.codePointAt(0) ?? 0)) % (360 - GREEN_BAND_SIZE) + } + const hue = hash < GREEN_BAND_START ? hash : hash + GREEN_BAND_SIZE + return `hsl(${hue}, 65%, 52%)` } export function translateHandleTypeToColour(type: string): string { const normalized = type.toLowerCase() - for (const [suffix, colour] of Object.entries(HANDLE_TYPE_COLOURS)) { - if (normalized.endsWith(suffix)) { - return colour - } + for (const [suffix, colour] of Object.entries(SEMANTIC_COLOURS)) { + if (normalized.endsWith(suffix)) return colour } - return HANDLE_TYPE_COLOURS.default + return colourFromName(normalized) } export function CustomHandle(properties: Readonly) { diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx index e5b45cb0..4348d4d3 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx @@ -27,6 +27,7 @@ import type { ElementDetails } from '@frankframework/doc-library-core' import { DeprecatedPopover } from './components/deprecated-popover' import { showWarningToast } from '~/components/toast' import { useHandleTypes } from '~/hooks/use-handle-types' +import { resolveForwardsWithInheritance } from '~/utils/frankdoc-utils' import AddSubcomponentModal from '~/components/flow/add-subcomponent-modal' import { useFrankConfigXsd } from '~/providers/frankconfig-xsd-provider' import { @@ -85,7 +86,13 @@ export default function FrankNode(properties: NodeProps) { if (!elements) return null const recordElements = elements as Record - return Object.values(recordElements).find((element) => element.name === properties.data.subtype) ?? null + const element = Object.values(recordElements).find((element) => element.name === properties.data.subtype) ?? null + if (!element) return element + + return { + ...element, + forwards: resolveForwardsWithInheritance(element), + } }, [elements, properties.data.subtype]) const isDeprecated = frankElement?.deprecated diff --git a/src/main/frontend/app/routes/studio/xml-to-json-parser.spec.ts b/src/main/frontend/app/routes/studio/xml-to-json-parser.spec.ts new file mode 100644 index 00000000..7bee5147 --- /dev/null +++ b/src/main/frontend/app/routes/studio/xml-to-json-parser.spec.ts @@ -0,0 +1,75 @@ +import { extractSourceHandles, type ResolveForwards } from './xml-to-json-parser' +import type { ElementProperty } from '@frankframework/doc-library-core' + +function elementFromXml(xml: string): Element { + const document_ = new DOMParser().parseFromString(xml, 'text/xml') + return document_.documentElement +} + +const FORWARDS: Record> = { + EchoPipe: { success: {}, exception: {} }, + IfPipe: { exception: {}, '*': {}, else: {} }, + SwitchPipe: { exception: {}, notFound: {}, empty: {}, '*': {} }, +} + +const resolveForwards: ResolveForwards = (subtype) => FORWARDS[subtype] + +describe('extractSourceHandles', () => { + it('creates a handle per for any element type', () => { + const element = elementFromXml( + '', + ) + expect(extractSourceHandles(element, resolveForwards)).toEqual([ + { type: 'exception', index: 1 }, + { type: 'then', index: 2 }, + { type: 'else', index: 3 }, + ]) + }) + + it('does NOT add an implicit success handle to a routing pipe (IfPipe)', () => { + const element = elementFromXml( + '', + ) + const handles = extractSourceHandles(element, resolveForwards) + expect(handles.some((handle) => handle.type === 'success')).toBe(false) + }) + + it('adds the implicit success fall-through handle for a FixedForwardPipe subclass with only an exception forward', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([ + { type: 'exception', index: 1 }, + { type: 'success', index: 2 }, + ]) + }) + + it('keeps an explicit success forward without duplicating it', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([{ type: 'success', index: 1 }]) + }) + + it('uses the FrankDoc default handle when no forwards are declared (EchoPipe -> success)', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([{ type: 'success', index: 1 }]) + }) + + it('uses the FrankDoc default handle when no forwards are declared (SwitchPipe -> first routing forward)', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element, resolveForwards)).toEqual([{ type: 'notFound', index: 1 }]) + }) + + it('falls back to a single success handle when no FrankDoc resolver is provided', () => { + const element = elementFromXml('') + expect(extractSourceHandles(element)).toEqual([{ type: 'success', index: 1 }]) + }) + + it('keeps adding the implicit success handle without a resolver (legacy behaviour)', () => { + const element = elementFromXml( + '', + ) + expect(extractSourceHandles(element)).toEqual([ + { type: 'then', index: 1 }, + { type: 'else', index: 2 }, + { type: 'success', index: 3 }, + ]) + }) +}) diff --git a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts index f15dc780..f8b2c0c8 100644 --- a/src/main/frontend/app/routes/studio/xml-to-json-parser.ts +++ b/src/main/frontend/app/routes/studio/xml-to-json-parser.ts @@ -5,6 +5,8 @@ import type { FrankNodeType } from '~/routes/studio/canvas/nodetypes/frank-node' import { getElementTypeFromName } from '~/routes/studio/node-translator-module' import { fetchConfigurationFileCached } from '~/services/configuration-file-service' import { translateElementFromOldToNewFormat } from '~/utils/flow-utils' +import { getDefaultSourceHandles } from '~/utils/frankdoc-utils' +import type { ElementProperty } from '@frankframework/doc-library-core' import { FlowConfig } from './canvas/flow.config' import type { GroupNode } from './canvas/nodetypes/group-node' import { isFrankNode } from '~/stores/flow-store' @@ -102,15 +104,22 @@ export async function getAdapterListenerType( return null } -export async function convertAdapterXmlToJson(adapter: Element) { +export type ResolveForwards = (subtype: string) => Record | undefined + +function supportsSuccessForward(subtype: string, resolveForwards?: ResolveForwards): boolean { + const forwards = resolveForwards?.(subtype) + return forwards ? 'success' in forwards : true +} + +export async function convertAdapterXmlToJson(adapter: Element, resolveForwards?: ResolveForwards) { const idCounter: IdCounter = { current: 0 } - const { nodes: flowNodes, elementToId } = convertAdapterToFlowNodes(adapter, idCounter) + const { nodes: flowNodes, elementToId } = convertAdapterToFlowNodes(adapter, idCounter, resolveForwards) const stickyNotes = extractStickyNotesFromAdapter(adapter, idCounter, flowNodes) const groupNodes = extractGroupNodesFromAdapter(adapter, flowNodes, idCounter) assignParentRelationships(flowNodes, groupNodes) const allNodes: FlowNode[] = [...groupNodes, ...flowNodes, ...stickyNotes] - return { nodes: allNodes, edges: extractEdgesFromAdapter(adapter, flowNodes, elementToId) } + return { nodes: allNodes, edges: extractEdgesFromAdapter(adapter, flowNodes, elementToId, resolveForwards) } } function buildNodeNameToIdMap(nodes: FlowNode[]): Map { @@ -174,7 +183,12 @@ function buildNameToNodeMap(nodes: FlowNode[]): Map { * @param elementToId A mapping of XML Elements to their corresponding node IDs, used for edge creation * @returns An array of FrankEdge objects representing all generated edges */ -function extractEdgesFromAdapter(adapter: Element, nodes: FlowNode[], elementToId: Map): FrankEdge[] { +function extractEdgesFromAdapter( + adapter: Element, + nodes: FlowNode[], + elementToId: Map, + resolveForwards?: ResolveForwards, +): FrankEdge[] { const pipelineElement = [...adapter.children].find((el) => el.tagName.toLowerCase() === 'pipeline') || null if (!pipelineElement) return [] @@ -207,9 +221,10 @@ function extractEdgesFromAdapter(adapter: Element, nodes: FlowNode[], elementToI explicitTargetsBySourceId, sourcesWithSuccessExitForward, sourcesWithSuccessPipeForward, + resolveForwards, ) - addImplicitSuccessExitEdge(nodes, edges, forwardIndexBySourceId) + addImplicitSuccessExitEdge(nodes, edges, forwardIndexBySourceId, resolveForwards) return edges } @@ -347,6 +362,7 @@ function addSequentialFallbackEdges( explicitTargetsBySourceId: Map>, sourcesWithSuccessExitForward: Set, sourcesWithSuccessPipeForward: Set, + resolveForwards?: ResolveForwards, ) { for (let i = 0; i < nodes.length - 1; i++) { const current = nodes[i] @@ -354,6 +370,7 @@ function addSequentialFallbackEdges( if (current.type === 'exitNode') continue // skip receivers (they already get edges to first pipeline pipe) if (isFrankNode(current) && current.data.type === 'receiver') continue + if (isFrankNode(current) && !supportsSuccessForward(current.data.subtype, resolveForwards)) continue // find next NON-exit node const next = nodes.slice(i + 1).find((n) => n.type !== 'exitNode') @@ -385,6 +402,7 @@ function addImplicitSuccessExitEdge( nodes: FlowNode[], edges: FrankEdge[], forwardIndexBySourceId: Map, + resolveForwards?: ResolveForwards, ) { const successExit = findSuccessExit(nodes) if (!successExit) return @@ -397,6 +415,8 @@ function addImplicitSuccessExitEdge( if (!lastPipelineNode) return + if (isFrankNode(lastPipelineNode) && !supportsSuccessForward(lastPipelineNode.data.subtype, resolveForwards)) return + const handleIndex = forwardIndexBySourceId.get(lastPipelineNode.id) ?? 1 forwardIndexBySourceId.set(lastPipelineNode.id, handleIndex + 1) @@ -438,15 +458,16 @@ function collectPipelineElements(adapter: Element): Element[] { return elements } -function extractSourceHandles(element: Element): SourceHandle[] { +export function extractSourceHandles(element: Element, resolveForwards?: ResolveForwards): SourceHandle[] { + const { subtype } = translateElementFromOldToNewFormat(element) + const forwards = resolveForwards?.(subtype) + let forwardElements = [...element.querySelectorAll('Forward')] - // Check if forwards are lower case instead if (forwardElements.length === 0) { forwardElements = [...element.querySelectorAll('forward')] - // No forwards? Create a single implicit success handle if (forwardElements.length === 0) { - return [{ type: 'success', index: 1 }] + return forwards ? getDefaultSourceHandles(forwards) : [{ type: 'success', index: 1 }] } } @@ -459,18 +480,10 @@ function extractSourceHandles(element: Element): SourceHandle[] { } }) - // Check if any forward represents SUCCESS - const hasSuccessForward = forwardElements.some((forward) => { - const name = forward.getAttribute('name')?.toUpperCase() - return name === 'SUCCESS' - }) + const hasSuccessForward = forwardElements.some((forward) => forward.getAttribute('name')?.toUpperCase() === 'SUCCESS') - // If not, add implicit fallback handle - if (!hasSuccessForward) { - handles.push({ - type: 'success', - index: handles.length + 1, - }) + if (!hasSuccessForward && supportsSuccessForward(subtype, resolveForwards)) { + handles.push({ type: 'success', index: handles.length + 1 }) } return handles @@ -505,6 +518,7 @@ function processExitElements(element: Element, exitNodes: ExitNode[]) { function convertAdapterToFlowNodes( adapter: Element, idCounter: IdCounter, + resolveForwards?: ResolveForwards, ): { nodes: FlowNode[]; elementToId: Map } { const nodes: FlowNode[] = [] const exitNodes: ExitNode[] = [] @@ -540,7 +554,7 @@ function convertAdapterToFlowNodes( continue } - const sourceHandles = extractSourceHandles(element) + const sourceHandles = extractSourceHandles(element, resolveForwards) const frankNode: FrankNodeType = convertElementToNode(element, idCounter, sourceHandles) elementToId.set(element, frankNode.id) nodes.push(frankNode) diff --git a/src/main/frontend/app/utils/frankdoc-utils.spec.ts b/src/main/frontend/app/utils/frankdoc-utils.spec.ts new file mode 100644 index 00000000..29306489 --- /dev/null +++ b/src/main/frontend/app/utils/frankdoc-utils.spec.ts @@ -0,0 +1,92 @@ +import { getDefaultSourceHandles, resolveForwardsWithInheritance } from './frankdoc-utils' +import type { ElementProperty } from '@frankframework/doc-library-core' + +describe('resolveForwardsWithInheritance', () => { + it('adds success for a non-router pipe with only an exception forward (e.g. EchoPipe)', () => { + const result = resolveForwardsWithInheritance({ forwards: { exception: {} } }) + expect(result).toHaveProperty('success') + expect(result).toHaveProperty('exception') + }) + + it('adds success for a non-router pipe without any forwards', () => { + expect(resolveForwardsWithInheritance({})).toHaveProperty('success') + }) + + it('adds success when the element is missing', () => { + expect(resolveForwardsWithInheritance()).toHaveProperty('success') + }) + + it('adds success for an endpoint pipe with error forwards (e.g. SenderPipe)', () => { + const result = resolveForwardsWithInheritance({ + forwards: { exception: {}, timeout: {}, '*': {} }, + labels: { EIP: 'Endpoint' }, + }) + expect(result).toHaveProperty('success') + expect(result).toHaveProperty('timeout') + }) + + it('does NOT add success for a router pipe (EIP=Router, e.g. SwitchPipe)', () => { + const result = resolveForwardsWithInheritance({ + forwards: { exception: {}, notFound: {}, empty: {}, '*': {} }, + labels: { EIP: 'Router' }, + }) + expect(result).not.toHaveProperty('success') + }) + + it('does NOT add success for a wildcard-only router (e.g. CounterSwitchPipe)', () => { + const result = resolveForwardsWithInheritance({ forwards: { exception: {}, '*': {} }, labels: { EIP: 'Router' } }) + expect(result).not.toHaveProperty('success') + }) + + it('does not duplicate success when the element already defines it', () => { + const result = resolveForwardsWithInheritance({ forwards: { exception: {}, success: {} } }) + expect(Object.keys(result).filter((forward) => forward === 'success')).toHaveLength(1) + }) +}) + +describe('getDefaultSourceHandles', () => { + it('defaults to the success handle when the element supports the success forward', () => { + expect(getDefaultSourceHandles({ success: {}, exception: {} })).toEqual([{ type: 'success', index: 1 }]) + }) + + it('skips the exception error forward when picking the default handle', () => { + expect(getDefaultSourceHandles({ exception: {}, success: {} })).toEqual([{ type: 'success', index: 1 }]) + }) + + it('defaults to the first routing forward for switch pipes (no success forward)', () => { + expect(getDefaultSourceHandles({ exception: {}, notFound: {}, empty: {}, '*': {} })).toEqual([ + { type: 'notFound', index: 1 }, + ]) + }) + + it('maps a wildcard-only forward to a custom default handle', () => { + expect(getDefaultSourceHandles({ exception: {}, '*': {} })).toEqual([{ type: 'custom', index: 1 }]) + }) + + it('falls back to exception when it is the only forward', () => { + expect(getDefaultSourceHandles({ exception: {} })).toEqual([{ type: 'exception', index: 1 }]) + }) + + it('returns no handles when forwards is empty', () => { + expect(getDefaultSourceHandles({})).toEqual([]) + }) + + it('returns no handles when forwards is missing', () => { + const missing: Record | undefined = undefined + expect(getDefaultSourceHandles(missing)).toEqual([]) + }) + + it('is consistent with resolveForwardsWithInheritance: a non-router pipe defaults to success', () => { + expect(getDefaultSourceHandles(resolveForwardsWithInheritance({ forwards: { exception: {} } }))).toEqual([ + { type: 'success', index: 1 }, + ]) + }) + + it('is consistent with resolveForwardsWithInheritance: a router pipe defaults to its first forward', () => { + const resolved = resolveForwardsWithInheritance({ + forwards: { exception: {}, notFound: {}, empty: {}, '*': {} }, + labels: { EIP: 'Router' }, + }) + expect(getDefaultSourceHandles(resolved)).toEqual([{ type: 'notFound', index: 1 }]) + }) +}) diff --git a/src/main/frontend/app/utils/frankdoc-utils.ts b/src/main/frontend/app/utils/frankdoc-utils.ts new file mode 100644 index 00000000..38f437b1 --- /dev/null +++ b/src/main/frontend/app/utils/frankdoc-utils.ts @@ -0,0 +1,30 @@ +import type { ElementProperty } from '@frankframework/doc-library-core' +import { getHandleTypes } from '~/hooks/use-handle-types' + +export interface SourceHandle { + type: string + index: number +} + +interface ForwardSource { + forwards?: Record + labels?: Record +} + +export function resolveForwardsWithInheritance( + element: ForwardSource | null | undefined, +): Record { + const forwards = element?.forwards ?? {} + + if (element?.labels?.EIP === 'Router') return forwards + + return { success: {}, ...forwards } +} + +export function getDefaultSourceHandles(resolvedForwards: Record | undefined): SourceHandle[] { + const handleTypes = getHandleTypes(resolvedForwards) + if (handleTypes.length === 0) return [] + + const defaultType = handleTypes.find((type) => type !== 'exception') ?? handleTypes[0] + return [{ type: defaultType, index: 1 }] +}