@objectstack/service-automation 3.0.8 → 3.0.9
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +8 -0
- package/dist/index.cjs +467 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -2
- package/dist/index.d.ts +102 -2
- package/dist/index.js +467 -26
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/engine.test.ts +817 -0
- package/src/engine.ts +574 -24
- package/src/plugins/logic-nodes-plugin.ts +2 -13
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
const outEdges = flow.edges.filter(
|
|
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
|
|
217
|
-
|
|
562
|
+
if (edge.condition) {
|
|
563
|
+
conditionalEdges.push(edge);
|
|
564
|
+
} else {
|
|
565
|
+
unconditionalEdges.push(edge);
|
|
218
566
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
}
|