@objectstack/service-automation 4.0.3 → 4.0.5

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 DELETED
@@ -1,807 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { FlowParsed, FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation';
4
- import type { ExecutionLog } from '@objectstack/spec/automation';
5
- import type { AutomationContext, AutomationResult, IAutomationService } from '@objectstack/spec/contracts';
6
- import type { Logger } from '@objectstack/spec/contracts';
7
- import { FlowSchema } from '@objectstack/spec/automation';
8
-
9
- // ─── Node Executor Interface (Plugin Extension Point) ───────────────
10
-
11
- /**
12
- * Each node type corresponds to a NodeExecutor.
13
- * Third-party plugins only need to implement this interface and register
14
- * it with the engine to extend automation capabilities.
15
- */
16
- export interface NodeExecutor {
17
- /** Corresponds to FlowNodeAction enum value */
18
- readonly type: string;
19
-
20
- /**
21
- * Execute a node
22
- * @param node - Current node definition
23
- * @param variables - Flow variable context (read/write)
24
- * @param context - Trigger context
25
- * @returns Execution result (may include output data, branch conditions, etc.)
26
- */
27
- execute(
28
- node: FlowNodeParsed,
29
- variables: Map<string, unknown>,
30
- context: AutomationContext,
31
- ): Promise<NodeExecutionResult>;
32
- }
33
-
34
- export interface NodeExecutionResult {
35
- success: boolean;
36
- output?: Record<string, unknown>;
37
- error?: string;
38
- /** Used by decision nodes — returns the selected branch label */
39
- branchLabel?: string;
40
- }
41
-
42
- // ─── Trigger Interface (Plugin Extension Point) ─────────────────────
43
-
44
- /**
45
- * Trigger interface. Schedule/Event/API triggers are registered via plugins.
46
- */
47
- export interface FlowTrigger {
48
- readonly type: string;
49
- start(flowName: string, callback: (ctx: AutomationContext) => Promise<void>): void;
50
- stop(flowName: string): void;
51
- }
52
-
53
- // ─── Core Automation Engine ─────────────────────────────────────────
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
-
87
- export class AutomationEngine implements IAutomationService {
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 }>>();
91
- private nodeExecutors = new Map<string, NodeExecutor>();
92
- private triggers = new Map<string, FlowTrigger>();
93
- private executionLogs: ExecutionLogEntry[] = [];
94
- private maxLogSize = 1000;
95
- private logger: Logger;
96
- private runCounter = 0;
97
-
98
- constructor(logger: Logger) {
99
- this.logger = logger;
100
- }
101
-
102
- // ── Plugin Extension API ──────────────────────────────
103
-
104
- /** Register a node executor (called by plugins) */
105
- registerNodeExecutor(executor: NodeExecutor): void {
106
- if (this.nodeExecutors.has(executor.type)) {
107
- this.logger.warn(`Node executor '${executor.type}' replaced`);
108
- }
109
- this.nodeExecutors.set(executor.type, executor);
110
- this.logger.info(`Node executor registered: ${executor.type}`);
111
- }
112
-
113
- /** Unregister a node executor (hot-unplug) */
114
- unregisterNodeExecutor(type: string): void {
115
- this.nodeExecutors.delete(type);
116
- this.logger.info(`Node executor unregistered: ${type}`);
117
- }
118
-
119
- /** Register a trigger (called by plugins) */
120
- registerTrigger(trigger: FlowTrigger): void {
121
- this.triggers.set(trigger.type, trigger);
122
- this.logger.info(`Trigger registered: ${trigger.type}`);
123
- }
124
-
125
- /** Unregister a trigger (hot-unplug) */
126
- unregisterTrigger(type: string): void {
127
- this.triggers.delete(type);
128
- this.logger.info(`Trigger unregistered: ${type}`);
129
- }
130
-
131
- /** Get all registered node types */
132
- getRegisteredNodeTypes(): string[] {
133
- return [...this.nodeExecutors.keys()];
134
- }
135
-
136
- /** Get all registered trigger types */
137
- getRegisteredTriggerTypes(): string[] {
138
- return [...this.triggers.keys()];
139
- }
140
-
141
- // ── IAutomationService Contract Implementation ────────
142
-
143
- registerFlow(name: string, definition: unknown): void {
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
-
158
- this.flows.set(name, parsed);
159
- if (!this.flowEnabled.has(name)) {
160
- this.flowEnabled.set(name, true);
161
- }
162
- this.logger.info(`Flow registered: ${name} (version ${parsed.version})`);
163
- }
164
-
165
- unregisterFlow(name: string): void {
166
- this.flows.delete(name);
167
- this.flowEnabled.delete(name);
168
- this.flowVersionHistory.delete(name);
169
- this.logger.info(`Flow unregistered: ${name}`);
170
- }
171
-
172
- async listFlows(): Promise<string[]> {
173
- return [...this.flows.keys()];
174
- }
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
-
217
- async execute(flowName: string, context?: AutomationContext): Promise<AutomationResult> {
218
- const startTime = Date.now();
219
- const flow = this.flows.get(flowName);
220
-
221
- if (!flow) {
222
- return { success: false, error: `Flow '${flowName}' not found` };
223
- }
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
-
230
- // Initialize variable context
231
- const variables = new Map<string, unknown>();
232
- if (flow.variables) {
233
- for (const v of flow.variables) {
234
- if (v.isInput && context?.params?.[v.name] !== undefined) {
235
- variables.set(v.name, context.params[v.name]);
236
- }
237
- }
238
- }
239
- // Inject trigger record
240
- if (context?.record) {
241
- variables.set('$record', context.record);
242
- }
243
-
244
- const runId = `run_${++this.runCounter}`;
245
- const startedAt = new Date().toISOString();
246
- const steps: StepLogEntry[] = [];
247
-
248
- try {
249
- // Find the start node
250
- const startNode = flow.nodes.find(n => n.type === 'start');
251
- if (!startNode) {
252
- return { success: false, error: 'Flow has no start node' };
253
- }
254
-
255
- // Validate node input schemas before execution
256
- this.validateNodeInputSchemas(flow, variables);
257
-
258
- // DAG traversal execution
259
- await this.executeNode(startNode, flow, variables, context ?? {}, steps);
260
-
261
- // Collect output variables
262
- const output: Record<string, unknown> = {};
263
- if (flow.variables) {
264
- for (const v of flow.variables) {
265
- if (v.isOutput) {
266
- output[v.name] = variables.get(v.name);
267
- }
268
- }
269
- }
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
-
291
- return {
292
- success: true,
293
- output,
294
- durationMs,
295
- };
296
- } catch (err: unknown) {
297
- const errorMessage = err instanceof Error ? err.message : String(err);
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
-
318
- // Error handling strategy
319
- if (flow.errorHandling?.strategy === 'retry') {
320
- return this.retryExecution(flowName, context, startTime, flow.errorHandling);
321
- }
322
- return {
323
- success: false,
324
- error: errorMessage,
325
- durationMs,
326
- };
327
- }
328
- }
329
-
330
- // ── DAG Traversal Core ──────────────────────────────────
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
- */
434
- private async executeNode(
435
- node: FlowNodeParsed,
436
- flow: FlowParsed,
437
- variables: Map<string, unknown>,
438
- context: AutomationContext,
439
- steps: StepLogEntry[],
440
- ): Promise<void> {
441
- if (node.type === 'end') return;
442
-
443
- const stepStart = Date.now();
444
- const stepStartedAt = new Date().toISOString();
445
-
446
- // Find executor
447
- const executor = this.nodeExecutors.get(node.type);
448
- if (!executor) {
449
- // start node without executor is fine — just skip
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
- });
460
- throw new Error(`No executor registered for node type '${node.type}'`);
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
- });
471
- } else {
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
-
509
- if (!result.success) {
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}`);
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
-
546
- // Write back output variables
547
- if (result.output) {
548
- for (const [key, value] of Object.entries(result.output)) {
549
- variables.set(`${node.id}.${key}`, value);
550
- }
551
- }
552
- }
553
-
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[] = [];
561
- for (const edge of outEdges) {
562
- if (edge.condition) {
563
- conditionalEdges.push(edge);
564
- } else {
565
- unconditionalEdges.push(edge);
566
- }
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
- }
576
- }
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
- ]);
604
- }
605
-
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
614
- let resolved = expression;
615
- for (const [key, value] of variables) {
616
- resolved = resolved.split(`{${key}}`).join(String(value));
617
- }
618
- resolved = resolved.trim();
619
-
620
- try {
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;
641
- } catch {
642
- return false;
643
- }
644
- }
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
- */
681
- private async retryExecution(
682
- flowName: string,
683
- context: AutomationContext | undefined,
684
- startTime: number,
685
- errorHandling: {
686
- maxRetries?: number;
687
- retryDelayMs?: number;
688
- backoffMultiplier?: number;
689
- maxRetryDelayMs?: number;
690
- jitter?: boolean;
691
- },
692
- ): Promise<AutomationResult> {
693
- const maxRetries = errorHandling.maxRetries ?? 3;
694
- const baseDelay = errorHandling.retryDelayMs ?? 1000;
695
- const multiplier = errorHandling.backoffMultiplier ?? 1;
696
- const maxDelay = errorHandling.maxRetryDelayMs ?? 30000;
697
- const useJitter = errorHandling.jitter ?? false;
698
-
699
- let lastError = 'Max retries exceeded';
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
- }
706
- await new Promise(r => setTimeout(r, delay));
707
-
708
- // Execute directly without recursion into retryExecution again
709
- const result = await this.executeWithoutRetry(flowName, context);
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 };
805
- }
806
- }
807
- }