Compare commits

...

1 Commits

Author SHA1 Message Date
Adam Gough
6f450c4f8e added while subflow without execution logic 2025-08-29 19:54:04 -07:00
72 changed files with 1148 additions and 160 deletions

View File

@@ -143,6 +143,7 @@ export const sampleWorkflowState = {
],
loops: {},
parallels: {},
whiles: {},
lastSaved: Date.now(),
isDeployed: false,
}

View File

@@ -420,7 +420,7 @@ export async function executeWorkflowForChat(
// Use deployed state for chat execution (this is the stable, deployed version)
const deployedState = workflowResult[0].deployedState as WorkflowState
const { blocks, edges, loops, parallels } = deployedState
const { blocks, edges, loops, parallels, whiles } = deployedState
// Prepare for execution, similar to use-workflow-execution.ts
const mergedStates = mergeSubblockState(blocks)
@@ -497,6 +497,7 @@ export async function executeWorkflowForChat(
filteredEdges,
loops,
parallels,
whiles,
true // Enable validation during execution
)

View File

@@ -71,6 +71,7 @@ export async function POST(request: NextRequest) {
edges: checkpointState?.edges || [],
loops: checkpointState?.loops || {},
parallels: checkpointState?.parallels || {},
whiles: checkpointState?.whiles || {},
isDeployed: checkpointState?.isDeployed || false,
deploymentStatuses: checkpointState?.deploymentStatuses || {},
hasActiveWebhook: checkpointState?.hasActiveWebhook || false,

View File

@@ -23,6 +23,7 @@ describe('Scheduled Workflow Execution API Route', () => {
edges: sampleWorkflowState.edges || [],
loops: sampleWorkflowState.loops || {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}),
}))

View File

@@ -230,6 +230,7 @@ export async function GET() {
const edges = normalizedData.edges
const loops = normalizedData.loops
const parallels = normalizedData.parallels
const whiles = normalizedData.whiles
logger.info(
`[${requestId}] Loaded scheduled workflow ${schedule.workflowId} from normalized tables`
)
@@ -384,6 +385,7 @@ export async function GET() {
edges,
loops,
parallels,
whiles,
true // Enable validation during execution
)

View File

@@ -68,6 +68,7 @@ const CreateTemplateSchema = z.object({
edges: z.array(z.any()),
loops: z.record(z.any()),
parallels: z.record(z.any()),
whiles: z.record(z.any()),
}),
})

View File

@@ -153,6 +153,7 @@ describe('Webhook Trigger API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}),
}))

View File

@@ -11,7 +11,11 @@ import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { db } from '@/db'
import { workflow as workflowTable } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
export const dynamic = 'force-dynamic'
@@ -125,6 +129,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edges: currentWorkflowData.edges,
loops: currentWorkflowData.loops || {},
parallels: currentWorkflowData.parallels || {},
whiles: currentWorkflowData.whiles || {},
}
const autoLayoutOptions = {
@@ -166,6 +171,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
utilities: {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
},
},

View File

@@ -69,6 +69,7 @@ describe('Workflow Deployment API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}),
}))

View File

@@ -109,6 +109,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
whiles: normalizedData.whiles,
}
const { hasWorkflowChanged } = await import('@/lib/workflows/utils')
@@ -192,6 +193,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const blocksMap: Record<string, any> = {}
const loops: Record<string, any> = {}
const parallels: Record<string, any> = {}
const whiles: Record<string, any> = {}
// Process blocks
blocks.forEach((block) => {
@@ -206,7 +208,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
})
// Process subflows (loops and parallels)
// Process subflows (loops, parallels, and whiles)
subflows.forEach((subflow) => {
const config = (subflow.config as any) || {}
if (subflow.type === 'loop') {
@@ -225,6 +227,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
} else if (subflow.type === 'while') {
whiles[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterations: config.iterations || 1,
whileType: config.whileType || 'while',
}
}
})
@@ -244,6 +253,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edges: edgesArray,
loops,
parallels,
whiles,
lastSaved: Date.now(),
}

View File

@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
import type { LoopConfig, ParallelConfig, WhileConfig } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDuplicateAPI')
@@ -209,16 +209,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
// Update block references in subflow config
let updatedConfig: LoopConfig | ParallelConfig = subflow.config as
let updatedConfig: LoopConfig | ParallelConfig | WhileConfig = subflow.config as
| LoopConfig
| ParallelConfig
| WhileConfig
if (subflow.config && typeof subflow.config === 'object') {
updatedConfig = JSON.parse(JSON.stringify(subflow.config)) as
| LoopConfig
| ParallelConfig
| WhileConfig
// Update the config ID to match the new subflow ID
;(updatedConfig as any).id = newSubflowId
// Update node references in config if they exist

View File

@@ -121,6 +121,7 @@ describe('Workflow Execution API Route', () => {
],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: false, // Changed to false since it's from deployed state
}),
}))
@@ -559,6 +560,7 @@ describe('Workflow Execution API Route', () => {
],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: false, // Changed to false since it's from deployed state
}),
}))

View File

@@ -115,13 +115,14 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any): P
const deployedData = await loadDeployedWorkflowState(workflowId)
// Use deployed data as primary source for API executions
const { blocks, edges, loops, parallels } = deployedData
const { blocks, edges, loops, parallels, whiles } = deployedData
logger.info(`[${requestId}] Using deployed state for workflow execution: ${workflowId}`)
logger.debug(`[${requestId}] Deployed data loaded:`, {
blocksCount: Object.keys(blocks || {}).length,
edgesCount: (edges || []).length,
loopsCount: Object.keys(loops || {}).length,
parallelsCount: Object.keys(parallels || {}).length,
whilesCount: Object.keys(whiles || {}).length,
})
// Use the same execution flow as in scheduled executions
@@ -275,6 +276,7 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any): P
edges,
loops,
parallels,
whiles,
true // Enable validation during execution
)

View File

@@ -52,6 +52,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edgesCount: deployedState.edges.length,
loopsCount: Object.keys(deployedState.loops || {}).length,
parallelsCount: Object.keys(deployedState.parallels || {}).length,
whilesCount: Object.keys(deployedState.whiles || {}).length,
})
// Save deployed state to normalized tables
@@ -60,6 +61,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
edges: deployedState.edges,
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
whiles: deployedState.whiles || {},
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,

View File

@@ -96,6 +96,7 @@ describe('Workflow By ID API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}
@@ -145,6 +146,7 @@ describe('Workflow By ID API Route', () => {
edges: [],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}
@@ -241,6 +243,7 @@ describe('Workflow By ID API Route', () => {
edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }],
loops: {},
parallels: {},
whiles: {},
isFromNormalizedTables: true,
}

View File

@@ -126,6 +126,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edgesCount: normalizedData.edges.length,
loopsCount: Object.keys(normalizedData.loops).length,
parallelsCount: Object.keys(normalizedData.parallels).length,
whilesCount: Object.keys(normalizedData.whiles).length,
loops: normalizedData.loops,
})
@@ -141,6 +142,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
whiles: normalizedData.whiles,
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,

View File

@@ -24,6 +24,7 @@ const BlockDataSchema = z.object({
count: z.number().optional(),
loopType: z.enum(['for', 'forEach']).optional(),
parallelType: z.enum(['collection', 'count']).optional(),
whileType: z.enum(['while', 'doWhile']).optional(),
type: z.string().optional(),
})
@@ -87,6 +88,13 @@ const ParallelSchema = z.object({
parallelType: z.enum(['count', 'collection']).optional(),
})
const WhileSchema = z.object({
id: z.string(),
nodes: z.array(z.string()),
iterations: z.number(),
whileType: z.enum(['while', 'doWhile']),
})
const DeploymentStatusSchema = z.object({
id: z.string(),
status: z.enum(['deploying', 'deployed', 'failed', 'stopping', 'stopped']),
@@ -99,6 +107,7 @@ const WorkflowStateSchema = z.object({
edges: z.array(EdgeSchema),
loops: z.record(LoopSchema).optional(),
parallels: z.record(ParallelSchema).optional(),
whiles: z.record(WhileSchema).optional(),
lastSaved: z.number().optional(),
isDeployed: z.boolean().optional(),
deployedAt: z.date().optional(),
@@ -197,6 +206,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
whiles: state.whiles || {},
lastSaved: state.lastSaved || Date.now(),
isDeployed: state.isDeployed || false,
deployedAt: state.deployedAt,
@@ -231,6 +241,9 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
success: true,
blocksCount: Object.keys(filteredBlocks).length,
edgesCount: state.edges.length,
loopsCount: Object.keys(state.loops || {}).length,
parallelsCount: Object.keys(state.parallels || {}).length,
whilesCount: Object.keys(state.whiles || {}).length,
},
{ status: 200 }
)

View File

@@ -38,7 +38,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const blocksMap: Record<string, any> = {}
const loops: Record<string, any> = {}
const parallels: Record<string, any> = {}
const whiles: Record<string, any> = {}
// Process blocks
blocks.forEach((block) => {
blocksMap[block.id] = {
@@ -71,6 +71,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
} else if (subflow.type === 'while') {
whiles[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterations: config.iterations || 1,
whileType: config.whileType || 'while',
}
}
})
@@ -90,6 +97,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
edges: edgesArray,
loops,
parallels,
whiles,
lastSaved: Date.now(),
}

View File

@@ -16,7 +16,11 @@ import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { db } from '@/db'
import { workflowCheckpoints, workflow as workflowTable } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
@@ -80,6 +84,7 @@ async function createWorkflowCheckpoint(
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})
@@ -293,6 +298,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
options: {
generateNewIds: false, // We'll handle ID generation manually for now
@@ -373,6 +379,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edges: [] as any[],
loops: {} as Record<string, any>,
parallels: {} as Record<string, any>,
whiles: {} as Record<string, any>,
lastSaved: Date.now(),
isDeployed: false,
deployedAt: undefined,
@@ -391,7 +398,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Get block configuration for proper setup
const blockConfig = getBlock(block.type)
if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) {
if (
!blockConfig &&
(block.type === 'loop' || block.type === 'parallel' || block.type === 'while')
) {
// Handle loop/parallel blocks (they don't have regular block configs)
// Preserve parentId if it exists (though loop/parallel shouldn't have parents)
const containerData = block.data || {}
@@ -414,7 +424,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
height: 0,
data: containerData,
}
logger.debug(`[${requestId}] Processed loop/parallel block: ${block.id} -> ${newId}`)
logger.debug(`[${requestId}] Processed loop/parallel/while block: ${block.id} -> ${newId}`)
} else if (blockConfig) {
// Handle regular blocks with proper configuration
const subBlocks: Record<string, any> = {}
@@ -545,14 +555,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
// Generate loop and parallel configurations
const loops = generateLoopBlocks(newWorkflowState.blocks)
const parallels = generateParallelBlocks(newWorkflowState.blocks)
const whiles = generateWhileBlocks(newWorkflowState.blocks)
newWorkflowState.loops = loops
newWorkflowState.parallels = parallels
newWorkflowState.whiles = whiles
logger.info(`[${requestId}] Generated workflow state`, {
blocksCount: Object.keys(newWorkflowState.blocks).length,
edgesCount: newWorkflowState.edges.length,
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
})
// Apply intelligent autolayout if requested
@@ -566,6 +579,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edges: newWorkflowState.edges,
loops: newWorkflowState.loops || {},
parallels: newWorkflowState.parallels || {},
whiles: newWorkflowState.whiles || {},
}
const autoLayoutOptions = {
@@ -608,6 +622,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
},
})
@@ -685,6 +700,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
edgesCount: newWorkflowState.edges.length,
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
},
errors: [],
warnings,

View File

@@ -4,7 +4,11 @@ import { simAgentClient } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowYamlAPI')
@@ -50,6 +54,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
},
})

View File

@@ -10,7 +10,11 @@ import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { db } from '@/db'
import { workflow } from '@/db/schema'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowYamlExportAPI')
@@ -144,6 +148,7 @@ export async function GET(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
},
})

View File

@@ -9,10 +9,12 @@ import { resolveOutputType } from '@/blocks/utils'
import {
convertLoopBlockToLoop,
convertParallelBlockToParallel,
convertWhileBlockToWhile,
findAllDescendantNodes,
findChildNodes,
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlAutoLayoutAPI')
@@ -26,6 +28,7 @@ const AutoLayoutRequestSchema = z.object({
edges: z.array(z.any()),
loops: z.record(z.any()).optional().default({}),
parallels: z.record(z.any()).optional().default({}),
whiles: z.record(z.any()).optional().default({}),
}),
options: z
.object({
@@ -36,6 +39,7 @@ const AutoLayoutRequestSchema = z.object({
horizontal: z.number().optional(),
vertical: z.number().optional(),
layer: z.number().optional(),
while: z.number().optional(),
})
.optional(),
alignment: z.enum(['start', 'center', 'end']).optional(),
@@ -45,6 +49,12 @@ const AutoLayoutRequestSchema = z.object({
y: z.number().optional(),
})
.optional(),
while: z
.object({
x: z.number().optional(),
y: z.number().optional(),
})
.optional(),
})
.optional(),
})
@@ -133,8 +143,10 @@ export async function POST(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
convertLoopBlockToLoop: convertLoopBlockToLoop.toString(),
convertParallelBlockToParallel: convertParallelBlockToParallel.toString(),
convertWhileBlockToWhile: convertWhileBlockToWhile.toString(),
findChildNodes: findChildNodes.toString(),
findAllDescendantNodes: findAllDescendantNodes.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})
@@ -192,6 +204,7 @@ export async function POST(request: NextRequest) {
edges: workflowState.edges || [],
loops: workflowState.loops || {},
parallels: workflowState.parallels || {},
whiles: workflowState.whiles || {},
},
errors: result.errors,
}

View File

@@ -9,10 +9,12 @@ import { resolveOutputType } from '@/blocks/utils'
import {
convertLoopBlockToLoop,
convertParallelBlockToParallel,
convertWhileBlockToWhile,
findAllDescendantNodes,
findChildNodes,
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlDiffCreateAPI')
@@ -130,8 +132,10 @@ export async function POST(request: NextRequest) {
resolveOutputType: resolveOutputType.toString(),
convertLoopBlockToLoop: convertLoopBlockToLoop.toString(),
convertParallelBlockToParallel: convertParallelBlockToParallel.toString(),
convertWhileBlockToWhile: convertWhileBlockToWhile.toString(),
findChildNodes: findChildNodes.toString(),
findAllDescendantNodes: findAllDescendantNodes.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
options,
}),
@@ -168,7 +172,7 @@ export async function POST(request: NextRequest) {
dataKeys: block.data ? Object.keys(block.data) : [],
})
}
if (block.type === 'loop' || block.type === 'parallel') {
if (block.type === 'loop' || block.type === 'parallel' || block.type === 'while') {
logger.info(`[${requestId}] Container block ${blockId} (${block.name}):`, {
type: block.type,
hasData: !!block.data,
@@ -180,8 +184,10 @@ export async function POST(request: NextRequest) {
// Log existing loops/parallels from sim-agent
const loops = result.diff?.proposedState?.loops || result.loops || {}
const parallels = result.diff?.proposedState?.parallels || result.parallels || {}
const whiles = result.diff?.proposedState?.whiles || result.whiles || {}
logger.info(`[${requestId}] Sim agent loops:`, loops)
logger.info(`[${requestId}] Sim agent parallels:`, parallels)
logger.info(`[${requestId}] Sim agent whiles:`, whiles)
}
// Log diff analysis specifically
@@ -207,7 +213,7 @@ export async function POST(request: NextRequest) {
// Find all loop and parallel blocks
const containerBlocks = Object.values(blocks).filter(
(block: any) => block.type === 'loop' || block.type === 'parallel'
(block: any) => block.type === 'loop' || block.type === 'parallel' || block.type === 'while'
)
// For each container, find its children based on loop-start edges
@@ -251,17 +257,23 @@ export async function POST(request: NextRequest) {
// Now regenerate loops and parallels with the fixed relationships
const loops = generateLoopBlocks(result.diff.proposedState.blocks)
const parallels = generateParallelBlocks(result.diff.proposedState.blocks)
const whiles = generateWhileBlocks(result.diff.proposedState.blocks)
result.diff.proposedState.loops = loops
result.diff.proposedState.parallels = parallels
result.diff.proposedState.whiles = whiles
logger.info(`[${requestId}] Regenerated loops and parallels after fixing parent-child:`, {
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
loops: Object.keys(loops).map((id) => ({
id,
nodes: loops[id].nodes,
})),
whiles: Object.keys(whiles).map((id) => ({
id,
nodes: whiles[id].nodes,
})),
})
}
@@ -309,7 +321,7 @@ export async function POST(request: NextRequest) {
// Generate loops and parallels for the blocks with fixed relationships
const loops = generateLoopBlocks(result.blocks)
const parallels = generateParallelBlocks(result.blocks)
const whiles = generateWhileBlocks(result.blocks)
const transformedResult = {
success: result.success,
diff: {
@@ -318,6 +330,7 @@ export async function POST(request: NextRequest) {
edges: result.edges || [],
loops: loops,
parallels: parallels,
whiles: whiles,
},
diffAnalysis: diffAnalysis,
metadata: result.metadata || {

View File

@@ -9,10 +9,12 @@ import { resolveOutputType } from '@/blocks/utils'
import {
convertLoopBlockToLoop,
convertParallelBlockToParallel,
convertWhileBlockToWhile,
findAllDescendantNodes,
findChildNodes,
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlDiffMergeAPI')
@@ -27,6 +29,7 @@ const MergeDiffRequestSchema = z.object({
edges: z.array(z.any()),
loops: z.record(z.any()).optional(),
parallels: z.record(z.any()).optional(),
whiles: z.record(z.any()).optional(),
}),
diffAnalysis: z.any().optional(),
metadata: z.object({
@@ -103,6 +106,8 @@ export async function POST(request: NextRequest) {
convertParallelBlockToParallel: convertParallelBlockToParallel.toString(),
findChildNodes: findChildNodes.toString(),
findAllDescendantNodes: findAllDescendantNodes.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
convertWhileBlockToWhile: convertWhileBlockToWhile.toString(),
},
options,
}),
@@ -139,7 +144,7 @@ export async function POST(request: NextRequest) {
dataKeys: block.data ? Object.keys(block.data) : [],
})
}
if (block.type === 'loop' || block.type === 'parallel') {
if (block.type === 'loop' || block.type === 'parallel' || block.type === 'while') {
logger.info(`[${requestId}] Container block ${blockId} (${block.name}):`, {
type: block.type,
hasData: !!block.data,
@@ -151,8 +156,10 @@ export async function POST(request: NextRequest) {
// Log existing loops/parallels from sim-agent
const loops = result.diff?.proposedState?.loops || result.loops || {}
const parallels = result.diff?.proposedState?.parallels || result.parallels || {}
const whiles = result.diff?.proposedState?.whiles || result.whiles || {}
logger.info(`[${requestId}] Sim agent loops:`, loops)
logger.info(`[${requestId}] Sim agent parallels:`, parallels)
logger.info(`[${requestId}] Sim agent whiles:`, whiles)
}
// Post-process the result to ensure loops and parallels are properly generated
@@ -165,13 +172,16 @@ export async function POST(request: NextRequest) {
// Find all loop and parallel blocks
const containerBlocks = Object.values(blocks).filter(
(block: any) => block.type === 'loop' || block.type === 'parallel'
(block: any) => block.type === 'loop' || block.type === 'parallel' || block.type === 'while'
)
// For each container, find its children based on loop-start edges
containerBlocks.forEach((container: any) => {
const childEdges = edges.filter(
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
(edge: any) =>
edge.source === container.id &&
(edge.sourceHandle === 'loop-start-source' ||
edge.sourceHandle === 'while-start-source')
)
childEdges.forEach((edge: any) => {
@@ -198,17 +208,23 @@ export async function POST(request: NextRequest) {
// Now regenerate loops and parallels with the fixed relationships
const loops = generateLoopBlocks(result.diff.proposedState.blocks)
const parallels = generateParallelBlocks(result.diff.proposedState.blocks)
const whiles = generateWhileBlocks(result.diff.proposedState.blocks)
result.diff.proposedState.loops = loops
result.diff.proposedState.parallels = parallels
result.diff.proposedState.whiles = whiles
logger.info(`[${requestId}] Regenerated loops and parallels after fixing parent-child:`, {
loopsCount: Object.keys(loops).length,
parallelsCount: Object.keys(parallels).length,
whilesCount: Object.keys(whiles).length,
loops: Object.keys(loops).map((id) => ({
id,
nodes: loops[id].nodes,
})),
whiles: Object.keys(whiles).map((id) => ({
id,
nodes: whiles[id].nodes,
})),
})
}
@@ -223,13 +239,16 @@ export async function POST(request: NextRequest) {
// Find all loop and parallel blocks
const containerBlocks = Object.values(blocks).filter(
(block: any) => block.type === 'loop' || block.type === 'parallel'
(block: any) => block.type === 'loop' || block.type === 'parallel' || block.type === 'while'
)
// For each container, find its children based on loop-start edges
containerBlocks.forEach((container: any) => {
const childEdges = edges.filter(
(edge: any) => edge.source === container.id && edge.sourceHandle === 'loop-start-source'
(edge: any) =>
edge.source === container.id &&
(edge.sourceHandle === 'loop-start-source' ||
edge.sourceHandle === 'while-start-source')
)
childEdges.forEach((edge: any) => {
@@ -256,7 +275,7 @@ export async function POST(request: NextRequest) {
// Generate loops and parallels for the blocks with fixed relationships
const loops = generateLoopBlocks(result.blocks)
const parallels = generateParallelBlocks(result.blocks)
const whiles = generateWhileBlocks(result.blocks)
const transformedResult = {
success: result.success,
diff: {
@@ -265,6 +284,7 @@ export async function POST(request: NextRequest) {
edges: result.edges || existingDiff.proposedState.edges || [],
loops: loops,
parallels: parallels,
whiles: whiles,
},
diffAnalysis: diffAnalysis,
metadata: result.metadata || {

View File

@@ -6,7 +6,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlGenerateAPI')
@@ -60,6 +64,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})

View File

@@ -6,7 +6,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlParseAPI')
@@ -57,6 +61,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
}),
})

View File

@@ -6,7 +6,11 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('YamlToWorkflowAPI')
@@ -65,6 +69,7 @@ export async function POST(request: NextRequest) {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
generateWhileBlocks: generateWhileBlocks.toString(),
},
options,
}),

View File

@@ -15,6 +15,7 @@
.workflow-container .react-flow__node-loopNode,
.workflow-container .react-flow__node-parallelNode,
.workflow-container .react-flow__node-whileNode,
.workflow-container .react-flow__node-subflowNode {
z-index: -1 !important;
}

View File

@@ -46,6 +46,7 @@ export function DeployedWorkflowModal({
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
whiles: state.whiles,
}))
const handleRevert = () => {

View File

@@ -83,6 +83,7 @@ export function DiffControls() {
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
whiles: rawState.whiles || {},
lastSaved: rawState.lastSaved || Date.now(),
isDeployed: rawState.isDeployed || false,
deploymentStatuses: rawState.deploymentStatuses || {},
@@ -98,6 +99,7 @@ export function DiffControls() {
edgesCount: workflowState.edges.length,
loopsCount: Object.keys(workflowState.loops).length,
parallelsCount: Object.keys(workflowState.parallels).length,
whilesCount: Object.keys(workflowState.whiles).length,
hasRequiredFields: Object.values(workflowState.blocks).every(
(block) => block.id && block.type && block.name && block.position
),
@@ -146,6 +148,7 @@ export function DiffControls() {
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId,
whiles: workflowState.whiles,
workflowState: JSON.stringify(workflowState),
}),
})

View File

@@ -12,9 +12,10 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
type IterationType = 'loop' | 'parallel'
type IterationType = 'loop' | 'parallel' | 'while'
type LoopType = 'for' | 'forEach'
type ParallelType = 'count' | 'collection'
type WhileType = 'while' | 'doWhile'
interface IterationNodeData {
width?: number
@@ -25,9 +26,11 @@ interface IterationNodeData {
extent?: 'parent'
loopType?: LoopType
parallelType?: ParallelType
whileType?: WhileType
// Common
count?: number
collection?: string | any[] | Record<string, any>
condition?: string
isPreview?: boolean
executionState?: {
currentIteration?: number
@@ -65,6 +68,12 @@ const CONFIG = {
items: 'distribution' as const,
},
},
while: {
typeLabels: { while: 'While Loop', doWhile: 'Do While' },
typeKey: 'whileType' as const,
storeKey: 'whiles' as const,
maxIterations: 100,
},
} as const
export function IterationBadges({ nodeId, data, iterationType }: IterationBadgesProps) {
@@ -77,9 +86,21 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
// Determine current type and values
const currentType = (data?.[config.typeKey] ||
(iterationType === 'loop' ? 'for' : 'count')) as any
const configIterations = (nodeConfig as any)?.[config.configKeys.iterations] ?? data?.count ?? 5
const configCollection = (nodeConfig as any)?.[config.configKeys.items] ?? data?.collection ?? ''
(iterationType === 'loop' ? 'for' : iterationType === 'parallel' ? 'count' : 'while')) as any
const configIterations =
iterationType === 'loop'
? ((nodeConfig as any)?.[CONFIG.loop.configKeys.iterations] ?? data?.count ?? 5)
: iterationType === 'parallel'
? ((nodeConfig as any)?.[CONFIG.parallel.configKeys.iterations] ?? data?.count ?? 5)
: ((nodeConfig as any)?.iterations ?? data?.count ?? 5)
const configCollection =
iterationType === 'loop'
? ((nodeConfig as any)?.[CONFIG.loop.configKeys.items] ?? data?.collection ?? '')
: iterationType === 'parallel'
? ((nodeConfig as any)?.[CONFIG.parallel.configKeys.items] ?? data?.collection ?? '')
: ''
const iterations = configIterations
const collectionString =
@@ -87,8 +108,10 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
// State management
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const isWhile = iterationType === 'while'
const [whileValue, setWhileValue] = useState<string>(data?.condition || '')
const inputValue = tempInputValue ?? iterations.toString()
const editorValue = collectionString
const editorValue = isWhile ? whileValue : collectionString
const [typePopoverOpen, setTypePopoverOpen] = useState(false)
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
const [showTagDropdown, setShowTagDropdown] = useState(false)
@@ -100,6 +123,7 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
const {
collaborativeUpdateLoopType,
collaborativeUpdateParallelType,
collaborativeUpdateWhileType,
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
} = useCollaborativeWorkflow()
@@ -110,12 +134,21 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
if (isPreview) return
if (iterationType === 'loop') {
collaborativeUpdateLoopType(nodeId, newType)
} else {
} else if (iterationType === 'parallel') {
collaborativeUpdateParallelType(nodeId, newType)
} else {
collaborativeUpdateWhileType(nodeId, newType)
}
setTypePopoverOpen(false)
},
[nodeId, iterationType, collaborativeUpdateLoopType, collaborativeUpdateParallelType, isPreview]
[
nodeId,
iterationType,
collaborativeUpdateLoopType,
collaborativeUpdateParallelType,
collaborativeUpdateWhileType,
isPreview,
]
)
// Handle iterations input change
@@ -141,7 +174,9 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
if (!Number.isNaN(value)) {
const newValue = Math.min(config.maxIterations, Math.max(1, value))
collaborativeUpdateIterationCount(nodeId, iterationType, newValue)
if (iterationType === 'loop' || iterationType === 'parallel') {
collaborativeUpdateIterationCount(nodeId, iterationType, newValue)
}
}
setTempInputValue(null)
setConfigPopoverOpen(false)
@@ -158,7 +193,11 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return
collaborativeUpdateIterationCollection(nodeId, iterationType, value)
if (iterationType === 'loop' || iterationType === 'parallel') {
collaborativeUpdateIterationCollection(nodeId, iterationType, value)
} else if (isWhile) {
setWhileValue(value)
}
const textarea = editorContainerRef.current?.querySelector('textarea')
if (textarea) {
@@ -170,14 +209,18 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
setShowTagDropdown(triggerCheck.show)
}
},
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview, isWhile]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return
collaborativeUpdateIterationCollection(nodeId, iterationType, newValue)
if (iterationType === 'loop' || iterationType === 'parallel') {
collaborativeUpdateIterationCollection(nodeId, iterationType, newValue)
} else if (isWhile) {
setWhileValue(newValue)
}
setShowTagDropdown(false)
setTimeout(() => {
@@ -187,7 +230,7 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
}
}, 0)
},
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview, isWhile]
)
// Determine if we're in count mode or collection mode
@@ -223,7 +266,11 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{iterationType === 'loop' ? 'Loop Type' : 'Parallel Type'}
{iterationType === 'loop'
? 'Loop Type'
: iterationType === 'parallel'
? 'Parallel Type'
: 'While Type'}
</div>
<div className='space-y-1'>
{typeOptions.map(([typeValue, typeLabel]) => (
@@ -259,24 +306,63 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{isCountMode ? `Iterations: ${iterations}` : 'Items'}
{isWhile ? 'Condition' : isCountMode ? `Iterations: ${iterations}` : 'Items'}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
{!isPreview && (
<PopoverContent
className={cn('p-3', !isCountMode ? 'w-72' : 'w-48')}
className={cn('p-3', isWhile || !isCountMode ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{isCountMode
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
{isWhile
? 'While Condition'
: isCountMode
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
</div>
{isCountMode ? (
{isWhile ? (
// Code editor for while condition
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
condition === true
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Enter a boolean expression.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
) : isCountMode ? (
// Number input for count-based mode
<div className='flex items-center gap-2'>
<Input

View File

@@ -136,12 +136,40 @@ describe('SubflowNodeComponent', () => {
}).not.toThrow()
})
it.concurrent('should accept while kind in NodeProps data', () => {
const validProps = {
id: 'test-id-while',
type: 'subflowNode' as const,
data: {
width: 400,
height: 300,
isPreview: false,
kind: 'while' as const,
},
selected: false,
zIndex: 1,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
}
expect(() => {
const _component: typeof SubflowNodeComponent = SubflowNodeComponent
expect(_component).toBeDefined()
expect(validProps.type).toBe('subflowNode')
}).not.toThrow()
})
it.concurrent('should handle different data configurations', () => {
const configurations = [
{ width: 500, height: 300, isPreview: false, kind: 'loop' as const },
{ width: 800, height: 600, isPreview: true, kind: 'parallel' as const },
{ width: 500, height: 300, isPreview: false, kind: 'while' as const },
{ width: 0, height: 0, isPreview: false, kind: 'loop' as const },
{ width: 0, height: 0, isPreview: false, kind: 'while' as const },
{ kind: 'loop' as const },
{ kind: 'while' as const },
]
configurations.forEach((data) => {
@@ -306,10 +334,20 @@ describe('SubflowNodeComponent', () => {
})
it.concurrent('should generate correct handle IDs for parallel kind', () => {
type SubflowKind = 'loop' | 'parallel'
type SubflowKind = 'loop' | 'parallel' | 'while'
const testHandleGeneration = (kind: SubflowKind) => {
const startHandleId = kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startHandleId =
kind === 'loop'
? 'loop-start-source'
: kind === 'parallel'
? 'parallel-start-source'
: 'while-start-source'
const endHandleId =
kind === 'loop'
? 'loop-end-source'
: kind === 'parallel'
? 'parallel-end-source'
: 'while-end-source'
return { startHandleId, endHandleId }
}
@@ -318,6 +356,29 @@ describe('SubflowNodeComponent', () => {
expect(result.endHandleId).toBe('parallel-end-source')
})
it.concurrent('should generate correct handle IDs for while kind', () => {
type SubflowKind = 'loop' | 'parallel' | 'while'
const testHandleGeneration = (kind: SubflowKind) => {
const startHandleId =
kind === 'loop'
? 'loop-start-source'
: kind === 'parallel'
? 'parallel-start-source'
: 'while-start-source'
const endHandleId =
kind === 'loop'
? 'loop-end-source'
: kind === 'parallel'
? 'parallel-end-source'
: 'while-end-source'
return { startHandleId, endHandleId }
}
const result = testHandleGeneration('while')
expect(result.startHandleId).toBe('while-start-source')
expect(result.endHandleId).toBe('while-end-source')
})
it.concurrent('should generate correct background colors for loop kind', () => {
const loopData = { ...defaultProps.data, kind: 'loop' as const }
const startBg = loopData.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
@@ -326,21 +387,41 @@ describe('SubflowNodeComponent', () => {
})
it.concurrent('should generate correct background colors for parallel kind', () => {
type SubflowKind = 'loop' | 'parallel'
type SubflowKind = 'loop' | 'parallel' | 'while'
const testBgGeneration = (kind: SubflowKind) => {
return kind === 'loop' ? '#2FB3FF' : '#FEE12B'
return kind === 'loop' ? '#2FB3FF' : kind === 'parallel' ? '#FEE12B' : '#57D9A3'
}
const startBg = testBgGeneration('parallel')
expect(startBg).toBe('#FEE12B')
})
it.concurrent('should generate correct background colors for while kind', () => {
type SubflowKind = 'loop' | 'parallel' | 'while'
const testBgGeneration = (kind: SubflowKind) => {
return kind === 'loop' ? '#2FB3FF' : kind === 'parallel' ? '#FEE12B' : '#57D9A3'
}
const startBg = testBgGeneration('while')
expect(startBg).toBe('#57D9A3')
})
it.concurrent('should demonstrate handle ID generation for any kind', () => {
type SubflowKind = 'loop' | 'parallel'
type SubflowKind = 'loop' | 'parallel' | 'while'
const testKind = (kind: SubflowKind) => {
const data = { kind }
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startHandleId =
data.kind === 'loop'
? 'loop-start-source'
: data.kind === 'parallel'
? 'parallel-start-source'
: 'while-start-source'
const endHandleId =
data.kind === 'loop'
? 'loop-end-source'
: data.kind === 'parallel'
? 'parallel-end-source'
: 'while-end-source'
return { startHandleId, endHandleId }
}
@@ -351,6 +432,10 @@ describe('SubflowNodeComponent', () => {
const parallelResult = testKind('parallel')
expect(parallelResult.startHandleId).toBe('parallel-start-source')
expect(parallelResult.endHandleId).toBe('parallel-end-source')
const whileResult = testKind('while')
expect(whileResult.startHandleId).toBe('while-start-source')
expect(whileResult.endHandleId).toBe('while-end-source')
})
it.concurrent('should pass correct iterationType to IterationBadges for loop', () => {
@@ -368,25 +453,49 @@ describe('SubflowNodeComponent', () => {
expect(parallelProps.data.kind).toBe('parallel')
})
it.concurrent('should handle both kinds in configuration arrays', () => {
const bothKinds = ['loop', 'parallel'] as const
bothKinds.forEach((kind) => {
it.concurrent('should pass correct iterationType to IterationBadges for while', () => {
const whileProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'while' as const },
}
// Mock IterationBadges should receive the kind as iterationType
expect(whileProps.data.kind).toBe('while')
})
it.concurrent('should handle loop, parallel, and while kinds in configuration arrays', () => {
const allKinds = ['loop', 'parallel', 'while'] as const
allKinds.forEach((kind) => {
const data = { ...defaultProps.data, kind }
expect(['loop', 'parallel']).toContain(data.kind)
expect(['loop', 'parallel', 'while']).toContain(data.kind)
// Test handle ID generation for both kinds
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
const startHandleId =
data.kind === 'loop'
? 'loop-start-source'
: data.kind === 'parallel'
? 'parallel-start-source'
: 'while-start-source'
const endHandleId =
data.kind === 'loop'
? 'loop-end-source'
: data.kind === 'parallel'
? 'parallel-end-source'
: 'while-end-source'
const startBg =
data.kind === 'loop' ? '#2FB3FF' : data.kind === 'parallel' ? '#FEE12B' : '#57D9A3'
if (kind === 'loop') {
expect(startHandleId).toBe('loop-start-source')
expect(endHandleId).toBe('loop-end-source')
expect(startBg).toBe('#2FB3FF')
} else {
} else if (kind === 'parallel') {
expect(startHandleId).toBe('parallel-start-source')
expect(endHandleId).toBe('parallel-end-source')
expect(startBg).toBe('#FEE12B')
} else {
expect(startHandleId).toBe('while-start-source')
expect(endHandleId).toBe('while-end-source')
expect(startBg).toBe('#57D9A3')
}
})
})
@@ -433,10 +542,15 @@ describe('SubflowNodeComponent', () => {
...defaultProps,
data: { ...defaultProps.data, kind: 'parallel' as const },
}
const whileProps = {
...defaultProps,
data: { ...defaultProps.data, kind: 'while' as const },
}
// The iterationType should match the kind
expect(loopProps.data.kind).toBe('loop')
expect(parallelProps.data.kind).toBe('parallel')
expect(whileProps.data.kind).toBe('while')
})
})

View File

@@ -26,6 +26,12 @@ const SubflowNodeStyles: React.FC = () => {
100% { box-shadow: 0 0 0 0 rgba(139, 195, 74, 0); }
}
@keyframes while-node-pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 159, 67, 0.3); }
70% { box-shadow: 0 0 0 6px rgba(255, 159, 67, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 159, 67, 0); }
}
.loop-node-drag-over {
animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
@@ -40,6 +46,13 @@ const SubflowNodeStyles: React.FC = () => {
box-shadow: 0 0 0 8px rgba(139, 195, 74, 0.1);
}
.while-node-drag-over {
animation: while-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
border-style: solid !important;
background-color: rgba(255, 159, 67, 0.08) !important;
box-shadow: 0 0 0 8px rgba(255, 159, 67, 0.1);
}
.react-flow__node-group:hover,
.hover-highlight {
border-color: #1e293b !important;
@@ -69,7 +82,7 @@ export interface SubflowNodeData {
extent?: 'parent'
hasNestedError?: boolean
isPreview?: boolean
kind: 'loop' | 'parallel'
kind: 'loop' | 'parallel' | 'while'
}
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
@@ -114,9 +127,26 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
const nestedStyles = getNestedStyles()
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const startBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
const startHandleId =
data.kind === 'loop'
? 'loop-start-source'
: data.kind === 'parallel'
? 'parallel-start-source'
: 'while-start-source'
const endHandleId =
data.kind === 'loop'
? 'loop-end-source'
: data.kind === 'parallel'
? 'parallel-end-source'
: 'while-end-source'
const startBg =
data.kind === 'loop'
? '#2FB3FF'
: data.kind === 'parallel'
? '#FEE12B'
: data.kind === 'while'
? '#FF9F43'
: '#2FB3FF'
return (
<>

View File

@@ -0,0 +1,29 @@
import { RefreshCwIcon } from 'lucide-react'
export const WhileTool = {
id: 'while',
type: 'while',
name: 'While',
description: 'While Loop',
icon: RefreshCwIcon,
bgColor: '#CC5500',
data: {
label: 'While',
whileType: 'while' as 'while' | 'doWhile',
condition: '',
width: 500,
height: 300,
extent: 'parent',
executionState: {
currentIteration: 0,
isExecuting: false,
startTime: null,
endTime: null,
},
},
style: {
width: 500,
height: 300,
},
isResizable: true,
}

View File

@@ -106,32 +106,34 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
</Tooltip>
)}
{/* Remove from subflow - only show when inside loop/parallel */}
{!isStarterBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
)
}
}}
className={cn(
'text-gray-500',
(disabled || !userPermissions.canEdit) && 'cursor-not-allowed opacity-50'
)}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Remove From Subflow')}</TooltipContent>
</Tooltip>
)}
{/* Remove from subflow - only show when inside loop/parallel/while */}
{!isStarterBlock &&
parentId &&
(parentType === 'loop' || parentType === 'parallel' || parentType === 'while') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => {
if (!disabled && userPermissions.canEdit) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId } })
)
}
}}
className={cn(
'text-gray-500',
(disabled || !userPermissions.canEdit) && 'cursor-not-allowed opacity-50'
)}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>{getTooltipMessage('Remove From Subflow')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -79,7 +79,7 @@ export function ConnectionBlocks({
const blockConfig = getBlock(connection.type)
const displayName = connection.name // Use the actual block name instead of transforming it
// Handle special blocks that aren't in the registry (loop and parallel)
// Handle special blocks that aren't in the registry (loop, parallel, while)
let Icon = blockConfig?.icon
let bgColor = blockConfig?.bgColor || '#6B7280' // Fallback to gray
@@ -90,6 +90,9 @@ export function ConnectionBlocks({
} else if (connection.type === 'parallel') {
Icon = SplitIcon as typeof Icon
bgColor = '#FEE12B' // Yellow color for parallel blocks
} else if (connection.type === 'while') {
Icon = RepeatIcon as typeof Icon
bgColor = '#FF9F43' // Orange color for while blocks
}
}

View File

@@ -58,6 +58,7 @@ export function generateFullWorkflowData() {
edges: workflowState.edges,
loops: workflowState.loops,
parallels: workflowState.parallels,
whiles: workflowState.whiles,
},
subBlockValues,
exportedAt: new Date().toISOString(),

View File

@@ -3,7 +3,13 @@ import type { Edge } from 'reactflow'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import type { DeploymentStatus } from '@/stores/workflows/registry/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import type {
BlockState,
Loop,
Parallel,
While,
WorkflowState,
} from '@/stores/workflows/workflow/types'
/**
* Interface for the current workflow abstraction
@@ -14,6 +20,7 @@ export interface CurrentWorkflow {
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
whiles: Record<string, While>
lastSaved?: number
isDeployed?: boolean
deployedAt?: Date
@@ -61,6 +68,7 @@ export function useCurrentWorkflow(): CurrentWorkflow {
edges: activeWorkflow.edges,
loops: activeWorkflow.loops || {},
parallels: activeWorkflow.parallels || {},
whiles: activeWorkflow.whiles || {},
lastSaved: activeWorkflow.lastSaved,
isDeployed: activeWorkflow.isDeployed,
deployedAt: activeWorkflow.deployedAt,

View File

@@ -522,6 +522,7 @@ export function useWorkflowExecution() {
edges: workflowEdges,
loops: workflowLoops,
parallels: workflowParallels,
whiles: workflowWhiles,
} = currentWorkflow
// Filter out blocks without type (these are layout-only blocks)
@@ -633,7 +634,8 @@ export function useWorkflowExecution() {
filteredStates,
filteredEdges,
workflowLoops,
workflowParallels
workflowParallels,
workflowWhiles
)
// If this is a chat execution, get the selected outputs

View File

@@ -104,6 +104,7 @@ export async function executeWorkflowWithLogging(
edges: workflowEdges,
loops: workflowLoops,
parallels: workflowParallels,
whiles: workflowWhiles,
} = currentWorkflow
// Filter out blocks without type (these are layout-only blocks)
@@ -201,7 +202,8 @@ export async function executeWorkflowWithLogging(
filteredStates,
filteredEdges,
workflowLoops,
workflowParallels
workflowParallels,
workflowWhiles
)
// If this is a chat execution, get the selected outputs

View File

@@ -15,7 +15,8 @@ const isContainerType = (blockType: string): boolean => {
blockType === 'parallel' ||
blockType === 'loopNode' ||
blockType === 'parallelNode' ||
blockType === 'subflowNode'
blockType === 'subflowNode' ||
blockType === 'while'
)
}
@@ -302,7 +303,7 @@ export const calculateRelativePosition = (
* @param getNodes Function to retrieve all nodes from ReactFlow
* @param updateBlockPosition Function to update the position of a block
* @param updateParentId Function to update the parent ID of a block
* @param resizeLoopNodes Function to resize loop nodes after parent update
* @param resizeLoopNodes Function to resize loop or parallel or while nodes after parent update
*/
export const updateNodeParent = (
nodeId: string,
@@ -336,7 +337,7 @@ export const updateNodeParent = (
}
/**
* Checks if a point is inside a loop or parallel node
* Checks if a point is inside a loop or parallel or while node
* @param position Position coordinates to check
* @param getNodes Function to retrieve all nodes from ReactFlow
* @returns The smallest container node containing the point, or null if none
@@ -390,7 +391,7 @@ export const isPointInLoopNode = (
}
/**
* Calculates appropriate dimensions for a loop or parallel node based on its children
* Calculates appropriate dimensions for a loop or parallel or while node based on its children
* @param nodeId ID of the container node
* @param getNodes Function to retrieve all nodes from ReactFlow
* @param blocks Block states from workflow store

View File

@@ -47,6 +47,7 @@ export async function applyAutoLayoutToWorkflow(
edges: any[],
loops: Record<string, any> = {},
parallels: Record<string, any> = {},
whiles: Record<string, any> = {},
options: AutoLayoutOptions = {}
): Promise<{
success: boolean
@@ -152,7 +153,7 @@ export async function applyAutoLayoutAndUpdateStore(
const { useWorkflowStore } = await import('@/stores/workflows/workflow/store')
const workflowStore = useWorkflowStore.getState()
const { blocks, edges, loops = {}, parallels = {} } = workflowStore
const { blocks, edges, loops = {}, parallels = {}, whiles = {} } = workflowStore
logger.info('Auto layout store data:', {
workflowId,
@@ -160,6 +161,7 @@ export async function applyAutoLayoutAndUpdateStore(
edgeCount: edges.length,
loopCount: Object.keys(loops).length,
parallelCount: Object.keys(parallels).length,
whileCount: Object.keys(whiles).length,
})
if (Object.keys(blocks).length === 0) {
@@ -174,6 +176,7 @@ export async function applyAutoLayoutAndUpdateStore(
edges,
loops,
parallels,
whiles,
options
)
@@ -265,5 +268,5 @@ export async function applyAutoLayoutToBlocks(
layoutedBlocks?: Record<string, any>
error?: string
}> {
return applyAutoLayoutToWorkflow('preview', blocks, edges, {}, {}, options)
return applyAutoLayoutToWorkflow('preview', blocks, edges, {}, {}, {}, options)
}

View File

@@ -98,7 +98,7 @@ const WorkflowContent = React.memo(() => {
useStreamCleanup(copilotCleanup)
// Extract workflow data from the abstraction
const { blocks, edges, loops, parallels, isDiffMode } = currentWorkflow
const { blocks, edges, isDiffMode } = currentWorkflow
// Get diff analysis for edge reconstruction
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore()
@@ -462,6 +462,8 @@ const WorkflowContent = React.memo(() => {
sourceHandle = 'loop-end-source'
} else if (block.type === 'parallel') {
sourceHandle = 'parallel-end-source'
} else if (block.type === 'while') {
sourceHandle = 'while-end-source'
}
return sourceHandle
@@ -481,14 +483,19 @@ const WorkflowContent = React.memo(() => {
if (type === 'connectionBlock') return
// Special handling for container nodes (loop or parallel)
if (type === 'loop' || type === 'parallel') {
if (type === 'loop' || type === 'parallel' || type === 'while') {
// Create a unique ID and name for the container
const id = crypto.randomUUID()
// Auto-number the blocks based on existing blocks of the same type
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === type)
const blockNumber = existingBlocksOfType.length + 1
const name = type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
const name =
type === 'loop'
? `Loop ${blockNumber}`
: type === 'parallel'
? `Parallel ${blockNumber}`
: `While ${blockNumber}`
// Calculate the center position of the viewport
const centerPosition = project({
@@ -615,21 +622,30 @@ const WorkflowContent = React.memo(() => {
// Clear any drag-over styling
document
.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over')
.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over, .while-node-drag-over')
.forEach((el) => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
el.classList.remove(
'loop-node-drag-over',
'parallel-node-drag-over',
'while-node-drag-over'
)
})
document.body.style.cursor = ''
// Special handling for container nodes (loop or parallel)
if (data.type === 'loop' || data.type === 'parallel') {
if (data.type === 'loop' || data.type === 'parallel' || data.type === 'while') {
// Create a unique ID and name for the container
const id = crypto.randomUUID()
// Auto-number the blocks based on existing blocks of the same type
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === data.type)
const blockNumber = existingBlocksOfType.length + 1
const name = data.type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
const name =
data.type === 'loop'
? `Loop ${blockNumber}`
: data.type === 'parallel'
? `Parallel ${blockNumber}`
: `While ${blockNumber}`
// Check if we're dropping inside another container
if (containerInfo) {
@@ -691,7 +707,12 @@ const WorkflowContent = React.memo(() => {
}
const blockConfig = getBlock(data.type)
if (!blockConfig && data.type !== 'loop' && data.type !== 'parallel') {
if (
!blockConfig &&
data.type !== 'loop' &&
data.type !== 'parallel' &&
data.type !== 'while'
) {
logger.error('Invalid block type:', { data })
return
}
@@ -703,7 +724,9 @@ const WorkflowContent = React.memo(() => {
? `Loop ${Object.values(blocks).filter((b) => b.type === 'loop').length + 1}`
: data.type === 'parallel'
? `Parallel ${Object.values(blocks).filter((b) => b.type === 'parallel').length + 1}`
: `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}`
: data.type === 'while'
? `While ${Object.values(blocks).filter((b) => b.type === 'while').length + 1}`
: `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}`
if (containerInfo) {
// Calculate position relative to the container node
@@ -762,7 +785,9 @@ const WorkflowContent = React.memo(() => {
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
: data.type === 'parallel'
? 'parallel-start-source'
: 'while-start-source'
addEdge({
id: crypto.randomUUID(),
@@ -833,9 +858,13 @@ const WorkflowContent = React.memo(() => {
// Clear any previous highlighting
document
.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over')
.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over, .while-node-drag-over')
.forEach((el) => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
el.classList.remove(
'loop-node-drag-over',
'parallel-node-drag-over',
'while-node-drag-over'
)
})
// If hovering over a container node, highlight it
@@ -854,6 +883,11 @@ const WorkflowContent = React.memo(() => {
(containerNode.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
} else if (
containerNode?.type === 'subflowNode' &&
(containerNode.data as any)?.kind === 'while'
) {
containerElement.classList.add('while-node-drag-over')
}
document.body.style.cursor = 'copy'
}
@@ -983,7 +1017,7 @@ const WorkflowContent = React.memo(() => {
}
// Handle container nodes differently
if (block.type === 'loop' || block.type === 'parallel') {
if (block.type === 'loop' || block.type === 'parallel' || block.type === 'while') {
const hasNestedError = nestedSubflowErrors.has(block.id)
nodeArray.push({
id: block.id,
@@ -997,7 +1031,7 @@ const WorkflowContent = React.memo(() => {
width: block.data?.width || 500,
height: block.data?.height || 300,
hasNestedError,
kind: block.type === 'loop' ? 'loop' : 'parallel',
kind: block.type === 'loop' ? 'loop' : block.type === 'parallel' ? 'parallel' : 'while',
},
})
return
@@ -1144,7 +1178,8 @@ const WorkflowContent = React.memo(() => {
const sourceParentId =
sourceNode.parentId ||
(connection.sourceHandle === 'loop-start-source' ||
connection.sourceHandle === 'parallel-start-source'
connection.sourceHandle === 'parallel-start-source' ||
connection.sourceHandle === 'while-start-source'
? connection.source
: undefined)
const targetParentId = targetNode.parentId
@@ -1155,7 +1190,8 @@ const WorkflowContent = React.memo(() => {
// Special case for container start source: Always allow connections to nodes within the same container
if (
(connection.sourceHandle === 'loop-start-source' ||
connection.sourceHandle === 'parallel-start-source') &&
connection.sourceHandle === 'parallel-start-source' ||
connection.sourceHandle === 'while-start-source') &&
targetNode.parentId === sourceNode.id
) {
// This is a connection from container start to a node inside the container - always allow
@@ -1222,7 +1258,11 @@ const WorkflowContent = React.memo(() => {
if (potentialParentId) {
const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`)
if (prevElement) {
prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
prevElement.classList.remove(
'loop-node-drag-over',
'parallel-node-drag-over',
'while-node-drag-over'
)
}
setPotentialParentId(null)
document.body.style.cursor = ''
@@ -1342,6 +1382,11 @@ const WorkflowContent = React.memo(() => {
(bestContainerMatch.container.data as any)?.kind === 'parallel'
) {
containerElement.classList.add('parallel-node-drag-over')
} else if (
bestContainerMatch.container.type === 'subflowNode' &&
(bestContainerMatch.container.data as any)?.kind === 'while'
) {
containerElement.classList.add('while-node-drag-over')
}
document.body.style.cursor = 'copy'
}
@@ -1350,7 +1395,11 @@ const WorkflowContent = React.memo(() => {
if (potentialParentId) {
const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`)
if (prevElement) {
prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
prevElement.classList.remove(
'loop-node-drag-over',
'parallel-node-drag-over',
'while-node-drag-over'
)
}
setPotentialParentId(null)
document.body.style.cursor = ''
@@ -1382,9 +1431,15 @@ const WorkflowContent = React.memo(() => {
const onNodeDragStop = useCallback(
(_event: React.MouseEvent, node: any) => {
// Clear UI effects
document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => {
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
})
document
.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over, .while-node-drag-over')
.forEach((el) => {
el.classList.remove(
'loop-node-drag-over',
'parallel-node-drag-over',
'while-node-drag-over'
)
})
document.body.style.cursor = ''
// Emit collaborative position update for the final position
@@ -1477,7 +1532,9 @@ const WorkflowContent = React.memo(() => {
const startSourceHandle =
(containerNode?.data as any)?.kind === 'loop'
? 'loop-start-source'
: 'parallel-start-source'
: (containerNode?.data as any)?.kind === 'parallel'
? 'parallel-start-source'
: 'while-start-source'
addEdge({
id: crypto.randomUUID(),

View File

@@ -148,7 +148,7 @@ export function SearchModal({
})
)
// Add special blocks (loop and parallel)
// Add special blocks (loop, parallel, and while)
const specialBlocks: BlockItem[] = [
{
id: 'loop',
@@ -166,6 +166,14 @@ export function SearchModal({
bgColor: '#FEE12B',
type: 'parallel',
},
{
id: 'while',
name: 'While',
description: 'While Loop',
icon: RepeatIcon,
bgColor: '#FF9F43',
type: 'while',
},
]
return [...regularBlocks, ...specialBlocks].sort((a, b) => a.name.localeCompare(b.name))

View File

@@ -0,0 +1,87 @@
import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { WhileTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/while/while-config'
type WhileToolbarItemProps = {
disabled?: boolean
}
// Custom component for the While Tool
export default function WhileToolbarItem({ disabled = false }: WhileToolbarItemProps) {
const userPermissions = useUserPermissionsContext()
const handleDragStart = (e: React.DragEvent) => {
if (disabled) {
e.preventDefault()
return
}
// Only send the essential data for the while node
const simplifiedData = {
type: 'while',
}
e.dataTransfer.setData('application/json', JSON.stringify(simplifiedData))
e.dataTransfer.effectAllowed = 'move'
}
// Handle click to add while block
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (disabled) return
// Dispatch a custom event to be caught by the workflow component
const event = new CustomEvent('add-block-from-toolbar', {
detail: {
type: 'while',
clientX: e.clientX,
clientY: e.clientY,
},
})
window.dispatchEvent(event)
},
[disabled]
)
const blockContent = (
<div
draggable={!disabled}
onDragStart={handleDragStart}
onClick={handleClick}
className={cn(
'group flex h-8 items-center gap-[10px] rounded-[8px] p-2 transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-muted active:cursor-grabbing'
)}
>
<div
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-[6px]'
style={{ backgroundColor: WhileTool.bgColor }}
>
<WhileTool.icon
className={cn(
'h-[14px] w-[14px] text-white transition-transform duration-200',
!disabled && 'group-hover:scale-110'
)}
/>
</div>
<span className='font-medium text-sm leading-none'>{WhileTool.name}</span>
</div>
)
if (disabled) {
return (
<Tooltip>
<TooltipTrigger asChild>{blockContent}</TooltipTrigger>
<TooltipContent>
{userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Edit permissions required to add blocks'}
</TooltipContent>
</Tooltip>
)
}
return blockContent
}

View File

@@ -7,6 +7,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { ToolbarBlock } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block'
import LoopToolbarItem from '@/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-loop-block/toolbar-loop-block'
import ParallelToolbarItem from '@/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-parallel-block/toolbar-parallel-block'
import WhileToolbarItem from '@/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-while-block/toolbar-while-block'
import { getAllBlocks } from '@/blocks'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
@@ -54,7 +55,7 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
}))
.sort((a, b) => a.name.localeCompare(b.name))
// Create special blocks (loop and parallel) if they match search
// Create special blocks (loop, parallel, and while) if they match search
const specialBlockItems: BlockItem[] = []
if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) {
@@ -73,6 +74,14 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
})
}
if (!searchQuery.trim() || 'while'.toLowerCase().includes(searchQuery.toLowerCase())) {
specialBlockItems.push({
name: 'While',
type: 'while',
isCustom: true,
})
}
// Sort special blocks alphabetically
specialBlockItems.sort((a, b) => a.name.localeCompare(b.name))
@@ -128,7 +137,7 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
/>
))}
{/* Special Blocks Section (Loop & Parallel) */}
{/* Special Blocks Section (Loop, Parallel, and While) */}
{specialBlocks.map((block) => {
if (block.type === 'loop') {
return <LoopToolbarItem key={block.type} disabled={!userPermissions.canEdit} />
@@ -136,6 +145,9 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
if (block.type === 'parallel') {
return <ParallelToolbarItem key={block.type} disabled={!userPermissions.canEdit} />
}
if (block.type === 'while') {
return <WhileToolbarItem key={block.type} disabled={!userPermissions.canEdit} />
}
return null
})}

View File

@@ -83,6 +83,14 @@ export function WorkflowPreview({
}
}, [workflowState.parallels, isValidWorkflowState])
const whilesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.whiles || {}).length,
ids: Object.keys(workflowState.whiles || {}).join(','),
}
}, [workflowState.whiles, isValidWorkflowState])
const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
@@ -166,6 +174,26 @@ export function WorkflowPreview({
return
}
if (block.type === 'while') {
nodeArray.push({
id: block.id,
type: 'subflowNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
width: block.data?.width || 500,
height: block.data?.height || 300,
state: 'valid',
isPreview: true,
kind: 'while',
},
})
return
}
const blockConfig = getBlock(block.type)
if (!blockConfig) {
logger.error(`No configuration found for block type: ${block.type}`, { blockId })
@@ -229,6 +257,7 @@ export function WorkflowPreview({
blocksStructure,
loopsStructure,
parallelsStructure,
whilesStructure,
showSubBlocks,
workflowState.blocks,
isValidWorkflowState,

View File

@@ -129,7 +129,7 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
edges,
loops || {},
parallels || {},
true // Enable validation during execution
{} // Enable validation during execution
)
// Handle special Airtable case

View File

@@ -118,7 +118,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
edges,
loops || {},
parallels || {},
true // Enable validation during execution
{} // Enable validation during execution
)
// Create executor and execute

View File

@@ -55,6 +55,7 @@ const BLOCK_COLORS = {
DEFAULT: '#2F55FF',
LOOP: '#2FB3FF',
PARALLEL: '#FEE12B',
WHILE: '#57D9A3',
} as const
const TAG_PREFIXES = {
@@ -294,6 +295,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blocks = useWorkflowStore((state) => state.blocks)
const loops = useWorkflowStore((state) => state.loops)
const parallels = useWorkflowStore((state) => state.parallels)
const whiles = useWorkflowStore((state) => state.whiles)
const edges = useWorkflowStore((state) => state.edges)
const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -321,7 +323,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockConfig = getBlock(sourceBlock.type)
if (!blockConfig) {
if (sourceBlock.type === 'loop' || sourceBlock.type === 'parallel') {
if (
sourceBlock.type === 'loop' ||
sourceBlock.type === 'parallel' ||
sourceBlock.type === 'while'
) {
const mockConfig = {
outputs: {
results: 'array',
@@ -467,13 +473,26 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
const serializer = new Serializer()
const serializedWorkflow = serializer.serializeWorkflow(blocks, edges, loops, parallels)
const serializedWorkflow = serializer.serializeWorkflow(blocks, edges, loops, parallels, whiles)
const accessibleBlockIds = BlockPathCalculator.findAllPathNodes(
serializedWorkflow.connections,
blockId
)
// If editing a while block condition, also include children inside the while container
const sourceBlock = blocks[blockId]
if (sourceBlock && sourceBlock.type === 'while') {
const whileCfg = whiles[blockId]
if (whileCfg && Array.isArray(whileCfg.nodes)) {
whileCfg.nodes.forEach((childId: string) => {
if (!accessibleBlockIds.includes(childId)) {
accessibleBlockIds.push(childId)
}
})
}
}
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
if (starterBlock && !accessibleBlockIds.includes(starterBlock.id)) {
accessibleBlockIds.push(starterBlock.id)
@@ -551,6 +570,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
let parallelBlockGroup: BlockTagGroup | null = null
let whileBlockGroup: BlockTagGroup | null = null
const containingParallel = Object.entries(parallels || {}).find(([_, parallel]) =>
parallel.nodes.includes(blockId)
)
@@ -579,6 +599,27 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
}
const containingWhile = Object.entries(whiles || {}).find(([_, w]) => w.nodes.includes(blockId))
let containingWhileBlockId: string | null = null
if (containingWhile) {
const [whileId] = containingWhile
containingWhileBlockId = whileId
const contextualTags: string[] = ['index']
const containingWhileBlock = blocks[whileId]
if (containingWhileBlock) {
const whileBlockName = containingWhileBlock.name || containingWhileBlock.type
whileBlockGroup = {
blockName: whileBlockName,
blockId: whileId,
blockType: 'while',
tags: contextualTags,
distance: 0,
}
}
}
const blockTagGroups: BlockTagGroup[] = []
const allBlockTags: string[] = []
@@ -589,11 +630,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const blockConfig = getBlock(accessibleBlock.type)
if (!blockConfig) {
if (accessibleBlock.type === 'loop' || accessibleBlock.type === 'parallel') {
if (
accessibleBlock.type === 'loop' ||
accessibleBlock.type === 'parallel' ||
accessibleBlock.type === 'while'
) {
// Skip this block if it's the containing loop/parallel block - we'll handle it with contextual tags
if (
accessibleBlockId === containingLoopBlockId ||
accessibleBlockId === containingParallelBlockId
accessibleBlockId === containingParallelBlockId ||
accessibleBlockId === containingWhileBlockId
) {
continue
}
@@ -729,6 +775,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (parallelBlockGroup) {
finalBlockTagGroups.push(parallelBlockGroup)
}
if (whileBlockGroup) {
finalBlockTagGroups.push(whileBlockGroup)
}
blockTagGroups.sort((a, b) => a.distance - b.distance)
finalBlockTagGroups.push(...blockTagGroups)
@@ -740,13 +789,16 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
if (parallelBlockGroup) {
contextualTags.push(...parallelBlockGroup.tags)
}
if (whileBlockGroup) {
contextualTags.push(...whileBlockGroup.tags)
}
return {
tags: [...variableTags, ...contextualTags, ...allBlockTags],
variableInfoMap,
blockTagGroups: finalBlockTagGroups,
}
}, [blocks, edges, loops, parallels, blockId, activeSourceBlockId, workflowVariables])
}, [blocks, edges, loops, parallels, whiles, blockId, activeSourceBlockId, workflowVariables])
const filteredTags = useMemo(() => {
if (!searchTerm) return tags
@@ -806,9 +858,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
})
} else {
const path = tagParts.slice(1).join('.')
// Handle contextual tags for loop/parallel blocks (single words like 'index', 'currentItem')
// Handle contextual tags for loop/parallel/while blocks (single words like 'index', 'currentItem')
if (
(group.blockType === 'loop' || group.blockType === 'parallel') &&
(group.blockType === 'loop' ||
group.blockType === 'parallel' ||
group.blockType === 'while') &&
tagParts.length === 1
) {
directTags.push({
@@ -912,7 +966,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
} else if (
blockGroup &&
(blockGroup.blockType === 'loop' || blockGroup.blockType === 'parallel')
(blockGroup.blockType === 'loop' ||
blockGroup.blockType === 'parallel' ||
blockGroup.blockType === 'while')
) {
if (!tag.includes('.') && ['index', 'currentItem', 'items'].includes(tag)) {
processedTag = `${blockGroup.blockType}.${tag}`
@@ -1283,6 +1339,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockColor = BLOCK_COLORS.LOOP
} else if (group.blockType === 'parallel') {
blockColor = BLOCK_COLORS.PARALLEL
} else if (group.blockType === 'while') {
blockColor = BLOCK_COLORS.WHILE
}
return (
@@ -1305,7 +1363,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
let tagIcon = group.blockName.charAt(0).toUpperCase()
if (
(group.blockType === 'loop' || group.blockType === 'parallel') &&
(group.blockType === 'loop' ||
group.blockType === 'parallel' ||
group.blockType === 'while') &&
!nestedTag.key.includes('.')
) {
if (nestedTag.key === 'index') {

View File

@@ -391,6 +391,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
edges: mergedEdges,
loops: workflowState.loops || existing.loops || {},
parallels: workflowState.parallels || existing.parallels || {},
whiles: workflowState.whiles || existing.whiles || {},
lastSaved: workflowState.lastSaved || existing.lastSaved || Date.now(),
isDeployed: workflowState.isDeployed ?? existing.isDeployed ?? false,
deployedAt: workflowState.deployedAt || existing.deployedAt,
@@ -532,6 +533,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
edges: mergedEdges,
loops: workflowState.loops || existing.loops || {},
parallels: workflowState.parallels || existing.parallels || {},
whiles: workflowState.whiles || existing.whiles || {},
lastSaved: workflowState.lastSaved || existing.lastSaved || Date.now(),
isDeployed: workflowState.isDeployed ?? existing.isDeployed ?? false,
deployedAt: workflowState.deployedAt || existing.deployedAt,

View File

@@ -5,6 +5,7 @@
export enum BlockType {
PARALLEL = 'parallel',
LOOP = 'loop',
WHILE = 'while',
ROUTER = 'router',
CONDITION = 'condition',
FUNCTION = 'function',

View File

@@ -6,6 +6,7 @@ import { FunctionBlockHandler } from '@/executor/handlers/function/function-hand
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
import { LoopBlockHandler } from '@/executor/handlers/loop/loop-handler'
import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-handler'
// import { WhileBlockHandler } from '@/executor/handlers/while/while-handler'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler'
@@ -20,6 +21,7 @@ export {
GenericBlockHandler,
LoopBlockHandler,
ParallelBlockHandler,
// WhileBlockHandler,
ResponseBlockHandler,
RouterBlockHandler,
TriggerBlockHandler,

View File

@@ -750,6 +750,7 @@ describe('Executor', () => {
],
loops: {},
parallels: {},
whiles: {},
}
const executor = new Executor(routerWorkflow)
@@ -1066,6 +1067,7 @@ describe('Executor', () => {
],
loops: {},
parallels: {},
whiles: {},
}
const executor = new Executor(workflow)

View File

@@ -12,6 +12,7 @@ import {
GenericBlockHandler,
LoopBlockHandler,
ParallelBlockHandler,
// WhileBlockHandler,
ResponseBlockHandler,
RouterBlockHandler,
TriggerBlockHandler,
@@ -19,6 +20,7 @@ import {
} from '@/executor/handlers'
import { LoopManager } from '@/executor/loops/loops'
import { ParallelManager } from '@/executor/parallels/parallels'
// import { WhileManager } from '@/executor/whiles/whiles'
import { PathTracker } from '@/executor/path/path'
import { InputResolver } from '@/executor/resolver/resolver'
import type {
@@ -73,6 +75,7 @@ export class Executor {
private resolver: InputResolver
private loopManager: LoopManager
private parallelManager: ParallelManager
// private whileManager: WhileManager
private pathTracker: PathTracker
private blockHandlers: BlockHandler[]
private workflowInput: any
@@ -134,6 +137,7 @@ export class Executor {
this.loopManager = new LoopManager(this.actualWorkflow.loops || {})
this.parallelManager = new ParallelManager(this.actualWorkflow.parallels || {})
// this.whileManager = new WhileManager(this.actualWorkflow.whiles || {})
// Calculate accessible blocks for consistent reference resolution
const accessibleBlocksMap = BlockPathCalculator.calculateAccessibleBlocksForWorkflow(
@@ -159,6 +163,7 @@ export class Executor {
new ApiBlockHandler(),
new LoopBlockHandler(this.resolver, this.pathTracker),
new ParallelBlockHandler(this.resolver, this.pathTracker),
// new WhileBlockHandler(this.resolver, this.pathTracker),
new ResponseBlockHandler(),
new WorkflowBlockHandler(),
new GenericBlockHandler(),
@@ -417,6 +422,9 @@ export class Executor {
// Process parallel iterations - similar to loops but conceptually for parallel execution
await this.parallelManager.processParallelIterations(context)
// Process while iterations - similar concept to loops but condition-driven
// await this.whileManager.processWhileIterations(context)
// Continue execution for any newly activated paths
// Only stop execution if there are no more blocks to execute
const updatedNextLayer = this.getNextExecutionLayer(context)
@@ -560,6 +568,7 @@ export class Executor {
}
await this.loopManager.processLoopIterations(context)
await this.parallelManager.processParallelIterations(context)
// await this.whileManager.processWhileIterations(context)
const nextLayer = this.getNextExecutionLayer(context)
setPendingBlocks(nextLayer)
@@ -759,6 +768,13 @@ export class Executor {
}
}
// Initialize while iterations
if (this.actualWorkflow.whiles) {
for (const whileId of Object.keys(this.actualWorkflow.whiles)) {
context.loopIterations.set(whileId, 0)
}
}
// Determine which block to initialize as the starting point
let initBlock: SerializedBlock | undefined
if (startBlockId) {
@@ -1207,6 +1223,20 @@ export class Executor {
return loopCompleted
}
// Special handling for while-start-source connections
if (conn.sourceHandle === 'while-start-source') {
// Activated when while block executes
return sourceExecuted
}
// Special handling for while-end-source connections
if (conn.sourceHandle === 'while-end-source') {
// Activated when while block has completed (condition false or max iterations)
const whileState = context.blockStates.get(conn.source)
const whileCompleted = Boolean(whileState?.output?.completed)
return sourceExecuted && whileCompleted
}
// Special handling for parallel-start-source connections
if (conn.sourceHandle === 'parallel-start-source') {
// This block is connected to a parallel's start output
@@ -1643,7 +1673,11 @@ export class Executor {
context.blockLogs.push(blockLog)
// Skip console logging for infrastructure blocks like loops and parallels
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
if (
block.metadata?.id !== BlockType.LOOP &&
block.metadata?.id !== BlockType.PARALLEL &&
block.metadata?.id !== BlockType.WHILE
) {
// Determine iteration context for this block
let iterationCurrent: number | undefined
let iterationTotal: number | undefined
@@ -1755,7 +1789,11 @@ export class Executor {
context.blockLogs.push(blockLog)
// Skip console logging for infrastructure blocks like loops and parallels
if (block.metadata?.id !== BlockType.LOOP && block.metadata?.id !== BlockType.PARALLEL) {
if (
block.metadata?.id !== BlockType.LOOP &&
block.metadata?.id !== BlockType.PARALLEL
// block.metadata?.id !== BlockType.WHILE
) {
// Determine iteration context for this block
let iterationCurrent: number | undefined
let iterationTotal: number | undefined
@@ -1927,6 +1965,7 @@ export class Executor {
block?.metadata?.id === BlockType.CONDITION ||
block?.metadata?.id === BlockType.LOOP ||
block?.metadata?.id === BlockType.PARALLEL
// block?.metadata?.id === BlockType.WHILE
) {
return false
}

View File

@@ -23,6 +23,7 @@ export class InputResolver {
private blockByNormalizedName: Map<string, SerializedBlock>
private loopsByBlockId: Map<string, string> // Maps block ID to containing loop ID
private parallelsByBlockId: Map<string, string> // Maps block ID to containing parallel ID
// private whilesByBlockId: Map<string, string> // Maps block ID to containing while ID
constructor(
private workflow: SerializedWorkflow,
@@ -70,6 +71,14 @@ export class InputResolver {
this.parallelsByBlockId.set(blockId, parallelId)
}
}
// Create efficient while lookup map
// this.whilesByBlockId = new Map()
// for (const [whileId, whileCfg] of Object.entries(workflow.whiles || {})) {
// for (const blockId of whileCfg.nodes) {
// this.whilesByBlockId.set(blockId, whileId)
// }
// }
}
/**
@@ -1103,6 +1112,17 @@ export class InputResolver {
}
}
// Special case: blocks in the same while can reference each other
// const currentBlockWhile = this.whilesByBlockId.get(currentBlockId)
// if (currentBlockWhile) {
// const whileCfg = this.workflow.whiles?.[currentBlockWhile]
// if (whileCfg) {
// for (const nodeId of whileCfg.nodes) {
// accessibleBlocks.add(nodeId)
// }
// }
// }
return accessibleBlocks
}
@@ -1867,4 +1887,14 @@ export class InputResolver {
getContainingParallelId(blockId: string): string | undefined {
return this.parallelsByBlockId.get(blockId)
}
/**
* Get the containing while ID for a block
* @param blockId - The ID of the block
* @returns The containing while ID or undefined if not in a while
*/
getContainingWhileId(blockId: string): string | undefined {
// return this.whilesByBlockId.get(blockId)
return undefined
}
}

View File

@@ -78,6 +78,7 @@ export class Routing {
// Flow control blocks
[BlockType.PARALLEL]: BlockCategory.FLOW_CONTROL,
[BlockType.LOOP]: BlockCategory.FLOW_CONTROL,
[BlockType.WHILE]: BlockCategory.FLOW_CONTROL,
[BlockType.WORKFLOW]: BlockCategory.FLOW_CONTROL,
// Routing blocks
@@ -139,6 +140,8 @@ export class Routing {
'parallel-end-source',
'loop-start-source',
'loop-end-source',
'while-start-source',
'while-end-source',
]
if (flowControlHandles.includes(sourceHandle || '')) {

View File

@@ -145,6 +145,17 @@ export interface ExecutionContext {
}
>
// While execution tracking
whileExecutions?: Map<
string,
{
maxIterations: number
loopType: 'while' | 'doWhile'
executionResults: Map<string, any> // iteration_0, iteration_1, etc.
currentIteration: number
}
>
// Mapping for virtual parallel block IDs to their original blocks
parallelBlockMapping?: Map<
string,

View File

View File

View File

@@ -562,8 +562,8 @@ export function useCollaborativeWorkflow() {
const blockConfig = getBlock(type)
// Handle loop/parallel blocks that don't use BlockConfig
if (!blockConfig && (type === 'loop' || type === 'parallel')) {
// Handle loop/parallel/while blocks that don't use BlockConfig
if (!blockConfig && (type === 'loop' || type === 'parallel' || type === 'while')) {
// For loop/parallel blocks, use empty subBlocks and outputs
const completeBlockData = {
id,
@@ -1129,6 +1129,16 @@ export function useCollaborativeWorkflow() {
[executeQueuedOperation, workflowStore]
)
// UI-only while type toggle (no server op yet)
const collaborativeUpdateWhileType = useCallback(
(whileId: string, whileType: 'while' | 'doWhile') => {
const currentBlock = workflowStore.blocks[whileId]
if (!currentBlock || currentBlock.type !== 'while') return
workflowStore.updateWhileType(whileId, whileType)
},
[workflowStore]
)
// Unified iteration management functions - count and collection only
const collaborativeUpdateIterationCount = useCallback(
(nodeId: string, iterationType: 'loop' | 'parallel', count: number) => {
@@ -1321,6 +1331,7 @@ export function useCollaborativeWorkflow() {
// Collaborative loop/parallel operations
collaborativeUpdateLoopType,
collaborativeUpdateParallelType,
collaborativeUpdateWhileType,
// Unified iteration operations
collaborativeUpdateIterationCount,

View File

@@ -12,6 +12,7 @@ export interface NormalizedWorkflowData {
edges: any[]
loops: Record<string, any>
parallels: Record<string, any>
whiles: Record<string, any>
isFromNormalizedTables: boolean // Flag to indicate source (true = normalized tables, false = deployed state)
}
@@ -49,6 +50,7 @@ export async function loadDeployedWorkflowState(
edges: deployedState.edges || [],
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
whiles: deployedState.whiles || {},
isFromNormalizedTables: false, // Flag to indicate this came from deployed state
}
} catch (error) {
@@ -126,6 +128,7 @@ export async function loadWorkflowFromNormalizedTables(
// Convert subflows to loops and parallels
const loops: Record<string, any> = {}
const parallels: Record<string, any> = {}
const whiles: Record<string, any> = {}
subflows.forEach((subflow) => {
const config = subflow.config || {}
@@ -140,6 +143,11 @@ export async function loadWorkflowFromNormalizedTables(
id: subflow.id,
...config,
}
} else if (subflow.type === SUBFLOW_TYPES.WHILE) {
whiles[subflow.id] = {
id: subflow.id,
...config,
}
} else {
logger.warn(`Unknown subflow type: ${subflow.type} for subflow ${subflow.id}`)
}
@@ -150,6 +158,7 @@ export async function loadWorkflowFromNormalizedTables(
edges: edgesArray,
loops,
parallels,
whiles,
isFromNormalizedTables: true,
}
} catch (error) {
@@ -238,6 +247,15 @@ export async function saveWorkflowToNormalizedTables(
})
})
Object.values(state.whiles || {}).forEach((whileSubflow) => {
subflowInserts.push({
id: whileSubflow.id,
workflowId: workflowId,
type: SUBFLOW_TYPES.WHILE,
config: whileSubflow,
})
})
if (subflowInserts.length > 0) {
await tx.insert(workflowSubflows).values(subflowInserts)
}
@@ -251,6 +269,7 @@ export async function saveWorkflowToNormalizedTables(
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
whiles: state.whiles || {},
lastSaved: Date.now(),
isDeployed: state.isDeployed,
deployedAt: state.deployedAt,
@@ -303,6 +322,7 @@ export async function migrateWorkflowToNormalizedTables(
edges: jsonState.edges || [],
loops: jsonState.loops || {},
parallels: jsonState.parallels || {},
whiles: jsonState.whiles || {},
lastSaved: jsonState.lastSaved,
isDeployed: jsonState.isDeployed,
deployedAt: jsonState.deployedAt,

View File

@@ -15,6 +15,7 @@ export function buildWorkflowStateForTemplate(workflowId: string) {
// Generate loops and parallels in the same format as deployment
const loops = workflowStore.generateLoopBlocks()
const parallels = workflowStore.generateParallelBlocks()
const whiles = workflowStore.generateWhileBlocks()
// Build the state object in the same format as deployment
const state = {
@@ -22,6 +23,7 @@ export function buildWorkflowStateForTemplate(workflowId: string) {
edges,
loops,
parallels,
whiles,
lastSaved: Date.now(),
}

View File

@@ -330,6 +330,32 @@ export function hasWorkflowChanged(
}
}
// 6. Compare whiles
const currentWhiles = currentState.whiles || {}
const deployedWhiles = deployedState.whiles || {}
const currentWhileIds = Object.keys(currentWhiles).sort()
const deployedWhileIds = Object.keys(deployedWhiles).sort()
if (
currentWhileIds.length !== deployedWhileIds.length ||
normalizedStringify(currentWhileIds) !== normalizedStringify(deployedWhileIds)
) {
return true
}
// Compare each while with normalized values
for (const whileId of currentWhileIds) {
const normalizedCurrentWhile = normalizeValue(currentWhiles[whileId])
const normalizedDeployedWhile = normalizeValue(deployedWhiles[whileId])
if (
normalizedStringify(normalizedCurrentWhile) !== normalizedStringify(normalizedDeployedWhile)
) {
return true
}
}
return false
}

View File

@@ -3,7 +3,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import type { BlockState, Loop, Parallel, While } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('Serializer')
@@ -27,6 +27,7 @@ export class Serializer {
edges: Edge[],
loops: Record<string, Loop>,
parallels?: Record<string, Parallel>,
whiles?: Record<string, While>,
validateRequired = false
): SerializedWorkflow {
return {
@@ -40,12 +41,13 @@ export class Serializer {
})),
loops,
parallels,
whiles,
}
}
private serializeBlock(block: BlockState, validateRequired = false): SerializedBlock {
// Special handling for subflow blocks (loops, parallels, etc.)
if (block.type === 'loop' || block.type === 'parallel') {
if (block.type === 'loop' || block.type === 'parallel' || block.type === 'while') {
return {
id: block.id,
position: block.position,
@@ -58,9 +60,15 @@ export class Serializer {
metadata: {
id: block.type,
name: block.name,
description: block.type === 'loop' ? 'Loop container' : 'Parallel container',
description:
block.type === 'loop'
? 'Loop container'
: block.type === 'parallel'
? 'Parallel container'
: 'While container',
category: 'subflow',
color: block.type === 'loop' ? '#3b82f6' : '#8b5cf6',
color:
block.type === 'loop' ? '#3b82f6' : block.type === 'parallel' ? '#8b5cf6' : '#FF9F43', // Orange color for while blocks
},
enabled: block.enabled,
}
@@ -211,8 +219,8 @@ export class Serializer {
private extractParams(block: BlockState): Record<string, any> {
// Special handling for subflow blocks (loops, parallels, etc.)
if (block.type === 'loop' || block.type === 'parallel') {
return {} // Loop and parallel blocks don't have traditional params
if (block.type === 'loop' || block.type === 'parallel' || block.type === 'while') {
return {} // Loop, parallel, and while blocks don't have traditional params
}
const blockConfig = getBlock(block.type)
@@ -359,13 +367,15 @@ export class Serializer {
}
// Special handling for subflow blocks (loops, parallels, etc.)
if (blockType === 'loop' || blockType === 'parallel') {
if (blockType === 'loop' || blockType === 'parallel' || blockType === 'while') {
return {
id: serializedBlock.id,
type: blockType,
name: serializedBlock.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel'),
name:
serializedBlock.metadata?.name ||
(blockType === 'loop' ? 'Loop' : blockType === 'parallel' ? 'Parallel' : 'While'),
position: serializedBlock.position,
subBlocks: {}, // Loops and parallels don't have traditional subBlocks
subBlocks: {}, // Loops, parallels, and whiles don't have traditional subBlocks
outputs: serializedBlock.outputs,
enabled: serializedBlock.enabled ?? true,
data: serializedBlock.config.params, // Preserve the data (parallelType, count, etc.)

View File

@@ -7,6 +7,7 @@ export interface SerializedWorkflow {
connections: SerializedConnection[]
loops: Record<string, SerializedLoop>
parallels?: Record<string, SerializedParallel>
whiles?: Record<string, SerializedWhile>
}
export interface SerializedConnection {
@@ -55,3 +56,10 @@ export interface SerializedParallel {
count?: number // Number of parallel executions for count-based parallel
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
}
export interface SerializedWhile {
id: string
nodes: string[]
iterations: number
whileType?: 'while' | 'doWhile'
}

View File

@@ -61,6 +61,7 @@ async function insertAutoConnectEdge(
enum SubflowType {
LOOP = 'loop',
PARALLEL = 'parallel',
WHILE = 'while',
}
// Helper function to check if a block type is a subflow type
@@ -134,6 +135,7 @@ export async function getWorkflowState(workflowId: string) {
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
whiles: normalizedData.whiles,
lastSaved: Date.now(),
isDeployed: workflowData[0].isDeployed || false,
deployedAt: workflowData[0].deployedAt,
@@ -280,7 +282,7 @@ async function handleBlockOperationTx(
throw insertError
}
// Auto-create subflow entry for loop/parallel blocks
// Auto-create subflow entry for loop/parallel/while blocks
if (isSubflowBlockType(payload.type)) {
try {
const subflowConfig =
@@ -672,7 +674,7 @@ async function handleBlockOperationTx(
throw insertError
}
// Auto-create subflow entry for loop/parallel blocks
// Auto-create subflow entry for loop/parallel/while blocks
if (isSubflowBlockType(payload.type)) {
try {
const subflowConfig =
@@ -832,7 +834,7 @@ async function handleSubflowOperationTx(
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
} else if (payload.type === 'parallel') {
} else if (payload.type === 'parallel' || payload.type === 'while') {
// Update the parallel block's data properties
const blockData = {
...payload.config,

View File

@@ -18,7 +18,11 @@ import type {
SyncControl,
WorkflowState,
} from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import {
generateLoopBlocks,
generateParallelBlocks,
generateWhileBlocks,
} from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowStore')
@@ -35,6 +39,7 @@ const initialState = {
deploymentStatuses: {},
needsRedeployment: false,
hasActiveWebhook: false,
whiles: {},
history: {
past: [],
present: {
@@ -43,6 +48,7 @@ const initialState = {
edges: [],
loops: {},
parallels: {},
whiles: {},
isDeployed: false,
isPublished: false,
},
@@ -106,8 +112,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
}
) => {
const blockConfig = getBlock(type)
// For custom nodes like loop and parallel that don't use BlockConfig
if (!blockConfig && (type === 'loop' || type === 'parallel')) {
// For custom nodes like loop, parallel, and while that don't use BlockConfig
if (!blockConfig && (type === 'loop' || type === 'parallel' || type === 'while')) {
// Merge parentId and extent into data if provided
const nodeData = {
...data,
@@ -136,6 +142,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
whiles: get().generateWhileBlocks(),
}
set(newState)
@@ -187,6 +194,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
whiles: get().generateWhileBlocks(),
}
set(newState)
@@ -287,6 +295,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
whiles: { ...get().whiles },
}
logger.info('[WorkflowStore/updateParentId] Updated parentId relationship:', {
@@ -316,6 +325,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id),
loops: { ...get().loops },
parallels: { ...get().parallels },
whiles: { ...get().whiles },
}
// Find and remove all child blocks if this is a parent node
@@ -407,6 +417,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: newEdges,
loops: generateLoopBlocks(get().blocks),
parallels: get().generateParallelBlocks(),
whiles: get().generateWhileBlocks(),
}
set(newState)
@@ -430,6 +441,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: newEdges,
loops: generateLoopBlocks(get().blocks),
parallels: get().generateParallelBlocks(),
whiles: get().generateWhileBlocks(),
}
set(newState)
@@ -452,6 +464,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [],
loops: {},
parallels: {},
whiles: {},
isDeployed: false,
isPublished: false,
},
@@ -484,6 +497,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
whiles: state.whiles,
lastSaved: state.lastSaved,
isDeployed: state.isDeployed,
deployedAt: state.deployedAt,
@@ -505,6 +519,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
whiles: { ...get().whiles },
}
set(newState)
@@ -557,6 +572,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
whiles: get().generateWhileBlocks(),
}
// Update the subblock store with the duplicated values
@@ -641,6 +657,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
whiles: { ...get().whiles },
}
// Update references in subblock store
@@ -914,6 +931,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: deployedState.edges,
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
whiles: deployedState.whiles || {},
isDeployed: true,
needsRedeployment: false,
hasActiveWebhook: false, // Reset webhook status
@@ -1037,6 +1055,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: filteredEdges,
loops: { ...get().loops },
parallels: { ...get().parallels },
whiles: { ...get().whiles },
}
set(newState)
@@ -1106,6 +1125,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: { ...get().loops },
parallels: generateParallelBlocks(newBlocks), // Regenerate parallels
whiles: { ...get().whiles },
}
set(newState)
@@ -1134,6 +1154,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: { ...get().loops },
parallels: generateParallelBlocks(newBlocks), // Regenerate parallels
whiles: { ...get().whiles },
}
set(newState)
@@ -1162,6 +1183,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...get().edges],
loops: { ...get().loops },
parallels: generateParallelBlocks(newBlocks), // Regenerate parallels
whiles: { ...get().whiles },
}
set(newState)
@@ -1174,6 +1196,39 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
generateParallelBlocks: () => {
return generateParallelBlocks(get().blocks)
},
// While block methods implementation (UI-only toggle)
updateWhileType: (whileId: string, whileType) => {
const block = get().blocks[whileId]
if (!block || block.type !== 'while') return
const newBlocks = {
...get().blocks,
[whileId]: {
...block,
data: {
...block.data,
whileType,
},
},
}
const newState = {
blocks: newBlocks,
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
whiles: get().generateWhileBlocks(),
}
set(newState)
pushHistory(set, get, newState, `Update while type`)
get().updateLastSaved()
},
generateWhileBlocks: () => {
return generateWhileBlocks(get().blocks)
},
})),
{ name: 'workflow-store' }
)

View File

@@ -5,8 +5,16 @@ import type { DeploymentStatus } from '@/stores/workflows/registry/types'
export const SUBFLOW_TYPES = {
LOOP: 'loop',
PARALLEL: 'parallel',
WHILE: 'while',
} as const
export const WHILE_TYPES = {
WHILE: 'while',
DO_WHILE: 'doWhile',
} as const
export type WhileType = (typeof WHILE_TYPES)[keyof typeof WHILE_TYPES]
export type SubflowType = (typeof SUBFLOW_TYPES)[keyof typeof SUBFLOW_TYPES]
export function isValidSubflowType(type: string): type is SubflowType {
@@ -26,12 +34,18 @@ export interface ParallelConfig {
parallelType?: 'count' | 'collection'
}
export interface WhileConfig {
nodes: string[]
iterations: number
whileType: WhileType
}
// Generic subflow interface
export interface Subflow {
id: string
workflowId: string
type: SubflowType
config: LoopConfig | ParallelConfig
config: LoopConfig | ParallelConfig | WhileConfig
createdAt: Date
updatedAt: Date
}
@@ -58,6 +72,9 @@ export interface BlockData {
// Parallel-specific properties
parallelType?: 'collection' | 'count' // Type of parallel execution
// While-specific properties
whileType?: WhileType
// Container node type (for ReactFlow node type determination)
type?: string
}
@@ -112,6 +129,13 @@ export interface ParallelBlock {
}
}
export interface While {
id: string
nodes: string[]
iterations: number
whileType: WhileType
}
export interface Loop {
id: string
nodes: string[]
@@ -134,6 +158,7 @@ export interface WorkflowState {
lastSaved?: number
loops: Record<string, Loop>
parallels: Record<string, Parallel>
whiles: Record<string, While>
lastUpdate?: number
// Legacy deployment fields (keeping for compatibility)
isDeployed?: boolean
@@ -196,8 +221,10 @@ export interface WorkflowActions {
updateParallelCount: (parallelId: string, count: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
updateWhileType: (whileId: string, whileType: WhileType) => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
generateWhileBlocks: () => Record<string, While>
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
setWebhookStatus: (hasActiveWebhook: boolean) => void
revertToDeployedState: (deployedState: WorkflowState) => void

View File

@@ -1,6 +1,7 @@
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import type { BlockState, Loop, Parallel, While } from '@/stores/workflows/workflow/types'
const DEFAULT_LOOP_ITERATIONS = 5
const DEFAULT_WHILE_ITERATIONS = 1000
/**
* Convert UI loop block to executor Loop format
@@ -39,6 +40,37 @@ export function convertLoopBlockToLoop(
}
}
/**
* Convert UI while block to executor While format
*
* @param whileBlockId - ID of the while block to convert
* @param blocks - Record of all blocks in the workflow
* @returns While object for execution engine or undefined if not a valid while
*/
export function convertWhileBlockToWhile(
whileBlockId: string,
blocks: Record<string, BlockState>
): While | undefined {
const whileBlock = blocks[whileBlockId]
if (!whileBlock || whileBlock.type !== 'while') return undefined
// Default iterations as a safety cap; higher for whiles
const iterations =
(whileBlock.data as any)?.iterations ||
(whileBlock.data as any)?.count ||
DEFAULT_WHILE_ITERATIONS
// Default whileType to 'while' when not provided
const whileType = (whileBlock.data as any)?.whileType || 'while'
return {
id: whileBlockId,
nodes: findChildNodes(whileBlockId, blocks),
iterations,
whileType,
}
}
/**
* Convert UI parallel block to executor Parallel format
*
@@ -162,3 +194,25 @@ export function generateParallelBlocks(
return parallels
}
/**
* Builds a complete collection of while blocks from the UI blocks
*
* @param blocks - Record of all blocks in the workflow
* @returns Record of While objects for execution engine
*/
export function generateWhileBlocks(blocks: Record<string, BlockState>): Record<string, While> {
const whiles: Record<string, While> = {}
// Find all while nodes
Object.entries(blocks)
.filter(([_, block]) => block.type === 'while')
.forEach(([id]) => {
const whileCfg = convertWhileBlockToWhile(id, blocks)
if (whileCfg) {
whiles[id] = whileCfg
}
})
return whiles
}