mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-25 06:48:12 -05:00
Compare commits
2 Commits
fix/ci-com
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e13f6ee75 | ||
|
|
2950353952 |
@@ -10,6 +10,7 @@ import {
|
|||||||
loadWorkflowFromNormalizedTables,
|
loadWorkflowFromNormalizedTables,
|
||||||
saveWorkflowToNormalizedTables,
|
saveWorkflowToNormalizedTables,
|
||||||
} from '@/lib/workflows/db-helpers'
|
} from '@/lib/workflows/db-helpers'
|
||||||
|
import { updateBlockReferences } from '@/lib/workflows/reference-utils'
|
||||||
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
|
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
|
||||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||||
import { getAllBlocks, getBlock } from '@/blocks'
|
import { getAllBlocks, getBlock } from '@/blocks'
|
||||||
@@ -31,36 +32,6 @@ const YamlWorkflowRequestSchema = z.object({
|
|||||||
createCheckpoint: z.boolean().optional().default(false),
|
createCheckpoint: z.boolean().optional().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateBlockReferences(
|
|
||||||
value: any,
|
|
||||||
blockIdMapping: Map<string, string>,
|
|
||||||
requestId: string
|
|
||||||
): any {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
// Replace references in string values
|
|
||||||
for (const [oldId, newId] of blockIdMapping.entries()) {
|
|
||||||
if (value.includes(oldId)) {
|
|
||||||
value = value.replaceAll(`<${oldId}.`, `<${newId}.`).replaceAll(`%${oldId}.`, `%${newId}.`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((item) => updateBlockReferences(item, blockIdMapping, requestId))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
const result: Record<string, any> = {}
|
|
||||||
for (const [key, val] of Object.entries(value)) {
|
|
||||||
result[key] = updateBlockReferences(val, blockIdMapping, requestId)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to create a checkpoint before workflow changes
|
* Helper function to create a checkpoint before workflow changes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { memo, useMemo, useRef } from 'react'
|
import { memo, useMemo, useRef } from 'react'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Copy, Trash2 } from 'lucide-react'
|
||||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||||
import { StartIcon } from '@/components/icons'
|
import { StartIcon } from '@/components/icons'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||||
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
|
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
|
||||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
|
|
||||||
|
const logger = createLogger('SubflowNode')
|
||||||
|
|
||||||
const SubflowNodeStyles: React.FC = () => {
|
const SubflowNodeStyles: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
@@ -74,7 +77,7 @@ export interface SubflowNodeData {
|
|||||||
|
|
||||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||||
const { getNodes } = useReactFlow()
|
const { getNodes } = useReactFlow()
|
||||||
const { collaborativeRemoveBlock } = useCollaborativeWorkflow()
|
const { collaborativeRemoveBlock, collaborativeDuplicateSubflow } = useCollaborativeWorkflow()
|
||||||
const blockRef = useRef<HTMLDivElement>(null)
|
const blockRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
@@ -171,18 +174,37 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isPreview && (
|
{!isPreview && (
|
||||||
<Button
|
<div
|
||||||
variant='ghost'
|
className='absolute top-2 right-2 z-20 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100'
|
||||||
size='sm'
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
collaborativeRemoveBlock(id)
|
|
||||||
}}
|
|
||||||
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
|
|
||||||
style={{ pointerEvents: 'auto' }}
|
style={{ pointerEvents: 'auto' }}
|
||||||
>
|
>
|
||||||
<Trash2 className='h-4 w-4' />
|
<Button
|
||||||
</Button>
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
collaborativeDuplicateSubflow(id)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to duplicate subflow', { err })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='text-gray-500 hover:text-slate-900'
|
||||||
|
>
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
collaborativeRemoveBlock(id)
|
||||||
|
}}
|
||||||
|
className='text-gray-500 hover:text-red-600'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subflow Start */}
|
{/* Subflow Start */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
|||||||
import type { Edge } from 'reactflow'
|
import type { Edge } from 'reactflow'
|
||||||
import { useSession } from '@/lib/auth-client'
|
import { useSession } from '@/lib/auth-client'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import { updateBlockReferences } from '@/lib/workflows/reference-utils'
|
||||||
import { getBlock } from '@/blocks'
|
import { getBlock } from '@/blocks'
|
||||||
import { resolveOutputType } from '@/blocks/utils'
|
import { resolveOutputType } from '@/blocks/utils'
|
||||||
import { useSocket } from '@/contexts/socket-context'
|
import { useSocket } from '@/contexts/socket-context'
|
||||||
@@ -254,6 +255,75 @@ export function useCollaborativeWorkflow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'duplicate-with-children': {
|
||||||
|
// Apply a duplicated subflow subtree from a remote collaborator
|
||||||
|
const parent = payload.parent
|
||||||
|
const children = Array.isArray(payload.children) ? payload.children : []
|
||||||
|
const edges = Array.isArray(payload.edges) ? payload.edges : []
|
||||||
|
|
||||||
|
// Add parent block
|
||||||
|
workflowStore.addBlock(
|
||||||
|
parent.id,
|
||||||
|
parent.type,
|
||||||
|
parent.name,
|
||||||
|
parent.position,
|
||||||
|
parent.data,
|
||||||
|
parent.parentId,
|
||||||
|
parent.extent,
|
||||||
|
{
|
||||||
|
enabled: parent.enabled,
|
||||||
|
horizontalHandles: parent.horizontalHandles,
|
||||||
|
isWide: parent.isWide,
|
||||||
|
advancedMode: parent.advancedMode,
|
||||||
|
triggerMode: parent.triggerMode ?? false,
|
||||||
|
height: parent.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add children blocks
|
||||||
|
children.forEach((child: any) => {
|
||||||
|
workflowStore.addBlock(
|
||||||
|
child.id,
|
||||||
|
child.type,
|
||||||
|
child.name,
|
||||||
|
child.position,
|
||||||
|
child.data,
|
||||||
|
child.parentId,
|
||||||
|
child.extent,
|
||||||
|
{
|
||||||
|
enabled: child.enabled,
|
||||||
|
horizontalHandles: child.horizontalHandles,
|
||||||
|
isWide: child.isWide,
|
||||||
|
advancedMode: child.advancedMode,
|
||||||
|
triggerMode: child.triggerMode ?? false,
|
||||||
|
height: child.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply subblock values for collaborators to see immediately
|
||||||
|
if (child.subBlocks && typeof child.subBlocks === 'object') {
|
||||||
|
Object.entries(child.subBlocks).forEach(([subblockId, subblock]) => {
|
||||||
|
const value = (subblock as any)?.value
|
||||||
|
if (value !== undefined) {
|
||||||
|
subBlockStore.setValue(child.id, subblockId, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add internal edges
|
||||||
|
edges.forEach((edge: any) => {
|
||||||
|
workflowStore.addEdge({
|
||||||
|
id: edge.id,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
sourceHandle: edge.sourceHandle,
|
||||||
|
targetHandle: edge.targetHandle,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (target === 'variable') {
|
} else if (target === 'variable') {
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
@@ -1061,6 +1131,222 @@ export function useCollaborativeWorkflow() {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const collaborativeDuplicateSubflow = useCallback(
|
||||||
|
(subflowId: string) => {
|
||||||
|
if (isShowingDiff) {
|
||||||
|
logger.debug('Skipping subflow duplication in diff mode')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isInActiveRoom()) {
|
||||||
|
logger.debug('Skipping subflow duplication - not in active workflow', {
|
||||||
|
currentWorkflowId,
|
||||||
|
activeWorkflowId,
|
||||||
|
subflowId,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = workflowStore.blocks[subflowId]
|
||||||
|
if (!parent || (parent.type !== 'loop' && parent.type !== 'parallel')) return
|
||||||
|
|
||||||
|
const newParentId = crypto.randomUUID()
|
||||||
|
const parentOffsetPosition = {
|
||||||
|
x: parent.position.x + 250,
|
||||||
|
y: parent.position.y + 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name bump similar to duplicateBlock
|
||||||
|
// Build a set of existing names to ensure uniqueness across the workflow
|
||||||
|
const existingNames = new Set(Object.values(workflowStore.blocks).map((b) => b.name))
|
||||||
|
|
||||||
|
const match = parent.name.match(/(.*?)(\d+)?$/)
|
||||||
|
let newParentName = match?.[2]
|
||||||
|
? `${match[1]}${Number.parseInt(match[2]) + 1}`
|
||||||
|
: `${parent.name} 1`
|
||||||
|
if (existingNames.has(newParentName)) {
|
||||||
|
const base = match ? match[1] : `${parent.name} `
|
||||||
|
let idx = match?.[2] ? Number.parseInt(match[2]) + 1 : 1
|
||||||
|
while (existingNames.has(`${base}${idx}`)) idx++
|
||||||
|
newParentName = `${base}${idx}`
|
||||||
|
}
|
||||||
|
existingNames.add(newParentName)
|
||||||
|
|
||||||
|
// Collect children and internal edges
|
||||||
|
const allBlocks = workflowStore.blocks
|
||||||
|
const children = Object.values(allBlocks).filter((b) => b.data?.parentId === subflowId)
|
||||||
|
const childIdSet = new Set(children.map((c) => c.id))
|
||||||
|
const allEdges = workflowStore.edges
|
||||||
|
|
||||||
|
const startHandle = parent.type === 'loop' ? 'loop-start-source' : 'parallel-start-source'
|
||||||
|
const internalEdges = allEdges.filter(
|
||||||
|
(e) =>
|
||||||
|
(e.source === subflowId && e.sourceHandle === startHandle && childIdSet.has(e.target)) ||
|
||||||
|
(childIdSet.has(e.source) && childIdSet.has(e.target))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build ID map
|
||||||
|
const idMap = new Map<string, string>()
|
||||||
|
idMap.set(subflowId, newParentId)
|
||||||
|
children.forEach((c) => idMap.set(c.id, crypto.randomUUID()))
|
||||||
|
|
||||||
|
// Construct parent payload
|
||||||
|
const parentPayload: any = {
|
||||||
|
id: newParentId,
|
||||||
|
sourceId: subflowId,
|
||||||
|
type: parent.type,
|
||||||
|
name: newParentName,
|
||||||
|
position: parentOffsetPosition,
|
||||||
|
data: parent.data ? JSON.parse(JSON.stringify(parent.data)) : {},
|
||||||
|
subBlocks: {},
|
||||||
|
outputs: parent.outputs ? JSON.parse(JSON.stringify(parent.outputs)) : {},
|
||||||
|
parentId: parent.data?.parentId || null,
|
||||||
|
extent: parent.data?.extent || null,
|
||||||
|
enabled: parent.enabled ?? true,
|
||||||
|
horizontalHandles: parent.horizontalHandles ?? true,
|
||||||
|
isWide: parent.isWide ?? false,
|
||||||
|
advancedMode: parent.advancedMode ?? false,
|
||||||
|
triggerMode: false,
|
||||||
|
height: parent.height || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic add of parent
|
||||||
|
workflowStore.addBlock(
|
||||||
|
newParentId,
|
||||||
|
parent.type,
|
||||||
|
newParentName,
|
||||||
|
parentOffsetPosition,
|
||||||
|
parentPayload.data,
|
||||||
|
parentPayload.parentId,
|
||||||
|
parentPayload.extent,
|
||||||
|
{
|
||||||
|
enabled: parentPayload.enabled,
|
||||||
|
horizontalHandles: parentPayload.horizontalHandles,
|
||||||
|
isWide: parentPayload.isWide,
|
||||||
|
advancedMode: parentPayload.advancedMode,
|
||||||
|
triggerMode: false,
|
||||||
|
height: parentPayload.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build children payloads, copy subblocks with values and update references
|
||||||
|
const activeId = activeWorkflowId || ''
|
||||||
|
const subblockValuesForWorkflow = subBlockStore.workflowValues[activeId] || {}
|
||||||
|
|
||||||
|
const childPayloads = children.map((child) => {
|
||||||
|
const newId = idMap.get(child.id) as string
|
||||||
|
// Name bump logic identical to duplicateBlock
|
||||||
|
const childNameMatch = child.name.match(/(.*?)(\d+)?$/)
|
||||||
|
let newChildName = childNameMatch?.[2]
|
||||||
|
? `${childNameMatch[1]}${Number.parseInt(childNameMatch[2]) + 1}`
|
||||||
|
: `${child.name} 1`
|
||||||
|
if (existingNames.has(newChildName)) {
|
||||||
|
const base = childNameMatch ? childNameMatch[1] : `${child.name} `
|
||||||
|
let idx = childNameMatch?.[2] ? Number.parseInt(childNameMatch[2]) + 1 : 1
|
||||||
|
while (existingNames.has(`${base}${idx}`)) idx++
|
||||||
|
newChildName = `${base}${idx}`
|
||||||
|
}
|
||||||
|
existingNames.add(newChildName)
|
||||||
|
const clonedSubBlocks = child.subBlocks ? JSON.parse(JSON.stringify(child.subBlocks)) : {}
|
||||||
|
const values = subblockValuesForWorkflow[child.id] || {}
|
||||||
|
Object.entries(values).forEach(([subblockId, value]) => {
|
||||||
|
const processed = updateBlockReferences(value, idMap, 'duplicate-subflow')
|
||||||
|
if (!clonedSubBlocks[subblockId]) {
|
||||||
|
clonedSubBlocks[subblockId] = { id: subblockId, type: 'unknown', value: processed }
|
||||||
|
} else {
|
||||||
|
clonedSubBlocks[subblockId].value = processed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optimistic add child
|
||||||
|
workflowStore.addBlock(
|
||||||
|
newId,
|
||||||
|
child.type,
|
||||||
|
newChildName,
|
||||||
|
child.position,
|
||||||
|
{
|
||||||
|
...(child.data ? JSON.parse(JSON.stringify(child.data)) : {}),
|
||||||
|
parentId: newParentId,
|
||||||
|
extent: 'parent',
|
||||||
|
},
|
||||||
|
newParentId,
|
||||||
|
'parent',
|
||||||
|
{
|
||||||
|
enabled: child.enabled,
|
||||||
|
horizontalHandles: child.horizontalHandles,
|
||||||
|
isWide: child.isWide,
|
||||||
|
advancedMode: child.advancedMode,
|
||||||
|
triggerMode: child.triggerMode ?? false,
|
||||||
|
height: child.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply subblock values locally for immediate feedback
|
||||||
|
Object.entries(clonedSubBlocks).forEach(([subblockId, sub]) => {
|
||||||
|
const v = (sub as any)?.value
|
||||||
|
if (v !== undefined) {
|
||||||
|
subBlockStore.setValue(newId, subblockId, v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: newId,
|
||||||
|
sourceId: child.id,
|
||||||
|
type: child.type,
|
||||||
|
name: newChildName,
|
||||||
|
position: child.position,
|
||||||
|
data: {
|
||||||
|
...(child.data ? JSON.parse(JSON.stringify(child.data)) : {}),
|
||||||
|
parentId: newParentId,
|
||||||
|
extent: 'parent',
|
||||||
|
},
|
||||||
|
subBlocks: clonedSubBlocks,
|
||||||
|
outputs: child.outputs ? JSON.parse(JSON.stringify(child.outputs)) : {},
|
||||||
|
parentId: newParentId,
|
||||||
|
extent: 'parent',
|
||||||
|
enabled: child.enabled ?? true,
|
||||||
|
horizontalHandles: child.horizontalHandles ?? true,
|
||||||
|
isWide: child.isWide ?? false,
|
||||||
|
advancedMode: child.advancedMode ?? false,
|
||||||
|
triggerMode: child.triggerMode ?? false,
|
||||||
|
height: child.height || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Duplicate internal edges with remapped IDs
|
||||||
|
const edgePayloads = internalEdges.map((e) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
source: idMap.get(e.source) || e.source,
|
||||||
|
target: idMap.get(e.target) || e.target,
|
||||||
|
sourceHandle: e.sourceHandle,
|
||||||
|
targetHandle: e.targetHandle,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Optimistic add edges
|
||||||
|
edgePayloads.forEach((edge) => workflowStore.addEdge(edge))
|
||||||
|
|
||||||
|
// Queue server op
|
||||||
|
executeQueuedOperation(
|
||||||
|
'duplicate-with-children',
|
||||||
|
'subflow',
|
||||||
|
{
|
||||||
|
parent: parentPayload,
|
||||||
|
children: childPayloads,
|
||||||
|
edges: edgePayloads,
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isShowingDiff,
|
||||||
|
isInActiveRoom,
|
||||||
|
currentWorkflowId,
|
||||||
|
activeWorkflowId,
|
||||||
|
workflowStore,
|
||||||
|
subBlockStore,
|
||||||
|
executeQueuedOperation,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
const collaborativeUpdateLoopType = useCallback(
|
const collaborativeUpdateLoopType = useCallback(
|
||||||
(loopId: string, loopType: 'for' | 'forEach') => {
|
(loopId: string, loopType: 'for' | 'forEach') => {
|
||||||
const currentBlock = workflowStore.blocks[loopId]
|
const currentBlock = workflowStore.blocks[loopId]
|
||||||
@@ -1311,6 +1597,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
collaborativeRemoveEdge,
|
collaborativeRemoveEdge,
|
||||||
collaborativeSetSubblockValue,
|
collaborativeSetSubblockValue,
|
||||||
collaborativeSetTagSelection,
|
collaborativeSetTagSelection,
|
||||||
|
collaborativeDuplicateSubflow,
|
||||||
|
|
||||||
// Collaborative variable operations
|
// Collaborative variable operations
|
||||||
collaborativeUpdateVariable,
|
collaborativeUpdateVariable,
|
||||||
|
|||||||
47
apps/sim/lib/workflows/reference-utils.ts
Normal file
47
apps/sim/lib/workflows/reference-utils.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('WorkflowReferenceUtils')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively update block ID references in a value using a provided ID mapping.
|
||||||
|
* Handles strings, arrays, and objects. Strings are searched for `"<oldId."` and `"%oldId."` patterns.
|
||||||
|
*/
|
||||||
|
export function updateBlockReferences(
|
||||||
|
value: any,
|
||||||
|
blockIdMapping: Map<string, string>,
|
||||||
|
contextId?: string
|
||||||
|
): any {
|
||||||
|
try {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
let result = value
|
||||||
|
for (const [oldId, newId] of blockIdMapping.entries()) {
|
||||||
|
if (result.includes(oldId)) {
|
||||||
|
result = result
|
||||||
|
.replaceAll(`<${oldId}.`, `<${newId}.`)
|
||||||
|
.replaceAll(`%${oldId}.`, `%${newId}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => updateBlockReferences(item, blockIdMapping, contextId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
result[key] = updateBlockReferences(val, blockIdMapping, contextId)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to update block references', {
|
||||||
|
contextId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -868,6 +868,108 @@ async function handleSubflowOperationTx(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'duplicate-with-children': {
|
||||||
|
// Validate required structure
|
||||||
|
const parent = payload?.parent
|
||||||
|
const children = Array.isArray(payload?.children) ? payload.children : []
|
||||||
|
const edges = Array.isArray(payload?.edges) ? payload.edges : []
|
||||||
|
|
||||||
|
if (!parent || !parent.id || !parent.type || !parent.name || !parent.position) {
|
||||||
|
throw new Error('Invalid payload for subflow duplication: missing parent fields')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSubflowBlockType(parent.type)) {
|
||||||
|
throw new Error('Invalid subflow type for duplication')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert parent block
|
||||||
|
await tx.insert(workflowBlocks).values({
|
||||||
|
id: parent.id,
|
||||||
|
workflowId,
|
||||||
|
type: parent.type,
|
||||||
|
name: parent.name,
|
||||||
|
positionX: parent.position.x,
|
||||||
|
positionY: parent.position.y,
|
||||||
|
data: parent.data || {},
|
||||||
|
subBlocks: parent.subBlocks || {},
|
||||||
|
outputs: parent.outputs || {},
|
||||||
|
parentId: parent.parentId || null,
|
||||||
|
extent: parent.extent || null,
|
||||||
|
enabled: parent.enabled ?? true,
|
||||||
|
horizontalHandles: parent.horizontalHandles ?? true,
|
||||||
|
isWide: parent.isWide ?? false,
|
||||||
|
advancedMode: parent.advancedMode ?? false,
|
||||||
|
height: parent.height || 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create subflow entry for parent
|
||||||
|
const subflowConfig =
|
||||||
|
parent.type === SubflowType.LOOP
|
||||||
|
? {
|
||||||
|
id: parent.id,
|
||||||
|
nodes: [],
|
||||||
|
iterations: parent.data?.count || DEFAULT_LOOP_ITERATIONS,
|
||||||
|
loopType: parent.data?.loopType || 'for',
|
||||||
|
forEachItems: parent.data?.collection || '',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: parent.id,
|
||||||
|
nodes: [],
|
||||||
|
distribution: parent.data?.collection || '',
|
||||||
|
...(parent.data?.parallelType ? { parallelType: parent.data.parallelType } : {}),
|
||||||
|
...(parent.data?.count ? { count: parent.data.count } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(workflowSubflows).values({
|
||||||
|
id: parent.id,
|
||||||
|
workflowId,
|
||||||
|
type: parent.type,
|
||||||
|
config: subflowConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insert child blocks
|
||||||
|
for (const child of children) {
|
||||||
|
await tx.insert(workflowBlocks).values({
|
||||||
|
id: child.id,
|
||||||
|
workflowId,
|
||||||
|
type: child.type,
|
||||||
|
name: child.name,
|
||||||
|
positionX: child.position.x,
|
||||||
|
positionY: child.position.y,
|
||||||
|
data: child.data || {},
|
||||||
|
subBlocks: child.subBlocks || {},
|
||||||
|
outputs: child.outputs || {},
|
||||||
|
parentId: parent.id,
|
||||||
|
extent: 'parent',
|
||||||
|
enabled: child.enabled ?? true,
|
||||||
|
horizontalHandles: child.horizontalHandles ?? true,
|
||||||
|
isWide: child.isWide ?? false,
|
||||||
|
advancedMode: child.advancedMode ?? false,
|
||||||
|
height: child.height || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert internal edges
|
||||||
|
for (const edge of edges) {
|
||||||
|
await tx.insert(workflowEdges).values({
|
||||||
|
id: edge.id,
|
||||||
|
workflowId,
|
||||||
|
sourceBlockId: edge.source,
|
||||||
|
targetBlockId: edge.target,
|
||||||
|
sourceHandle: edge.sourceHandle || null,
|
||||||
|
targetHandle: edge.targetHandle || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update subflow node list with newly inserted children
|
||||||
|
await updateSubflowNodeList(tx, workflowId, parent.id)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[SERVER] Duplicated subflow subtree ${parent.id} with ${children.length} children and ${edges.length} edges`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// Add other subflow operations as needed
|
// Add other subflow operations as needed
|
||||||
default:
|
default:
|
||||||
logger.warn(`Unknown subflow operation: ${operation}`)
|
logger.warn(`Unknown subflow operation: ${operation}`)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export async function verifyOperationPermission(
|
|||||||
'update-trigger-mode',
|
'update-trigger-mode',
|
||||||
'toggle-handles',
|
'toggle-handles',
|
||||||
'duplicate',
|
'duplicate',
|
||||||
|
'duplicate-with-children',
|
||||||
],
|
],
|
||||||
write: [
|
write: [
|
||||||
'add',
|
'add',
|
||||||
@@ -119,6 +120,7 @@ export async function verifyOperationPermission(
|
|||||||
'update-trigger-mode',
|
'update-trigger-mode',
|
||||||
'toggle-handles',
|
'toggle-handles',
|
||||||
'duplicate',
|
'duplicate',
|
||||||
|
'duplicate-with-children',
|
||||||
],
|
],
|
||||||
read: ['update-position'], // Read-only users can only move things around
|
read: ['update-position'], // Read-only users can only move things around
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,18 +67,59 @@ export const EdgeOperationSchema = z.object({
|
|||||||
operationId: z.string().optional(),
|
operationId: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const SubflowOperationSchema = z.object({
|
// Shared schemas for subflow duplication
|
||||||
operation: z.enum(['add', 'remove', 'update']),
|
const BlockInsertPayloadSchema = z.object({
|
||||||
target: z.literal('subflow'),
|
id: z.string(),
|
||||||
payload: z.object({
|
sourceId: z.string().optional(),
|
||||||
id: z.string(),
|
type: z.string(),
|
||||||
type: z.enum(['loop', 'parallel']).optional(),
|
name: z.string(),
|
||||||
config: z.record(z.any()).optional(),
|
position: PositionSchema,
|
||||||
}),
|
data: z.record(z.any()).optional(),
|
||||||
timestamp: z.number(),
|
subBlocks: z.record(z.any()).optional(),
|
||||||
operationId: z.string().optional(),
|
outputs: z.record(z.any()).optional(),
|
||||||
|
parentId: z.string().nullable().optional(),
|
||||||
|
extent: z.enum(['parent']).nullable().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
horizontalHandles: z.boolean().optional(),
|
||||||
|
isWide: z.boolean().optional(),
|
||||||
|
advancedMode: z.boolean().optional(),
|
||||||
|
triggerMode: z.boolean().optional(),
|
||||||
|
height: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const EdgeInsertPayloadSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
source: z.string(),
|
||||||
|
target: z.string(),
|
||||||
|
sourceHandle: z.string().nullable().optional(),
|
||||||
|
targetHandle: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SubflowOperationSchema = z.union([
|
||||||
|
z.object({
|
||||||
|
operation: z.literal('update'),
|
||||||
|
target: z.literal('subflow'),
|
||||||
|
payload: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.enum(['loop', 'parallel']).optional(),
|
||||||
|
config: z.record(z.any()).optional(),
|
||||||
|
}),
|
||||||
|
timestamp: z.number(),
|
||||||
|
operationId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
operation: z.literal('duplicate-with-children'),
|
||||||
|
target: z.literal('subflow'),
|
||||||
|
payload: z.object({
|
||||||
|
parent: BlockInsertPayloadSchema,
|
||||||
|
children: z.array(BlockInsertPayloadSchema),
|
||||||
|
edges: z.array(EdgeInsertPayloadSchema),
|
||||||
|
}),
|
||||||
|
timestamp: z.number(),
|
||||||
|
operationId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
export const VariableOperationSchema = z.union([
|
export const VariableOperationSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
operation: z.literal('add'),
|
operation: z.literal('add'),
|
||||||
|
|||||||
Reference in New Issue
Block a user