import { randomUUID } from 'node:crypto' import type { WorkflowGraph, WorkflowNode } from './workflows.ts' import { assertWorkflowGraphValid } from './workflows.ts' export interface UpdateWorkflowNodeInputOptions { version?: 'v3' strictUiCompatibility?: boolean strictReferenceCompatibility?: boolean strictAuthoring?: boolean } function cloneGraph(graph: WorkflowGraph): WorkflowGraph { return JSON.parse(JSON.stringify(graph)) as WorkflowGraph } function cloneInput(input: Record): Record { return JSON.parse(JSON.stringify(input)) as Record } function getInput(node: WorkflowNode): Record { if (!node.input || typeof node.input !== 'object') node.input = {} return node.input } function getCaseName(workflowCase: { name?: unknown }): string | undefined { return typeof workflowCase.name === 'string' && workflowCase.name.length > 0 ? workflowCase.name : undefined } function buildUniqueCasePortIdsByName(cases: Array<{ name?: unknown, portId?: unknown }>): Map { const counts = new Map() const portIds = new Map() for (const workflowCase of cases) { const name = getCaseName(workflowCase) if (!name) continue counts.set(name, (counts.get(name) ?? 0) + 1) if (typeof workflowCase.portId === 'string' && workflowCase.portId.length > 0) portIds.set(name, workflowCase.portId) } for (const [name, count] of counts.entries()) { if (count !== 1) portIds.delete(name) } return portIds } function syncConditionPorts(node: WorkflowNode, previousInput: Record): void { const input = getInput(node) as { cases?: Array<{ name?: unknown, portId?: unknown }> default?: { portId?: unknown } | null } const previous = previousInput as { cases?: Array<{ name?: unknown, portId?: unknown }> default?: { portId?: unknown } | null } const branchPortIds: string[] = [] const previousCasesByName = buildUniqueCasePortIdsByName(previous.cases ?? []) let createdNewCasePort = false for (const [index, workflowCase] of (input.cases ?? []).entries()) { let portId = typeof workflowCase.portId === 'string' && workflowCase.portId.length > 0 ? workflowCase.portId : undefined if (!portId) { const name = getCaseName(workflowCase) const previousNamedPortId = name ? previousCasesByName.get(name) : undefined if (previousNamedPortId) { if (createdNewCasePort) { throw new Error( `condition_case_port_id_required: condition node '${node.id}' patch prepends or inserts cases before existing case '${name}'. Provide explicit portId values for reordered/prepended cases.`, ) } portId = previousNamedPortId } else { const previousPortId = previous.cases?.[index]?.portId if (typeof previousPortId === 'string' && previousPortId.length > 0 && previous.cases?.[index]?.name === workflowCase.name) { portId = previousPortId } else { createdNewCasePort = true portId = randomUUID() } } workflowCase.portId = portId } branchPortIds.push(portId) } if (input.default) { let portId = typeof input.default.portId === 'string' && input.default.portId.length > 0 ? input.default.portId : undefined if (!portId) { const previousPortId = previous.default?.portId portId = typeof previousPortId === 'string' && previousPortId.length > 0 ? previousPortId : randomUUID() input.default.portId = portId } branchPortIds.push(portId) } const existingBranchPorts = new Map( (node.ports ?? []) .filter(port => port.dir === 'out' && port.type === 'branch') .map(port => [port.id, port]), ) const seen = new Set() node.ports = [ ...(node.ports ?? []).filter(port => port.dir === 'in'), ...branchPortIds.flatMap((portId) => { if (seen.has(portId)) return [] seen.add(portId) return [existingBranchPorts.get(portId) ?? { id: portId, dir: 'out', type: 'branch' }] }), ] } function cleanVariableMap(input: Record, allowedKeys?: string[]): void { if (!allowedKeys || !input.variables || typeof input.variables !== 'object') return const allowed = new Set(allowedKeys) const variables = input.variables as Record for (const key of Object.keys(variables)) { if (!allowed.has(key)) delete variables[key] } } export function updateWorkflowNodeInput( graph: WorkflowGraph, nodeIdOrName: string, patch: Record, options: UpdateWorkflowNodeInputOptions & { allowedVariableKeys?: string[] } = {}, ): WorkflowGraph { const next = cloneGraph(graph) const node = next.nodes.find(candidate => candidate.id === nodeIdOrName || candidate.name === nodeIdOrName) if (!node) { const available = next.nodes.map(candidate => `${candidate.name} (${candidate.id})`).join(', ') throw new Error(`updateWorkflowNodeInput: node '${nodeIdOrName}' not found. Available nodes: ${available}`) } if (node.actionId === 'stop') throw new Error('updateWorkflowNodeInput: stop nodes do not have editable input.') const input = getInput(node) const previousInput = cloneInput(input) const patchInput = cloneInput(patch) Object.assign(input, patchInput) if (node.actionId === 'condition') syncConditionPorts(node, previousInput) if (node.actionId === 'canvas' || node.actionId === 'template') cleanVariableMap(input, options.allowedVariableKeys) assertWorkflowGraphValid(next, { version: options.version ?? 'v3', strictUiCompatibility: options.strictUiCompatibility, strictReferenceCompatibility: options.strictReferenceCompatibility, strictAuthoring: options.strictAuthoring, }) return next }