@flowuent-org/diagramming-core 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -276,6 +276,42 @@ export const automationDefaultNodes = [
276
276
  height: 150,
277
277
  measured: { width: 336, height: 150 },
278
278
  },
279
+ // =====================================
280
+ // Loop Node
281
+ // =====================================
282
+ {
283
+ id: 'loop-node',
284
+ type: 'AutomationLoopNode',
285
+ position: { x: 1100, y: 200 },
286
+ data: {
287
+ label: 'For Each Item',
288
+ description: 'Iterate over array items and run body steps for each',
289
+ headerText: 'For-each loop over array',
290
+ status: 'Ready',
291
+ parameters: {
292
+ arraySource: { type: 'previousStep' },
293
+ maxIterations: 100,
294
+ currentItemVariable: 'currentItem',
295
+ loopIndexVariable: 'loopIndex',
296
+ },
297
+ formData: {
298
+ nodeId: 'loop-node',
299
+ title: 'For Each Item',
300
+ type: 'loop',
301
+ arraySource: { type: 'previousStep' },
302
+ maxIterations: 100,
303
+ currentItemVariable: 'currentItem',
304
+ loopIndexVariable: 'loopIndex',
305
+ },
306
+ lastRun: 'Never',
307
+ backgroundColor: '#181C25',
308
+ textColor: '#ffffff',
309
+ borderColor: '#1e293b',
310
+ },
311
+ width: 336,
312
+ height: 150,
313
+ measured: { width: 336, height: 150 },
314
+ },
279
315
  {
280
316
  id: 'end-node',
281
317
  type: 'AutomationEndNode',
@@ -695,11 +731,11 @@ export const automationDefaultEdges = [
695
731
  },
696
732
  },
697
733
  {
698
- id: 'edge-formatting-to-end',
734
+ id: 'edge-formatting-to-loop',
699
735
  source: 'data-formatting-node',
700
- target: 'end-node',
701
- sourceHandle: 'right',
702
- targetHandle: 'left',
736
+ target: 'loop-node',
737
+ sourceHandle: 'right-source',
738
+ targetHandle: 'left-target',
703
739
  data: {
704
740
  label: '',
705
741
  type: 'flow',
@@ -713,6 +749,64 @@ export const automationDefaultEdges = [
713
749
  color: '#ffffff',
714
750
  },
715
751
  },
752
+ {
753
+ id: 'edge-loop-to-slack',
754
+ source: 'loop-node',
755
+ target: 'slack-node',
756
+ sourceHandle: 'loop',
757
+ targetHandle: 'left-target',
758
+ data: {
759
+ label: 'Loop body',
760
+ type: 'flow',
761
+ },
762
+ style: {
763
+ stroke: '#86EFAC',
764
+ strokeWidth: 2,
765
+ },
766
+ markerEnd: {
767
+ type: MarkerType.ArrowClosed,
768
+ color: '#86EFAC',
769
+ },
770
+ },
771
+ {
772
+ id: 'edge-slack-back-to-loop',
773
+ source: 'slack-node',
774
+ target: 'loop-node',
775
+ sourceHandle: 'right-source',
776
+ targetHandle: 'left-target',
777
+ data: {
778
+ label: 'Back to loop',
779
+ type: 'flow',
780
+ },
781
+ style: {
782
+ stroke: '#86EFAC',
783
+ strokeWidth: 2,
784
+ strokeDasharray: '5 5',
785
+ },
786
+ markerEnd: {
787
+ type: MarkerType.ArrowClosed,
788
+ color: '#86EFAC',
789
+ },
790
+ },
791
+ {
792
+ id: 'edge-loop-done-to-end',
793
+ source: 'loop-node',
794
+ target: 'end-node',
795
+ sourceHandle: 'done',
796
+ targetHandle: 'left-target',
797
+ data: {
798
+ label: 'Done',
799
+ type: 'flow',
800
+ },
801
+ style: {
802
+ stroke: '#9CA3AF',
803
+ strokeWidth: 2,
804
+ },
805
+ markerEnd: {
806
+ type: MarkerType.ArrowClosed,
807
+ color: '#9CA3AF',
808
+ },
809
+ },
716
810
  // =====================================
717
811
  // Slack Node Edge
718
812
  // =====================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowuent-org/diagramming-core",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,105 @@
1
+ import React from 'react';
2
+ import { Box, Typography, Chip } from '@mui/material';
3
+ import { LoopNodeIcon } from '../../icons';
4
+ import {
5
+ AutomationNodeCard,
6
+ AutomationNodeCardData,
7
+ } from './AutomationNodeCard';
8
+ import { AutomationLoopNodeForm } from '../../types/automation-node-data-types';
9
+
10
+ export interface AutomationLoopNodeData extends AutomationNodeCardData {
11
+ parameters?: {
12
+ arraySource?: AutomationLoopNodeForm['arraySource'];
13
+ maxIterations?: number;
14
+ currentItemVariable?: string;
15
+ loopIndexVariable?: string;
16
+ [key: string]: unknown;
17
+ };
18
+ }
19
+
20
+ export interface AutomationLoopNodeProps {
21
+ data: AutomationLoopNodeData;
22
+ selected?: boolean;
23
+ }
24
+
25
+ export const AutomationLoopNode: React.FC<AutomationLoopNodeProps> = ({
26
+ data,
27
+ selected,
28
+ }) => {
29
+ const formData = (data.formData ?? {}) as Partial<AutomationLoopNodeForm>;
30
+ const arraySource =
31
+ data.parameters?.arraySource ?? formData.arraySource;
32
+ const maxIterations =
33
+ data.parameters?.maxIterations ?? formData.maxIterations ?? 100;
34
+ const currentItemVariable =
35
+ data.parameters?.currentItemVariable ??
36
+ formData.currentItemVariable ??
37
+ 'currentItem';
38
+ const loopIndexVariable =
39
+ data.parameters?.loopIndexVariable ??
40
+ formData.loopIndexVariable ??
41
+ 'loopIndex';
42
+
43
+ const sourceLabel =
44
+ arraySource?.type === 'variable'
45
+ ? arraySource.variableName ?? 'Unnamed variable'
46
+ : arraySource?.type === 'previousStep'
47
+ ? 'Previous step'
48
+ : 'Not configured';
49
+
50
+ return (
51
+ <AutomationNodeCard
52
+ data={data}
53
+ selected={selected}
54
+ icon={<LoopNodeIcon />}
55
+ aiAssistantTitle="Loop Node"
56
+ loopHandles
57
+ descriptionContent={
58
+ <Box sx={{ width: '100%' }}>
59
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
60
+ <Chip
61
+ size="small"
62
+ label={sourceLabel.toUpperCase()}
63
+ sx={{
64
+ height: 20,
65
+ fontSize: '10px',
66
+ fontWeight: 600,
67
+ color: '#86EFAC',
68
+ bgcolor: 'rgba(134, 239, 172, 0.12)',
69
+ }}
70
+ />
71
+ <Chip
72
+ size="small"
73
+ label={`MAX ${maxIterations}`}
74
+ sx={{
75
+ height: 20,
76
+ fontSize: '10px',
77
+ fontWeight: 600,
78
+ color: '#9CA3AF',
79
+ bgcolor: 'rgba(156, 163, 175, 0.12)',
80
+ }}
81
+ />
82
+ </Box>
83
+ <Typography
84
+ variant="body2"
85
+ sx={{ color: '#9CA3AF', fontSize: '12px', lineHeight: 1.4, mb: 0.5 }}
86
+ >
87
+ {arraySource
88
+ ? `For each item → ${currentItemVariable}, index → ${loopIndexVariable}`
89
+ : data.description}
90
+ </Typography>
91
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1.5, px: 0.5 }}>
92
+ <Typography variant="caption" sx={{ color: '#86EFAC', fontSize: '10px', fontWeight: 600 }}>
93
+ LOOP →
94
+ </Typography>
95
+ <Typography variant="caption" sx={{ color: '#9CA3AF', fontSize: '10px', fontWeight: 600 }}>
96
+ DONE →
97
+ </Typography>
98
+ </Box>
99
+ </Box>
100
+ }
101
+ />
102
+ );
103
+ };
104
+
105
+ export default AutomationLoopNode;
@@ -30,6 +30,7 @@ export interface AutomationNodeCardProps {
30
30
  headerText?: string;
31
31
  aiAssistantTitle?: string;
32
32
  branchHandles?: boolean;
33
+ loopHandles?: boolean;
33
34
  descriptionContent?: React.ReactNode;
34
35
  }
35
36
 
@@ -64,6 +65,7 @@ export const AutomationNodeCard: React.FC<AutomationNodeCardProps> = ({
64
65
  headerText,
65
66
  aiAssistantTitle,
66
67
  branchHandles = false,
68
+ loopHandles = false,
67
69
  descriptionContent,
68
70
  }) => {
69
71
  const { t } = useTranslation();
@@ -248,6 +250,29 @@ export const AutomationNodeCard: React.FC<AutomationNodeCardProps> = ({
248
250
  style={handleTargetStyle('right', selected)}
249
251
  />
250
252
  </>
253
+ ) : loopHandles ? (
254
+ <>
255
+ <Handle
256
+ type="source"
257
+ position={Position.Right}
258
+ id="loop"
259
+ className="connection-handle"
260
+ style={{ ...handleSourceStyle(selected), right: '-8px', top: '35%' }}
261
+ />
262
+ <Handle
263
+ type="source"
264
+ position={Position.Right}
265
+ id="done"
266
+ className="connection-handle"
267
+ style={{ ...handleSourceStyle(selected), right: '-8px', top: '65%' }}
268
+ />
269
+ <Handle
270
+ type="target"
271
+ position={Position.Right}
272
+ id="right-target"
273
+ style={handleTargetStyle('right', selected)}
274
+ />
275
+ </>
251
276
  ) : (
252
277
  <>
253
278
  <Handle
@@ -51,6 +51,12 @@ export type {
51
51
  AutomationDecisionNodeData,
52
52
  AutomationDecisionNodeProps,
53
53
  } from './AutomationDecisionNode';
54
+ // Loop / for-each node
55
+ export { AutomationLoopNode } from './AutomationLoopNode';
56
+ export type {
57
+ AutomationLoopNodeData,
58
+ AutomationLoopNodeProps,
59
+ } from './AutomationLoopNode';
54
60
  // SMS node
55
61
  export { AutomationSmsNode } from './AutomationSmsNode';
56
62
  export type {
@@ -299,6 +299,16 @@ export const DecisionNodeIcon: React.FC<SvgIconProps> = ({ size, color, style })
299
299
  </svg>
300
300
  );
301
301
 
302
+ export const LoopNodeIcon: React.FC<SvgIconProps> = ({ size, color, style }) => (
303
+ <svg width="35" height="35" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" style={style}>
304
+ <path d="M0 14C0 6.26801 6.26801 0 14 0C21.732 0 28 6.26801 28 14C28 21.732 21.732 28 14 28C6.26801 28 0 21.732 0 14Z" fill="#1E3A8A"/>
305
+ <path d="M17.5 9.5H11.5C10.3954 9.5 9.5 10.3954 9.5 11.5V13.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
306
+ <path d="M10.5 18.5H16.5C17.6046 18.5 18.5 17.6046 18.5 16.5V14.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
307
+ <path d="M9.5 13.5L7.5 11.5L9.5 9.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
308
+ <path d="M18.5 14.5L20.5 16.5L18.5 18.5" stroke="#86EFAC" strokeLinecap="round" strokeLinejoin="round"/>
309
+ </svg>
310
+ );
311
+
302
312
  export const SmsNodeIcon: React.FC<SvgIconProps> = ({ size, color, style }) => (
303
313
  <svg width="35" height="35" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" style={style}>
304
314
  <path d="M0 14C0 6.26801 6.26801 0 14 0C21.732 0 28 6.26801 28 14C28 21.732 21.732 28 14 28C6.26801 28 0 21.732 0 14Z" fill="#1E3A8A"/>
@@ -23,6 +23,7 @@ export {
23
23
  FormattingNodeIcon,
24
24
  ApiNodeIcon,
25
25
  DecisionNodeIcon,
26
+ LoopNodeIcon,
26
27
  SmsNodeIcon,
27
28
  VoiceCallNodeIcon,
28
29
  SubWorkflowNodeIcon,
@@ -3,7 +3,7 @@ export interface AutomationNodeFormData {
3
3
  nodeId: string;
4
4
  title: string;
5
5
  headerText?: string;
6
- type: 'start' | 'api' | 'formatting' | 'end' | 'navigation' | 'decision' | 'sms' | 'voice-call' | 'workflow';
6
+ type: 'start' | 'api' | 'formatting' | 'end' | 'navigation' | 'decision' | 'loop' | 'sms' | 'voice-call' | 'workflow';
7
7
  isPinned?: boolean;
8
8
  isBlock?: boolean;
9
9
  blocks?: Array<{
@@ -203,6 +203,17 @@ export interface AutomationDecisionNodeForm extends AutomationNodeFormData {
203
203
  defaultBranch?: string;
204
204
  }
205
205
 
206
+ export interface AutomationLoopNodeForm extends AutomationNodeFormData {
207
+ type: 'loop';
208
+ arraySource: {
209
+ type: 'previousStep' | 'variable';
210
+ variableName?: string;
211
+ };
212
+ maxIterations?: number;
213
+ currentItemVariable?: string;
214
+ loopIndexVariable?: string;
215
+ }
216
+
206
217
  export interface AutomationSmsNodeForm extends AutomationNodeFormData {
207
218
  type: 'sms';
208
219
  recipientPhone: string;
@@ -256,6 +267,7 @@ export type AutomationNodeForm =
256
267
  | AutomationEndNodeForm
257
268
  | AutomationNavigationNodeForm
258
269
  | AutomationDecisionNodeForm
270
+ | AutomationLoopNodeForm
259
271
  | AutomationSmsNodeForm
260
272
  | AutomationVoiceCallNodeForm
261
273
  | AutomationWorkflowNodeForm;
@@ -15,6 +15,7 @@ import {
15
15
  AutomationInteractionNode,
16
16
  AutomationMonitoringNode,
17
17
  AutomationDecisionNode,
18
+ AutomationLoopNode,
18
19
  AutomationSmsNode,
19
20
  AutomationVoiceCallNode,
20
21
  AutomationWorkflowNode,
@@ -36,6 +37,7 @@ const nodeTypes = {
36
37
  AutomationInteractionNode,
37
38
  AutomationMonitoringNode,
38
39
  AutomationDecisionNode,
40
+ AutomationLoopNode,
39
41
  AutomationSmsNode,
40
42
  AutomationVoiceCallNode,
41
43
  AutomationWorkflowNode,
@@ -5,7 +5,9 @@ import {
5
5
  AutomationApiNodeForm,
6
6
  AutomationFormattingNodeForm,
7
7
  AutomationEndNodeForm,
8
+ AutomationLoopNodeForm,
8
9
  } from '../types/automation-node-data-types';
10
+ import { assertAutomationLoopNodeValid } from './validateAutomationLoopNode';
9
11
 
10
12
  export interface AutomationContext {
11
13
  runId: string;
@@ -128,6 +130,192 @@ export class AutomationExecutionEngine {
128
130
  return this.nodes.filter((node) => nextNodeIds.includes(node.id));
129
131
  }
130
132
 
133
+ private getOutgoingEdge(
134
+ nodeId: string,
135
+ sourceHandle: string,
136
+ ): Edge | undefined {
137
+ return this.edges.find(
138
+ (edge) => edge.source === nodeId && edge.sourceHandle === sourceHandle,
139
+ );
140
+ }
141
+
142
+ private getTargetNode(edge: Edge): Node | undefined {
143
+ return this.nodes.find((node) => node.id === edge.target);
144
+ }
145
+
146
+ private getFirstOutgoingEdge(nodeId: string): Edge | undefined {
147
+ return this.edges.find((edge) => edge.source === nodeId);
148
+ }
149
+
150
+ private resolveArraySource(
151
+ formData: AutomationLoopNodeForm,
152
+ inputData: string | number | boolean | object | null,
153
+ ): unknown[] {
154
+ if (formData.arraySource.type === 'variable') {
155
+ const variableName = formData.arraySource.variableName;
156
+ if (!variableName) {
157
+ throw new Error('Loop node variable name is not configured');
158
+ }
159
+ const value = this.context.variables[variableName];
160
+ if (!Array.isArray(value)) {
161
+ throw new Error(
162
+ `Loop variable "${variableName}" must be an array, got ${typeof value}`,
163
+ );
164
+ }
165
+ return value;
166
+ }
167
+
168
+ if (inputData === null || inputData === undefined) {
169
+ throw new Error('Loop node previous step output is empty');
170
+ }
171
+ if (Array.isArray(inputData)) {
172
+ return inputData;
173
+ }
174
+ return [inputData];
175
+ }
176
+
177
+ private async runNodeWithStatus(
178
+ node: Node,
179
+ inputData?: string | number | boolean | object | null,
180
+ ): Promise<string | number | boolean | object | null> {
181
+ this.log(
182
+ 'info',
183
+ node.id,
184
+ String(node.type || 'node'),
185
+ 'Executing node...',
186
+ );
187
+
188
+ (node.data as Record<string, unknown>).status = 'Running';
189
+ if (this.onNodeUpdate) {
190
+ this.onNodeUpdate(node.id, { ...node.data });
191
+ }
192
+
193
+ try {
194
+ const result = await this.executeNode(node, inputData);
195
+ (node.data as Record<string, unknown>).status = 'Completed';
196
+ (node.data as Record<string, unknown>).lastRun = new Date().toISOString();
197
+ if (this.onNodeUpdate) {
198
+ this.onNodeUpdate(node.id, { ...node.data });
199
+ }
200
+ return result;
201
+ } catch (error) {
202
+ (node.data as Record<string, unknown>).status = 'Error';
203
+ (node.data as Record<string, unknown>).lastRun = new Date().toISOString();
204
+ if (this.onNodeUpdate) {
205
+ this.onNodeUpdate(node.id, { ...node.data });
206
+ }
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ private async executeBodySubgraph(
212
+ startNode: Node,
213
+ loopNodeId: string,
214
+ inputData: string | number | boolean | object | null,
215
+ ): Promise<string | number | boolean | object | null> {
216
+ let currentNode: Node | null = startNode;
217
+ let currentData = inputData;
218
+ const visitedInBody = new Set<string>();
219
+
220
+ while (currentNode && currentNode.id !== loopNodeId) {
221
+ if (visitedInBody.has(currentNode.id)) {
222
+ throw new Error(
223
+ `Infinite loop detected in loop body at node ${currentNode.id}`,
224
+ );
225
+ }
226
+ visitedInBody.add(currentNode.id);
227
+
228
+ currentData = await this.runNodeWithStatus(currentNode, currentData);
229
+
230
+ const nextEdge = this.getFirstOutgoingEdge(currentNode.id);
231
+ if (!nextEdge) {
232
+ throw new Error(
233
+ `Loop body node ${currentNode.id} has no outgoing edge back to the loop node`,
234
+ );
235
+ }
236
+
237
+ const nextNode = this.getTargetNode(nextEdge);
238
+ if (!nextNode) {
239
+ throw new Error(`Loop body target node not found for edge ${nextEdge.id}`);
240
+ }
241
+
242
+ currentNode = nextNode;
243
+ }
244
+
245
+ return currentData;
246
+ }
247
+
248
+ private async executeLoopNode(
249
+ node: Node,
250
+ inputData: string | number | boolean | object | null,
251
+ ): Promise<{ doneNextNode: Node | null; result: object }> {
252
+ assertAutomationLoopNodeValid(node, this.edges);
253
+
254
+ const formData = (node.data.formData || node.data) as AutomationLoopNodeForm;
255
+ const maxIterations = formData.maxIterations ?? 100;
256
+ const currentItemVariable = formData.currentItemVariable ?? 'currentItem';
257
+ const loopIndexVariable = formData.loopIndexVariable ?? 'loopIndex';
258
+
259
+ const array = this.resolveArraySource(formData, inputData);
260
+ const iterationCount = Math.min(array.length, maxIterations);
261
+
262
+ this.log(
263
+ 'info',
264
+ node.id,
265
+ 'AutomationLoopNode',
266
+ `Starting loop over ${iterationCount} item(s)`,
267
+ { totalItems: array.length, maxIterations },
268
+ );
269
+
270
+ const loopEdge = this.getOutgoingEdge(node.id, 'loop');
271
+ if (!loopEdge) {
272
+ throw new Error('Loop node is missing the "loop" handle edge');
273
+ }
274
+
275
+ const bodyStartNode = this.getTargetNode(loopEdge);
276
+ if (!bodyStartNode) {
277
+ throw new Error('Loop body entry node not found');
278
+ }
279
+
280
+ for (let i = 0; i < iterationCount; i++) {
281
+ this.context.variables[currentItemVariable] = array[i] as
282
+ | string
283
+ | number
284
+ | boolean
285
+ | object
286
+ | null;
287
+ this.context.variables[loopIndexVariable] = i;
288
+
289
+ this.log(
290
+ 'info',
291
+ node.id,
292
+ 'AutomationLoopNode',
293
+ `Loop iteration ${i + 1}/${iterationCount}`,
294
+ { [currentItemVariable]: array[i], [loopIndexVariable]: i },
295
+ );
296
+
297
+ await this.executeBodySubgraph(bodyStartNode, node.id, array[i] as
298
+ | string
299
+ | number
300
+ | boolean
301
+ | object
302
+ | null);
303
+ }
304
+
305
+ const result = {
306
+ iterationsRun: iterationCount,
307
+ totalItems: array.length,
308
+ maxIterations,
309
+ };
310
+
311
+ this.storeExecutionResult(node.id, result, true);
312
+
313
+ const doneEdge = this.getOutgoingEdge(node.id, 'done');
314
+ const doneNextNode = doneEdge ? this.getTargetNode(doneEdge) ?? null : null;
315
+
316
+ return { doneNextNode, result };
317
+ }
318
+
131
319
  private async executeStartNode(
132
320
  node: Node,
133
321
  ): Promise<{ runId: string; startTime: string }> {
@@ -522,6 +710,21 @@ export class AutomationExecutionEngine {
522
710
  return inputData;
523
711
  }
524
712
 
713
+ private async executeStubNode(
714
+ node: Node,
715
+ inputData: string | number | boolean | object | null,
716
+ ): Promise<string | number | boolean | object | null> {
717
+ this.log(
718
+ 'info',
719
+ node.id,
720
+ String(node.type || 'node'),
721
+ 'Node executed (stub — full runtime not yet implemented)',
722
+ inputData,
723
+ );
724
+ this.storeExecutionResult(node.id, inputData, true);
725
+ return inputData;
726
+ }
727
+
525
728
  private async executeNode(
526
729
  node: Node,
527
730
  inputData?: string | number | boolean | object | null,
@@ -537,7 +740,14 @@ export class AutomationExecutionEngine {
537
740
  return await this.executeFormattingNode(node, inputData || null);
538
741
  case 'AutomationEndNode':
539
742
  return await this.executeEndNode(node, inputData || null);
743
+ case 'AutomationLoopNode':
744
+ throw new Error(
745
+ 'Loop nodes must be executed via executeLoopNode from the workflow runner',
746
+ );
540
747
  default:
748
+ if (typeof nodeType === 'string' && nodeType.startsWith('Automation')) {
749
+ return await this.executeStubNode(node, inputData || null);
750
+ }
541
751
  throw new Error(`Unknown node type: ${nodeType}`);
542
752
  }
543
753
  }
@@ -560,60 +770,77 @@ export class AutomationExecutionEngine {
560
770
  const visitedNodes = new Set<string>();
561
771
 
562
772
  while (currentNode) {
563
- if (visitedNodes.has(currentNode.id)) {
773
+ if (
774
+ currentNode.type !== 'AutomationLoopNode' &&
775
+ visitedNodes.has(currentNode.id)
776
+ ) {
564
777
  throw new Error(
565
778
  `Circular dependency detected: node ${currentNode.id} already visited`,
566
779
  );
567
780
  }
568
781
 
569
- visitedNodes.add(currentNode.id);
570
-
571
- // Announce node execution start
572
- this.log(
573
- 'info',
574
- currentNode.id,
575
- String(currentNode.type || 'node'),
576
- 'Executing node...',
577
- );
578
-
579
- // Update node status to running
580
- (currentNode.data as Record<string, unknown>).status = 'Running';
581
- if (this.onNodeUpdate) {
582
- // Create new object reference so React detects the change
583
- this.onNodeUpdate(currentNode.id, { ...currentNode.data });
782
+ if (currentNode.type !== 'AutomationLoopNode') {
783
+ visitedNodes.add(currentNode.id);
584
784
  }
585
785
 
586
- try {
587
- currentData = await this.executeNode(currentNode, currentData);
588
- (currentNode.data as Record<string, unknown>).status = 'Completed';
589
- (currentNode.data as Record<string, unknown>).lastRun =
590
- new Date().toISOString();
786
+ if (currentNode.type === 'AutomationLoopNode') {
787
+ (currentNode.data as Record<string, unknown>).status = 'Running';
591
788
  if (this.onNodeUpdate) {
592
- // Create new object reference so React detects the change
593
789
  this.onNodeUpdate(currentNode.id, { ...currentNode.data });
594
790
  }
595
- } catch (error) {
596
- (currentNode.data as Record<string, unknown>).status = 'Error';
597
- (currentNode.data as Record<string, unknown>).lastRun =
598
- new Date().toISOString();
599
- if (this.onNodeUpdate) {
600
- // Create new object reference so React detects the change
601
- this.onNodeUpdate(currentNode.id, { ...currentNode.data });
791
+
792
+ try {
793
+ const { doneNextNode, result } = await this.executeLoopNode(
794
+ currentNode,
795
+ currentData,
796
+ );
797
+ currentData = result;
798
+ (currentNode.data as Record<string, unknown>).status = 'Completed';
799
+ (currentNode.data as Record<string, unknown>).lastRun =
800
+ new Date().toISOString();
801
+ if (this.onNodeUpdate) {
802
+ this.onNodeUpdate(currentNode.id, { ...currentNode.data });
803
+ }
804
+
805
+ if (doneNextNode) {
806
+ this.log(
807
+ 'info',
808
+ doneNextNode.id,
809
+ String(doneNextNode.type || 'node'),
810
+ 'Moving to done path after loop',
811
+ );
812
+ currentNode = doneNextNode;
813
+ } else {
814
+ currentNode = null;
815
+ }
816
+ } catch (error) {
817
+ (currentNode.data as Record<string, unknown>).status = 'Error';
818
+ (currentNode.data as Record<string, unknown>).lastRun =
819
+ new Date().toISOString();
820
+ if (this.onNodeUpdate) {
821
+ this.onNodeUpdate(currentNode.id, { ...currentNode.data });
822
+ }
823
+ throw error;
602
824
  }
603
- throw error;
825
+ continue;
604
826
  }
605
827
 
606
- // Find next node
607
- const nextNodes = this.getNextNodes(currentNode.id);
608
- if (nextNodes.length > 0) {
609
- const next = nextNodes[0];
610
- this.log(
611
- 'info',
612
- next.id,
613
- String(next.type || 'node'),
614
- 'Moving to next node',
615
- );
616
- currentNode = next;
828
+ currentData = await this.runNodeWithStatus(currentNode, currentData);
829
+
830
+ const nextEdge = this.getFirstOutgoingEdge(currentNode.id);
831
+ if (nextEdge) {
832
+ const next = this.getTargetNode(nextEdge);
833
+ if (next) {
834
+ this.log(
835
+ 'info',
836
+ next.id,
837
+ String(next.type || 'node'),
838
+ 'Moving to next node',
839
+ );
840
+ currentNode = next;
841
+ } else {
842
+ currentNode = null;
843
+ }
617
844
  } else {
618
845
  this.log('info', 'workflow', 'engine', 'No next node. Ending.');
619
846
  currentNode = null;
@@ -5,6 +5,7 @@ import {
5
5
  AutomationApiNodeForm,
6
6
  AutomationFormattingNodeForm,
7
7
  AutomationEndNodeForm,
8
+ AutomationLoopNodeForm,
8
9
  } from '../types/automation-node-data-types';
9
10
  import { Node, Edge } from '@xyflow/react';
10
11
 
@@ -165,6 +166,22 @@ export class AutomationFlowProcessor {
165
166
  case 'end':
166
167
  // End node doesn't create new variables
167
168
  break;
169
+
170
+ case 'loop': {
171
+ const currentItemVar = formData.currentItemVariable ?? 'currentItem';
172
+ const loopIndexVar = formData.loopIndexVariable ?? 'loopIndex';
173
+ for (const varName of [currentItemVar, loopIndexVar]) {
174
+ if (!variables.find((v) => v.name === varName)) {
175
+ variables.push({
176
+ name: varName,
177
+ type: varName === loopIndexVar ? 'number' : 'object',
178
+ defaultValue: null,
179
+ description: `Loop iteration variable from ${formData.title}`,
180
+ });
181
+ }
182
+ }
183
+ break;
184
+ }
168
185
  }
169
186
  }
170
187
 
@@ -252,6 +269,12 @@ module.exports = { executeAutomationWorkflow };
252
269
  case 'end':
253
270
  code += this.generateEndNodeCode(formData as AutomationEndNodeForm);
254
271
  break;
272
+ case 'loop':
273
+ code += this.generateLoopNodeCode(
274
+ formData as AutomationLoopNodeForm,
275
+ this.edges,
276
+ );
277
+ break;
255
278
  }
256
279
 
257
280
  return code;
@@ -539,6 +562,53 @@ module.exports = { executeAutomationWorkflow };
539
562
  `;
540
563
  }
541
564
 
565
+ private generateLoopNodeCode(
566
+ formData: AutomationLoopNodeForm,
567
+ edges: Edge[],
568
+ ): string {
569
+ const maxIterations = formData.maxIterations ?? 100;
570
+ const currentItemVariable = formData.currentItemVariable ?? 'currentItem';
571
+ const loopIndexVariable = formData.loopIndexVariable ?? 'loopIndex';
572
+
573
+ const arrayExpr =
574
+ formData.arraySource.type === 'variable'
575
+ ? `context.variables['${formData.arraySource.variableName}']`
576
+ : '/* previous step output */ context.variables.__previousStepOutput';
577
+
578
+ const loopEdge = edges.find(
579
+ (edge) => edge.source === formData.nodeId && edge.sourceHandle === 'loop',
580
+ );
581
+ const doneEdge = edges.find(
582
+ (edge) => edge.source === formData.nodeId && edge.sourceHandle === 'done',
583
+ );
584
+
585
+ const bodyComment = loopEdge
586
+ ? `// Body starts at node: ${loopEdge.target}`
587
+ : '// Body entry not connected';
588
+ const doneComment = doneEdge
589
+ ? `// Continue on done path at node: ${doneEdge.target}`
590
+ : '// Done path not connected';
591
+
592
+ return ` // Loop Node: ${formData.title}
593
+ context.logs.push({
594
+ timestamp: new Date().toISOString(),
595
+ level: 'info',
596
+ nodeId: '${formData.nodeId}',
597
+ message: 'Starting for-each loop',
598
+ });
599
+
600
+ const __loopArray = Array.isArray(${arrayExpr}) ? ${arrayExpr} : [${arrayExpr}];
601
+ const __maxIter = ${maxIterations};
602
+ for (let __i = 0; __i < Math.min(__loopArray.length, __maxIter); __i++) {
603
+ context.variables['${currentItemVariable}'] = __loopArray[__i];
604
+ context.variables['${loopIndexVariable}'] = __i;
605
+ ${bodyComment}
606
+ // Execute loop body nodes here (back-edge returns to loop node)
607
+ }
608
+ ${doneComment}
609
+ `;
610
+ }
611
+
542
612
  /**
543
613
  * Export workflow data as JSON
544
614
  */
@@ -0,0 +1,104 @@
1
+ import { Edge, Node } from '@xyflow/react';
2
+ import { AutomationLoopNodeForm } from '../types/automation-node-data-types';
3
+
4
+ export interface LoopNodeValidationResult {
5
+ valid: boolean;
6
+ errors: string[];
7
+ }
8
+
9
+ function getFormData(node: Node): Partial<AutomationLoopNodeForm> {
10
+ return (node.data?.formData ?? node.data ?? {}) as Partial<AutomationLoopNodeForm>;
11
+ }
12
+
13
+ function getOutgoingEdges(nodeId: string, edges: Edge[], sourceHandle?: string): Edge[] {
14
+ return edges.filter(
15
+ (edge) =>
16
+ edge.source === nodeId &&
17
+ (sourceHandle === undefined || edge.sourceHandle === sourceHandle),
18
+ );
19
+ }
20
+
21
+ function getReachableBodyNodeIds(
22
+ startNodeId: string,
23
+ loopNodeId: string,
24
+ edges: Edge[],
25
+ ): Set<string> {
26
+ const reachable = new Set<string>();
27
+ const queue = [startNodeId];
28
+
29
+ while (queue.length > 0) {
30
+ const currentId = queue.shift()!;
31
+ if (currentId === loopNodeId || reachable.has(currentId)) {
32
+ continue;
33
+ }
34
+ reachable.add(currentId);
35
+
36
+ const outgoing = edges.filter((edge) => edge.source === currentId);
37
+ for (const edge of outgoing) {
38
+ if (edge.target !== loopNodeId && !reachable.has(edge.target)) {
39
+ queue.push(edge.target);
40
+ }
41
+ }
42
+ }
43
+
44
+ return reachable;
45
+ }
46
+
47
+ export function validateAutomationLoopNode(
48
+ node: Node,
49
+ edges: Edge[],
50
+ ): LoopNodeValidationResult {
51
+ const errors: string[] = [];
52
+ const formData = getFormData(node);
53
+ const nodeId = node.id;
54
+
55
+ if (!formData.arraySource?.type) {
56
+ errors.push('Array data source is not configured');
57
+ } else if (
58
+ formData.arraySource.type === 'variable' &&
59
+ !formData.arraySource.variableName?.trim()
60
+ ) {
61
+ errors.push('Variable name is required when array source type is "variable"');
62
+ }
63
+
64
+ const loopEdges = getOutgoingEdges(nodeId, edges, 'loop');
65
+ const doneEdges = getOutgoingEdges(nodeId, edges, 'done');
66
+
67
+ if (loopEdges.length === 0) {
68
+ errors.push('Loop node must have exactly one outgoing edge on the "loop" handle');
69
+ } else if (loopEdges.length > 1) {
70
+ errors.push('Loop node must have only one outgoing edge on the "loop" handle');
71
+ }
72
+
73
+ if (doneEdges.length === 0) {
74
+ errors.push('Loop node must have exactly one outgoing edge on the "done" handle');
75
+ } else if (doneEdges.length > 1) {
76
+ errors.push('Loop node must have only one outgoing edge on the "done" handle');
77
+ }
78
+
79
+ if (loopEdges.length === 1) {
80
+ const bodyStartId = loopEdges[0].target;
81
+ const bodyNodeIds = getReachableBodyNodeIds(bodyStartId, nodeId, edges);
82
+ const hasBackEdge = edges.some(
83
+ (edge) => edge.target === nodeId && bodyNodeIds.has(edge.source),
84
+ );
85
+
86
+ if (!hasBackEdge) {
87
+ errors.push(
88
+ 'Loop body must connect back to the Loop node to advance iterations',
89
+ );
90
+ }
91
+ }
92
+
93
+ return {
94
+ valid: errors.length === 0,
95
+ errors,
96
+ };
97
+ }
98
+
99
+ export function assertAutomationLoopNodeValid(node: Node, edges: Edge[]): void {
100
+ const result = validateAutomationLoopNode(node, edges);
101
+ if (!result.valid) {
102
+ throw new Error(result.errors.join('; '));
103
+ }
104
+ }