@objectstack/service-automation 3.0.8 → 3.0.10

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.
package/src/engine.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import type { FlowParsed, FlowNodeParsed } from '@objectstack/spec/automation';
3
+ import type { FlowParsed, FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation';
4
+ import type { ExecutionLog } from '@objectstack/spec/automation';
4
5
  import type { AutomationContext, AutomationResult, IAutomationService } from '@objectstack/spec/contracts';
5
6
  import type { Logger } from '@objectstack/spec/contracts';
6
7
  import { FlowSchema } from '@objectstack/spec/automation';
@@ -51,11 +52,48 @@ export interface FlowTrigger {
51
52
 
52
53
  // ─── Core Automation Engine ─────────────────────────────────────────
53
54
 
55
+ /**
56
+ * Internal execution step log entry.
57
+ */
58
+ interface StepLogEntry {
59
+ nodeId: string;
60
+ nodeType: string;
61
+ nodeLabel?: string;
62
+ status: 'success' | 'failure' | 'skipped';
63
+ startedAt: string;
64
+ completedAt?: string;
65
+ durationMs?: number;
66
+ error?: { code: string; message: string; stack?: string };
67
+ }
68
+
69
+ /**
70
+ * Internal execution log entry — compatible with ExecutionLog from spec.
71
+ */
72
+ interface ExecutionLogEntry {
73
+ id: string;
74
+ flowName: string;
75
+ flowVersion?: number;
76
+ status: ExecutionLog['status'];
77
+ startedAt: string;
78
+ completedAt?: string;
79
+ durationMs?: number;
80
+ trigger: { type: string; userId?: string; object?: string; recordId?: string };
81
+ steps: StepLogEntry[];
82
+ variables?: Record<string, unknown>;
83
+ output?: unknown;
84
+ error?: string;
85
+ }
86
+
54
87
  export class AutomationEngine implements IAutomationService {
55
88
  private flows = new Map<string, FlowParsed>();
89
+ private flowEnabled = new Map<string, boolean>();
90
+ private flowVersionHistory = new Map<string, Array<{ version: number; definition: FlowParsed; createdAt: string }>>();
56
91
  private nodeExecutors = new Map<string, NodeExecutor>();
57
92
  private triggers = new Map<string, FlowTrigger>();
93
+ private executionLogs: ExecutionLogEntry[] = [];
94
+ private maxLogSize = 1000;
58
95
  private logger: Logger;
96
+ private runCounter = 0;
59
97
 
60
98
  constructor(logger: Logger) {
61
99
  this.logger = logger;
@@ -104,12 +142,30 @@ export class AutomationEngine implements IAutomationService {
104
142
 
105
143
  registerFlow(name: string, definition: unknown): void {
106
144
  const parsed = FlowSchema.parse(definition);
145
+
146
+ // DAG cycle detection
147
+ this.detectCycles(parsed);
148
+
149
+ // Version history management
150
+ const history = this.flowVersionHistory.get(name) ?? [];
151
+ history.push({
152
+ version: parsed.version,
153
+ definition: parsed,
154
+ createdAt: new Date().toISOString(),
155
+ });
156
+ this.flowVersionHistory.set(name, history);
157
+
107
158
  this.flows.set(name, parsed);
108
- this.logger.info(`Flow registered: ${name}`);
159
+ if (!this.flowEnabled.has(name)) {
160
+ this.flowEnabled.set(name, true);
161
+ }
162
+ this.logger.info(`Flow registered: ${name} (version ${parsed.version})`);
109
163
  }
110
164
 
111
165
  unregisterFlow(name: string): void {
112
166
  this.flows.delete(name);
167
+ this.flowEnabled.delete(name);
168
+ this.flowVersionHistory.delete(name);
113
169
  this.logger.info(`Flow unregistered: ${name}`);
114
170
  }
115
171
 
@@ -117,6 +173,47 @@ export class AutomationEngine implements IAutomationService {
117
173
  return [...this.flows.keys()];
118
174
  }
119
175
 
176
+ async getFlow(name: string): Promise<FlowParsed | null> {
177
+ return this.flows.get(name) ?? null;
178
+ }
179
+
180
+ async toggleFlow(name: string, enabled: boolean): Promise<void> {
181
+ if (!this.flows.has(name)) {
182
+ throw new Error(`Flow '${name}' not found`);
183
+ }
184
+ this.flowEnabled.set(name, enabled);
185
+ this.logger.info(`Flow '${name}' ${enabled ? 'enabled' : 'disabled'}`);
186
+ }
187
+
188
+ /** Get flow version history */
189
+ getFlowVersionHistory(name: string): Array<{ version: number; definition: FlowParsed; createdAt: string }> {
190
+ return this.flowVersionHistory.get(name) ?? [];
191
+ }
192
+
193
+ /** Rollback flow to a specific version */
194
+ rollbackFlow(name: string, version: number): void {
195
+ const history = this.flowVersionHistory.get(name);
196
+ if (!history) {
197
+ throw new Error(`Flow '${name}' has no version history`);
198
+ }
199
+ const entry = history.find(h => h.version === version);
200
+ if (!entry) {
201
+ throw new Error(`Version ${version} not found for flow '${name}'`);
202
+ }
203
+ this.flows.set(name, entry.definition);
204
+ this.logger.info(`Flow '${name}' rolled back to version ${version}`);
205
+ }
206
+
207
+ async listRuns(flowName: string, options?: { limit?: number; cursor?: string }): Promise<ExecutionLogEntry[]> {
208
+ const limit = options?.limit ?? 20;
209
+ const logs = this.executionLogs.filter(l => l.flowName === flowName);
210
+ return logs.slice(-limit).reverse();
211
+ }
212
+
213
+ async getRun(runId: string): Promise<ExecutionLogEntry | null> {
214
+ return this.executionLogs.find(l => l.id === runId) ?? null;
215
+ }
216
+
120
217
  async execute(flowName: string, context?: AutomationContext): Promise<AutomationResult> {
121
218
  const startTime = Date.now();
122
219
  const flow = this.flows.get(flowName);
@@ -125,6 +222,11 @@ export class AutomationEngine implements IAutomationService {
125
222
  return { success: false, error: `Flow '${flowName}' not found` };
126
223
  }
127
224
 
225
+ // Check if flow is disabled
226
+ if (this.flowEnabled.get(flowName) === false) {
227
+ return { success: false, error: `Flow '${flowName}' is disabled` };
228
+ }
229
+
128
230
  // Initialize variable context
129
231
  const variables = new Map<string, unknown>();
130
232
  if (flow.variables) {
@@ -139,6 +241,10 @@ export class AutomationEngine implements IAutomationService {
139
241
  variables.set('$record', context.record);
140
242
  }
141
243
 
244
+ const runId = `run_${++this.runCounter}`;
245
+ const startedAt = new Date().toISOString();
246
+ const steps: StepLogEntry[] = [];
247
+
142
248
  try {
143
249
  // Find the start node
144
250
  const startNode = flow.nodes.find(n => n.type === 'start');
@@ -146,8 +252,11 @@ export class AutomationEngine implements IAutomationService {
146
252
  return { success: false, error: 'Flow has no start node' };
147
253
  }
148
254
 
255
+ // Validate node input schemas before execution
256
+ this.validateNodeInputSchemas(flow, variables);
257
+
149
258
  // DAG traversal execution
150
- await this.executeNode(startNode, flow, variables, context ?? {});
259
+ await this.executeNode(startNode, flow, variables, context ?? {}, steps);
151
260
 
152
261
  // Collect output variables
153
262
  const output: Record<string, unknown> = {};
@@ -159,14 +268,53 @@ export class AutomationEngine implements IAutomationService {
159
268
  }
160
269
  }
161
270
 
271
+ const durationMs = Date.now() - startTime;
272
+
273
+ // Record execution log
274
+ this.recordLog({
275
+ id: runId,
276
+ flowName,
277
+ flowVersion: flow.version,
278
+ status: 'completed',
279
+ startedAt,
280
+ completedAt: new Date().toISOString(),
281
+ durationMs,
282
+ trigger: {
283
+ type: context?.event ?? 'manual',
284
+ userId: context?.userId,
285
+ object: context?.object,
286
+ },
287
+ steps,
288
+ output,
289
+ });
290
+
162
291
  return {
163
292
  success: true,
164
293
  output,
165
- durationMs: Date.now() - startTime,
294
+ durationMs,
166
295
  };
167
296
  } catch (err: unknown) {
168
297
  const errorMessage = err instanceof Error ? err.message : String(err);
169
298
 
299
+ // Record failed execution log
300
+ const durationMs = Date.now() - startTime;
301
+ this.recordLog({
302
+ id: runId,
303
+ flowName,
304
+ flowVersion: flow.version,
305
+ status: 'failed',
306
+ startedAt,
307
+ completedAt: new Date().toISOString(),
308
+ durationMs,
309
+ trigger: {
310
+ type: context?.event ?? 'manual',
311
+ userId: context?.userId,
312
+ object: context?.object,
313
+ },
314
+ steps,
315
+ error: errorMessage,
316
+ });
317
+
170
318
  // Error handling strategy
171
319
  if (flow.errorHandling?.strategy === 'retry') {
172
320
  return this.retryExecution(flowName, context, startTime, flow.errorHandling);
@@ -174,34 +322,227 @@ export class AutomationEngine implements IAutomationService {
174
322
  return {
175
323
  success: false,
176
324
  error: errorMessage,
177
- durationMs: Date.now() - startTime,
325
+ durationMs,
178
326
  };
179
327
  }
180
328
  }
181
329
 
182
330
  // ── DAG Traversal Core ──────────────────────────────────
183
331
 
332
+ private recordLog(entry: ExecutionLogEntry): void {
333
+ this.executionLogs.push(entry);
334
+ // Evict oldest logs when exceeding max size
335
+ if (this.executionLogs.length > this.maxLogSize) {
336
+ this.executionLogs.splice(0, this.executionLogs.length - this.maxLogSize);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Detect cycles in the flow graph (DAG validation).
342
+ * Uses DFS with coloring (white/gray/black) to detect back edges.
343
+ * Throws an error with cycle details if a cycle is found.
344
+ */
345
+ private detectCycles(flow: FlowParsed): void {
346
+ const WHITE = 0, GRAY = 1, BLACK = 2;
347
+ const color = new Map<string, number>();
348
+ const parent = new Map<string, string>();
349
+
350
+ // Build adjacency list from edges
351
+ const adj = new Map<string, string[]>();
352
+ for (const node of flow.nodes) {
353
+ color.set(node.id, WHITE);
354
+ adj.set(node.id, []);
355
+ }
356
+ for (const edge of flow.edges) {
357
+ const targets = adj.get(edge.source);
358
+ if (targets) targets.push(edge.target);
359
+ }
360
+
361
+ const dfs = (nodeId: string): string[] | null => {
362
+ color.set(nodeId, GRAY);
363
+ for (const neighbor of adj.get(nodeId) ?? []) {
364
+ if (color.get(neighbor) === GRAY) {
365
+ // Back edge found — reconstruct cycle
366
+ const cycle = [neighbor, nodeId];
367
+ let cur = nodeId;
368
+ while (cur !== neighbor) {
369
+ cur = parent.get(cur)!;
370
+ if (cur) cycle.push(cur);
371
+ else break;
372
+ }
373
+ return cycle.reverse();
374
+ }
375
+ if (color.get(neighbor) === WHITE) {
376
+ parent.set(neighbor, nodeId);
377
+ const result = dfs(neighbor);
378
+ if (result) return result;
379
+ }
380
+ }
381
+ color.set(nodeId, BLACK);
382
+ return null;
383
+ };
384
+
385
+ for (const node of flow.nodes) {
386
+ if (color.get(node.id) === WHITE) {
387
+ const cycle = dfs(node.id);
388
+ if (cycle) {
389
+ throw new Error(`Flow contains a cycle: ${cycle.join(' → ')}. Only DAG flows are allowed.`);
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Get the runtime type name of a value for schema validation.
397
+ */
398
+ private getValueType(value: unknown): string {
399
+ if (Array.isArray(value)) return 'array';
400
+ if (typeof value === 'object' && value !== null) return 'object';
401
+ return typeof value;
402
+ }
403
+
404
+ /**
405
+ * Validate node input schemas before execution.
406
+ * Checks that node config matches declared inputSchema if present.
407
+ */
408
+ private validateNodeInputSchemas(flow: FlowParsed, _variables: Map<string, unknown>): void {
409
+ for (const node of flow.nodes) {
410
+ if (node.inputSchema && node.config) {
411
+ for (const [paramName, paramDef] of Object.entries(node.inputSchema)) {
412
+ if (paramDef.required && !(paramName in (node.config as Record<string, unknown>))) {
413
+ throw new Error(
414
+ `Node '${node.id}' missing required input parameter '${paramName}'`,
415
+ );
416
+ }
417
+ const value = (node.config as Record<string, unknown>)[paramName];
418
+ if (value !== undefined) {
419
+ const actualType = this.getValueType(value);
420
+ if (actualType !== paramDef.type) {
421
+ throw new Error(
422
+ `Node '${node.id}' parameter '${paramName}' expected type '${paramDef.type}' but got '${actualType}'`,
423
+ );
424
+ }
425
+ }
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Execute a node with timeout support, fault edge handling, and step logging.
433
+ */
184
434
  private async executeNode(
185
435
  node: FlowNodeParsed,
186
436
  flow: FlowParsed,
187
437
  variables: Map<string, unknown>,
188
438
  context: AutomationContext,
439
+ steps: StepLogEntry[],
189
440
  ): Promise<void> {
190
441
  if (node.type === 'end') return;
191
442
 
443
+ const stepStart = Date.now();
444
+ const stepStartedAt = new Date().toISOString();
445
+
192
446
  // Find executor
193
447
  const executor = this.nodeExecutors.get(node.type);
194
448
  if (!executor) {
195
449
  // start node without executor is fine — just skip
196
450
  if (node.type !== 'start') {
451
+ steps.push({
452
+ nodeId: node.id,
453
+ nodeType: node.type,
454
+ status: 'failure',
455
+ startedAt: stepStartedAt,
456
+ completedAt: new Date().toISOString(),
457
+ durationMs: Date.now() - stepStart,
458
+ error: { code: 'NO_EXECUTOR', message: `No executor registered for node type '${node.type}'` },
459
+ });
197
460
  throw new Error(`No executor registered for node type '${node.type}'`);
198
461
  }
462
+ // Log start node step
463
+ steps.push({
464
+ nodeId: node.id,
465
+ nodeType: node.type,
466
+ status: 'success',
467
+ startedAt: stepStartedAt,
468
+ completedAt: new Date().toISOString(),
469
+ durationMs: Date.now() - stepStart,
470
+ });
199
471
  } else {
200
- // Execute node
201
- const result = await executor.execute(node, variables, context);
472
+ // Execute node with optional timeout
473
+ let result: NodeExecutionResult;
474
+ try {
475
+ if (node.timeoutMs && node.timeoutMs > 0) {
476
+ result = await this.executeWithTimeout(
477
+ executor.execute(node, variables, context),
478
+ node.timeoutMs,
479
+ node.id,
480
+ );
481
+ } else {
482
+ result = await executor.execute(node, variables, context);
483
+ }
484
+ } catch (execErr: unknown) {
485
+ const errMsg = execErr instanceof Error ? execErr.message : String(execErr);
486
+ steps.push({
487
+ nodeId: node.id,
488
+ nodeType: node.type,
489
+ status: 'failure',
490
+ startedAt: stepStartedAt,
491
+ completedAt: new Date().toISOString(),
492
+ durationMs: Date.now() - stepStart,
493
+ error: { code: 'EXECUTION_ERROR', message: errMsg },
494
+ });
495
+
496
+ // Check for fault edges
497
+ const faultEdge = flow.edges.find(e => e.source === node.id && e.type === 'fault');
498
+ if (faultEdge) {
499
+ variables.set('$error', { nodeId: node.id, message: errMsg });
500
+ const faultTarget = flow.nodes.find(n => n.id === faultEdge.target);
501
+ if (faultTarget) {
502
+ await this.executeNode(faultTarget, flow, variables, context, steps);
503
+ return;
504
+ }
505
+ }
506
+ throw execErr;
507
+ }
508
+
202
509
  if (!result.success) {
203
- throw new Error(`Node '${node.id}' failed: ${result.error}`);
510
+ const errMsg = result.error ?? 'Unknown error';
511
+ steps.push({
512
+ nodeId: node.id,
513
+ nodeType: node.type,
514
+ status: 'failure',
515
+ startedAt: stepStartedAt,
516
+ completedAt: new Date().toISOString(),
517
+ durationMs: Date.now() - stepStart,
518
+ error: { code: 'NODE_FAILURE', message: errMsg },
519
+ });
520
+
521
+ // Write error output to variable context for downstream nodes
522
+ variables.set('$error', { nodeId: node.id, message: errMsg, output: result.output });
523
+
524
+ // Check for fault edges
525
+ const faultEdge = flow.edges.find(e => e.source === node.id && e.type === 'fault');
526
+ if (faultEdge) {
527
+ const faultTarget = flow.nodes.find(n => n.id === faultEdge.target);
528
+ if (faultTarget) {
529
+ await this.executeNode(faultTarget, flow, variables, context, steps);
530
+ return;
531
+ }
532
+ }
533
+ throw new Error(`Node '${node.id}' failed: ${errMsg}`);
204
534
  }
535
+
536
+ // Log successful step
537
+ steps.push({
538
+ nodeId: node.id,
539
+ nodeType: node.type,
540
+ status: 'success',
541
+ startedAt: stepStartedAt,
542
+ completedAt: new Date().toISOString(),
543
+ durationMs: Date.now() - stepStart,
544
+ });
545
+
205
546
  // Write back output variables
206
547
  if (result.output) {
207
548
  for (const [key, value] of Object.entries(result.output)) {
@@ -210,48 +551,257 @@ export class AutomationEngine implements IAutomationService {
210
551
  }
211
552
  }
212
553
 
213
- // Find next nodes (filter by edge conditions)
214
- const outEdges = flow.edges.filter(e => e.source === node.id);
554
+ // Find next nodes separate conditional and unconditional edges
555
+ const outEdges = flow.edges.filter(
556
+ e => e.source === node.id && e.type !== 'fault',
557
+ );
558
+
559
+ const conditionalEdges: FlowEdgeParsed[] = [];
560
+ const unconditionalEdges: FlowEdgeParsed[] = [];
215
561
  for (const edge of outEdges) {
216
- if (edge.condition && !this.evaluateCondition(edge.condition, variables)) {
217
- continue;
562
+ if (edge.condition) {
563
+ conditionalEdges.push(edge);
564
+ } else {
565
+ unconditionalEdges.push(edge);
218
566
  }
219
- const nextNode = flow.nodes.find(n => n.id === edge.target);
220
- if (nextNode) {
221
- await this.executeNode(nextNode, flow, variables, context);
567
+ }
568
+
569
+ // Conditional edges: evaluate sequentially (mutually exclusive)
570
+ for (const edge of conditionalEdges) {
571
+ if (this.evaluateCondition(edge.condition!, variables)) {
572
+ const nextNode = flow.nodes.find(n => n.id === edge.target);
573
+ if (nextNode) {
574
+ await this.executeNode(nextNode, flow, variables, context, steps);
575
+ }
222
576
  }
223
577
  }
578
+
579
+ // Unconditional edges: execute in parallel (Promise.all)
580
+ if (unconditionalEdges.length > 0) {
581
+ const parallelTasks = unconditionalEdges
582
+ .map(edge => flow.nodes.find(n => n.id === edge.target))
583
+ .filter((n): n is FlowNodeParsed => n != null)
584
+ .map(nextNode => this.executeNode(nextNode, flow, variables, context, steps));
585
+
586
+ await Promise.all(parallelTasks);
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Execute a promise with timeout using Promise.race.
592
+ */
593
+ private executeWithTimeout(
594
+ promise: Promise<NodeExecutionResult>,
595
+ timeoutMs: number,
596
+ nodeId: string,
597
+ ): Promise<NodeExecutionResult> {
598
+ return Promise.race([
599
+ promise,
600
+ new Promise<NodeExecutionResult>((_, reject) =>
601
+ setTimeout(() => reject(new Error(`Node '${nodeId}' timed out after ${timeoutMs}ms`)), timeoutMs),
602
+ ),
603
+ ]);
224
604
  }
225
605
 
226
- private evaluateCondition(expression: string, variables: Map<string, unknown>): boolean {
227
- // MVP: Simple template replacement + expression evaluation.
228
- // Flow definitions are authored by trusted developers/admins.
229
- // TODO: Replace with safe expression evaluator (e.g., jexl) for production.
606
+ /**
607
+ * Safe expression evaluator.
608
+ * Uses simple operator-based parsing without `new Function`.
609
+ * Supports: comparisons (>, <, >=, <=, ==, !=, ===, !==),
610
+ * boolean literals (true, false), and basic arithmetic.
611
+ */
612
+ evaluateCondition(expression: string, variables: Map<string, unknown>): boolean {
613
+ // Template replacement: {varName} → value
230
614
  let resolved = expression;
231
615
  for (const [key, value] of variables) {
232
616
  resolved = resolved.split(`{${key}}`).join(String(value));
233
617
  }
618
+ resolved = resolved.trim();
619
+
234
620
  try {
235
- return new Function(`return (${resolved})`)() as boolean;
621
+ // Boolean literals
622
+ if (resolved === 'true') return true;
623
+ if (resolved === 'false') return false;
624
+
625
+ // Comparison operators (ordered by length to match longer operators first)
626
+ const operators = ['===', '!==', '>=', '<=', '!=', '==', '>', '<'] as const;
627
+ for (const op of operators) {
628
+ const idx = resolved.indexOf(op);
629
+ if (idx !== -1) {
630
+ const left = resolved.slice(0, idx).trim();
631
+ const right = resolved.slice(idx + op.length).trim();
632
+ return this.compareValues(left, op, right);
633
+ }
634
+ }
635
+
636
+ // Numeric truthy check
637
+ const numVal = Number(resolved);
638
+ if (!isNaN(numVal)) return numVal !== 0;
639
+
640
+ return false;
236
641
  } catch {
237
642
  return false;
238
643
  }
239
644
  }
240
645
 
646
+ /**
647
+ * Compare two string-represented values with an operator.
648
+ */
649
+ private compareValues(left: string, op: string, right: string): boolean {
650
+ const lNum = Number(left);
651
+ const rNum = Number(right);
652
+ const bothNumeric = !isNaN(lNum) && !isNaN(rNum) && left !== '' && right !== '';
653
+
654
+ if (bothNumeric) {
655
+ switch (op) {
656
+ case '>': return lNum > rNum;
657
+ case '<': return lNum < rNum;
658
+ case '>=': return lNum >= rNum;
659
+ case '<=': return lNum <= rNum;
660
+ case '==': case '===': return lNum === rNum;
661
+ case '!=': case '!==': return lNum !== rNum;
662
+ default: return false;
663
+ }
664
+ }
665
+ // String comparison
666
+ switch (op) {
667
+ case '==': case '===': return left === right;
668
+ case '!=': case '!==': return left !== right;
669
+ case '>': return left > right;
670
+ case '<': return left < right;
671
+ case '>=': return left >= right;
672
+ case '<=': return left <= right;
673
+ default: return false;
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Retry execution with exponential backoff, jitter, and recursive protection.
679
+ * Uses an iterative loop with an internal retry flag to prevent recursive call stacking.
680
+ */
241
681
  private async retryExecution(
242
682
  flowName: string,
243
683
  context: AutomationContext | undefined,
244
684
  startTime: number,
245
- errorHandling: { maxRetries?: number; retryDelayMs?: number },
685
+ errorHandling: {
686
+ maxRetries?: number;
687
+ retryDelayMs?: number;
688
+ backoffMultiplier?: number;
689
+ maxRetryDelayMs?: number;
690
+ jitter?: boolean;
691
+ },
246
692
  ): Promise<AutomationResult> {
247
693
  const maxRetries = errorHandling.maxRetries ?? 3;
248
- const delay = errorHandling.retryDelayMs ?? 1000;
694
+ const baseDelay = errorHandling.retryDelayMs ?? 1000;
695
+ const multiplier = errorHandling.backoffMultiplier ?? 1;
696
+ const maxDelay = errorHandling.maxRetryDelayMs ?? 30000;
697
+ const useJitter = errorHandling.jitter ?? false;
249
698
 
699
+ let lastError = 'Max retries exceeded';
250
700
  for (let i = 0; i < maxRetries; i++) {
701
+ // Calculate delay with exponential backoff
702
+ let delay = Math.min(baseDelay * Math.pow(multiplier, i), maxDelay);
703
+ if (useJitter) {
704
+ delay = delay * (0.5 + Math.random() * 0.5);
705
+ }
251
706
  await new Promise(r => setTimeout(r, delay));
252
- const result = await this.execute(flowName, context);
707
+
708
+ // Execute directly without recursion into retryExecution again
709
+ const result = await this.executeWithoutRetry(flowName, context);
253
710
  if (result.success) return result;
711
+ lastError = result.error ?? 'Unknown error';
712
+ }
713
+ return { success: false, error: lastError, durationMs: Date.now() - startTime };
714
+ }
715
+
716
+ /**
717
+ * Execute a flow without triggering retry logic (used by retryExecution to prevent recursion).
718
+ */
719
+ private async executeWithoutRetry(
720
+ flowName: string,
721
+ context?: AutomationContext,
722
+ ): Promise<AutomationResult> {
723
+ const startTime = Date.now();
724
+ const flow = this.flows.get(flowName);
725
+
726
+ if (!flow) {
727
+ return { success: false, error: `Flow '${flowName}' not found` };
728
+ }
729
+ if (this.flowEnabled.get(flowName) === false) {
730
+ return { success: false, error: `Flow '${flowName}' is disabled` };
731
+ }
732
+
733
+ const variables = new Map<string, unknown>();
734
+ if (flow.variables) {
735
+ for (const v of flow.variables) {
736
+ if (v.isInput && context?.params?.[v.name] !== undefined) {
737
+ variables.set(v.name, context.params[v.name]);
738
+ }
739
+ }
740
+ }
741
+ if (context?.record) {
742
+ variables.set('$record', context.record);
743
+ }
744
+
745
+ const runId = `run_${++this.runCounter}`;
746
+ const startedAt = new Date().toISOString();
747
+ const steps: StepLogEntry[] = [];
748
+
749
+ try {
750
+ const startNode = flow.nodes.find(n => n.type === 'start');
751
+ if (!startNode) {
752
+ return { success: false, error: 'Flow has no start node' };
753
+ }
754
+
755
+ await this.executeNode(startNode, flow, variables, context ?? {}, steps);
756
+
757
+ const output: Record<string, unknown> = {};
758
+ if (flow.variables) {
759
+ for (const v of flow.variables) {
760
+ if (v.isOutput) {
761
+ output[v.name] = variables.get(v.name);
762
+ }
763
+ }
764
+ }
765
+
766
+ const durationMs = Date.now() - startTime;
767
+ this.recordLog({
768
+ id: runId,
769
+ flowName,
770
+ flowVersion: flow.version,
771
+ status: 'completed',
772
+ startedAt,
773
+ completedAt: new Date().toISOString(),
774
+ durationMs,
775
+ trigger: {
776
+ type: context?.event ?? 'manual',
777
+ userId: context?.userId,
778
+ object: context?.object,
779
+ },
780
+ steps,
781
+ output,
782
+ });
783
+
784
+ return { success: true, output, durationMs };
785
+ } catch (err: unknown) {
786
+ const errorMessage = err instanceof Error ? err.message : String(err);
787
+ const durationMs = Date.now() - startTime;
788
+ this.recordLog({
789
+ id: runId,
790
+ flowName,
791
+ flowVersion: flow.version,
792
+ status: 'failed',
793
+ startedAt,
794
+ completedAt: new Date().toISOString(),
795
+ durationMs,
796
+ trigger: {
797
+ type: context?.event ?? 'manual',
798
+ userId: context?.userId,
799
+ object: context?.object,
800
+ },
801
+ steps,
802
+ error: errorMessage,
803
+ });
804
+ return { success: false, error: errorMessage, durationMs };
254
805
  }
255
- return { success: false, error: 'Max retries exceeded', durationMs: Date.now() - startTime };
256
806
  }
257
807
  }