@@ -705,6 +705,9 @@ export function filterDisallowedTools(
705705 * The LLM may generate human-readable IDs like "web_search" or "research_agent"
706706 * which need to be converted to proper UUIDs for database compatibility.
707707 *
708+ * Runs in two passes: every id is claimed before any reference is rewritten, so a
709+ * reference can point at an id claimed by a later operation.
710+ *
708711 * Returns the normalized operations and a mapping from old IDs to new UUIDs.
709712 */
710713export function normalizeBlockIdsInOperations ( operations : EditWorkflowOperation [ ] ) : {
@@ -714,14 +717,38 @@ export function normalizeBlockIdsInOperations(operations: EditWorkflowOperation[
714717 const logger = createLogger ( 'EditWorkflowServerTool' )
715718 const idMapping = new Map < string , string > ( )
716719
717- // First pass: collect all non-UUID block_ids from add/insert operations
720+ const claimId = ( id : string | undefined ) => {
721+ if ( ! id || isValidUuid ( id ) || idMapping . has ( id ) ) return
722+ const newId = generateId ( )
723+ idMapping . set ( id , newId )
724+ logger . debug ( 'Normalizing block ID' , { oldId : id , newId } )
725+ }
726+
727+ /**
728+ * Children arrive keyed by the model's own handle under `params.nestedNodes`,
729+ * and containers nest arbitrarily deep. Every level must be claimed here or the
730+ * handle is persisted verbatim as `workflow_blocks.id`, which is a global
731+ * primary key — a name another workflow already stored collides on insert.
732+ *
733+ * Only reached for `add`/`insert_into_subflow`. `edit` also creates children via
734+ * `mergeNestedNodesForParent`, but there a child matching an existing block by
735+ * *name* keeps that block's id, so claiming its handle would repoint sibling
736+ * references at an id no block was created under. That path needs the id minted
737+ * at creation with an alias recorded for reference resolution, not a wider gate
738+ * here.
739+ */
740+ const claimNestedNodeIds = ( nestedNodes : Record < string , any > | undefined ) => {
741+ if ( ! nestedNodes ) return
742+ for ( const [ childId , childBlock ] of Object . entries ( nestedNodes ) ) {
743+ claimId ( childId )
744+ claimNestedNodeIds ( childBlock ?. nestedNodes )
745+ }
746+ }
747+
718748 for ( const op of operations ) {
719749 if ( op . operation_type === 'add' || op . operation_type === 'insert_into_subflow' ) {
720- if ( op . block_id && ! isValidUuid ( op . block_id ) ) {
721- const newId = generateId ( )
722- idMapping . set ( op . block_id , newId )
723- logger . debug ( 'Normalizing block ID' , { oldId : op . block_id , newId } )
724- }
750+ claimId ( op . block_id )
751+ claimNestedNodeIds ( op . params ?. nestedNodes )
725752 }
726753 }
727754
@@ -740,7 +767,52 @@ export function normalizeBlockIdsInOperations(operations: EditWorkflowOperation[
740767 return idMapping . get ( id ) ?? id
741768 }
742769
743- // Second pass: update all references to use new UUIDs
770+ const normalizeConnections = ( connections : Record < string , any > ) : Record < string , any > => {
771+ const normalizedConnections : Record < string , any > = { }
772+ for ( const [ handle , targets ] of Object . entries < any > ( connections ) ) {
773+ if ( typeof targets === 'string' ) {
774+ normalizedConnections [ handle ] = replaceId ( targets )
775+ } else if ( Array . isArray ( targets ) ) {
776+ normalizedConnections [ handle ] = targets . map ( ( t ) => {
777+ if ( typeof t === 'string' ) return replaceId ( t )
778+ if ( t && typeof t === 'object' && t . block ) {
779+ return { ...t , block : replaceId ( t . block ) }
780+ }
781+ return t
782+ } )
783+ } else if ( targets && typeof targets === 'object' && targets . block ) {
784+ normalizedConnections [ handle ] = { ...targets , block : replaceId ( targets . block ) }
785+ } else {
786+ normalizedConnections [ handle ] = targets
787+ }
788+ }
789+ return normalizedConnections
790+ }
791+
792+ /**
793+ * A child's `connections` may name sibling children, not just top-level blocks,
794+ * so nested references resolve through the same flat id mapping.
795+ */
796+ const normalizeNestedNodes = ( nestedNodes : Record < string , any > ) : Record < string , any > => {
797+ const normalizedNestedNodes : Record < string , any > = { }
798+ for ( const [ childId , childBlock ] of Object . entries < any > ( nestedNodes ) ) {
799+ const newChildId = replaceId ( childId ) ?? childId
800+ normalizedNestedNodes [ newChildId ] =
801+ childBlock && typeof childBlock === 'object'
802+ ? {
803+ ...childBlock ,
804+ ...( childBlock . connections && {
805+ connections : normalizeConnections ( childBlock . connections ) ,
806+ } ) ,
807+ ...( childBlock . nestedNodes && {
808+ nestedNodes : normalizeNestedNodes ( childBlock . nestedNodes ) ,
809+ } ) ,
810+ }
811+ : childBlock
812+ }
813+ return normalizedNestedNodes
814+ }
815+
744816 const normalizedOperations = operations . map ( ( op ) => {
745817 const normalized : EditWorkflowOperation = {
746818 ...op ,
@@ -755,37 +827,12 @@ export function normalizeBlockIdsInOperations(operations: EditWorkflowOperation[
755827 normalized . params . subflowId = replaceId ( normalized . params . subflowId )
756828 }
757829
758- // Update connection references
759830 if ( normalized . params . connections ) {
760- const normalizedConnections : Record < string , any > = { }
761- for ( const [ handle , targets ] of Object . entries ( normalized . params . connections ) ) {
762- if ( typeof targets === 'string' ) {
763- normalizedConnections [ handle ] = replaceId ( targets )
764- } else if ( Array . isArray ( targets ) ) {
765- normalizedConnections [ handle ] = targets . map ( ( t ) => {
766- if ( typeof t === 'string' ) return replaceId ( t )
767- if ( t && typeof t === 'object' && t . block ) {
768- return { ...t , block : replaceId ( t . block ) }
769- }
770- return t
771- } )
772- } else if ( targets && typeof targets === 'object' && ( targets as any ) . block ) {
773- normalizedConnections [ handle ] = { ...targets , block : replaceId ( ( targets as any ) . block ) }
774- } else {
775- normalizedConnections [ handle ] = targets
776- }
777- }
778- normalized . params . connections = normalizedConnections
831+ normalized . params . connections = normalizeConnections ( normalized . params . connections )
779832 }
780833
781- // Update nestedNodes block IDs
782834 if ( normalized . params . nestedNodes ) {
783- const normalizedNestedNodes : Record < string , any > = { }
784- for ( const [ childId , childBlock ] of Object . entries ( normalized . params . nestedNodes ) ) {
785- const newChildId = replaceId ( childId ) ?? childId
786- normalizedNestedNodes [ newChildId ] = childBlock
787- }
788- normalized . params . nestedNodes = normalizedNestedNodes
835+ normalized . params . nestedNodes = normalizeNestedNodes ( normalized . params . nestedNodes )
789836 }
790837 }
791838
0 commit comments