@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/dist/index.js CHANGED
@@ -3,8 +3,13 @@ import { FlowSchema } from "@objectstack/spec/automation";
3
3
  var AutomationEngine = class {
4
4
  constructor(logger) {
5
5
  this.flows = /* @__PURE__ */ new Map();
6
+ this.flowEnabled = /* @__PURE__ */ new Map();
7
+ this.flowVersionHistory = /* @__PURE__ */ new Map();
6
8
  this.nodeExecutors = /* @__PURE__ */ new Map();
7
9
  this.triggers = /* @__PURE__ */ new Map();
10
+ this.executionLogs = [];
11
+ this.maxLogSize = 1e3;
12
+ this.runCounter = 0;
8
13
  this.logger = logger;
9
14
  }
10
15
  // ── Plugin Extension API ──────────────────────────────
@@ -42,22 +47,73 @@ var AutomationEngine = class {
42
47
  // ── IAutomationService Contract Implementation ────────
43
48
  registerFlow(name, definition) {
44
49
  const parsed = FlowSchema.parse(definition);
50
+ this.detectCycles(parsed);
51
+ const history = this.flowVersionHistory.get(name) ?? [];
52
+ history.push({
53
+ version: parsed.version,
54
+ definition: parsed,
55
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
56
+ });
57
+ this.flowVersionHistory.set(name, history);
45
58
  this.flows.set(name, parsed);
46
- this.logger.info(`Flow registered: ${name}`);
59
+ if (!this.flowEnabled.has(name)) {
60
+ this.flowEnabled.set(name, true);
61
+ }
62
+ this.logger.info(`Flow registered: ${name} (version ${parsed.version})`);
47
63
  }
48
64
  unregisterFlow(name) {
49
65
  this.flows.delete(name);
66
+ this.flowEnabled.delete(name);
67
+ this.flowVersionHistory.delete(name);
50
68
  this.logger.info(`Flow unregistered: ${name}`);
51
69
  }
52
70
  async listFlows() {
53
71
  return [...this.flows.keys()];
54
72
  }
73
+ async getFlow(name) {
74
+ return this.flows.get(name) ?? null;
75
+ }
76
+ async toggleFlow(name, enabled) {
77
+ if (!this.flows.has(name)) {
78
+ throw new Error(`Flow '${name}' not found`);
79
+ }
80
+ this.flowEnabled.set(name, enabled);
81
+ this.logger.info(`Flow '${name}' ${enabled ? "enabled" : "disabled"}`);
82
+ }
83
+ /** Get flow version history */
84
+ getFlowVersionHistory(name) {
85
+ return this.flowVersionHistory.get(name) ?? [];
86
+ }
87
+ /** Rollback flow to a specific version */
88
+ rollbackFlow(name, version) {
89
+ const history = this.flowVersionHistory.get(name);
90
+ if (!history) {
91
+ throw new Error(`Flow '${name}' has no version history`);
92
+ }
93
+ const entry = history.find((h) => h.version === version);
94
+ if (!entry) {
95
+ throw new Error(`Version ${version} not found for flow '${name}'`);
96
+ }
97
+ this.flows.set(name, entry.definition);
98
+ this.logger.info(`Flow '${name}' rolled back to version ${version}`);
99
+ }
100
+ async listRuns(flowName, options) {
101
+ const limit = options?.limit ?? 20;
102
+ const logs = this.executionLogs.filter((l) => l.flowName === flowName);
103
+ return logs.slice(-limit).reverse();
104
+ }
105
+ async getRun(runId) {
106
+ return this.executionLogs.find((l) => l.id === runId) ?? null;
107
+ }
55
108
  async execute(flowName, context) {
56
109
  const startTime = Date.now();
57
110
  const flow = this.flows.get(flowName);
58
111
  if (!flow) {
59
112
  return { success: false, error: `Flow '${flowName}' not found` };
60
113
  }
114
+ if (this.flowEnabled.get(flowName) === false) {
115
+ return { success: false, error: `Flow '${flowName}' is disabled` };
116
+ }
61
117
  const variables = /* @__PURE__ */ new Map();
62
118
  if (flow.variables) {
63
119
  for (const v of flow.variables) {
@@ -69,12 +125,16 @@ var AutomationEngine = class {
69
125
  if (context?.record) {
70
126
  variables.set("$record", context.record);
71
127
  }
128
+ const runId = `run_${++this.runCounter}`;
129
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
130
+ const steps = [];
72
131
  try {
73
132
  const startNode = flow.nodes.find((n) => n.type === "start");
74
133
  if (!startNode) {
75
134
  return { success: false, error: "Flow has no start node" };
76
135
  }
77
- await this.executeNode(startNode, flow, variables, context ?? {});
136
+ this.validateNodeInputSchemas(flow, variables);
137
+ await this.executeNode(startNode, flow, variables, context ?? {}, steps);
78
138
  const output = {};
79
139
  if (flow.variables) {
80
140
  for (const v of flow.variables) {
@@ -83,73 +143,461 @@ var AutomationEngine = class {
83
143
  }
84
144
  }
85
145
  }
146
+ const durationMs = Date.now() - startTime;
147
+ this.recordLog({
148
+ id: runId,
149
+ flowName,
150
+ flowVersion: flow.version,
151
+ status: "completed",
152
+ startedAt,
153
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
154
+ durationMs,
155
+ trigger: {
156
+ type: context?.event ?? "manual",
157
+ userId: context?.userId,
158
+ object: context?.object
159
+ },
160
+ steps,
161
+ output
162
+ });
86
163
  return {
87
164
  success: true,
88
165
  output,
89
- durationMs: Date.now() - startTime
166
+ durationMs
90
167
  };
91
168
  } catch (err) {
92
169
  const errorMessage = err instanceof Error ? err.message : String(err);
170
+ const durationMs = Date.now() - startTime;
171
+ this.recordLog({
172
+ id: runId,
173
+ flowName,
174
+ flowVersion: flow.version,
175
+ status: "failed",
176
+ startedAt,
177
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
178
+ durationMs,
179
+ trigger: {
180
+ type: context?.event ?? "manual",
181
+ userId: context?.userId,
182
+ object: context?.object
183
+ },
184
+ steps,
185
+ error: errorMessage
186
+ });
93
187
  if (flow.errorHandling?.strategy === "retry") {
94
188
  return this.retryExecution(flowName, context, startTime, flow.errorHandling);
95
189
  }
96
190
  return {
97
191
  success: false,
98
192
  error: errorMessage,
99
- durationMs: Date.now() - startTime
193
+ durationMs
100
194
  };
101
195
  }
102
196
  }
103
197
  // ── DAG Traversal Core ──────────────────────────────────
104
- async executeNode(node, flow, variables, context) {
198
+ recordLog(entry) {
199
+ this.executionLogs.push(entry);
200
+ if (this.executionLogs.length > this.maxLogSize) {
201
+ this.executionLogs.splice(0, this.executionLogs.length - this.maxLogSize);
202
+ }
203
+ }
204
+ /**
205
+ * Detect cycles in the flow graph (DAG validation).
206
+ * Uses DFS with coloring (white/gray/black) to detect back edges.
207
+ * Throws an error with cycle details if a cycle is found.
208
+ */
209
+ detectCycles(flow) {
210
+ const WHITE = 0, GRAY = 1, BLACK = 2;
211
+ const color = /* @__PURE__ */ new Map();
212
+ const parent = /* @__PURE__ */ new Map();
213
+ const adj = /* @__PURE__ */ new Map();
214
+ for (const node of flow.nodes) {
215
+ color.set(node.id, WHITE);
216
+ adj.set(node.id, []);
217
+ }
218
+ for (const edge of flow.edges) {
219
+ const targets = adj.get(edge.source);
220
+ if (targets) targets.push(edge.target);
221
+ }
222
+ const dfs = (nodeId) => {
223
+ color.set(nodeId, GRAY);
224
+ for (const neighbor of adj.get(nodeId) ?? []) {
225
+ if (color.get(neighbor) === GRAY) {
226
+ const cycle = [neighbor, nodeId];
227
+ let cur = nodeId;
228
+ while (cur !== neighbor) {
229
+ cur = parent.get(cur);
230
+ if (cur) cycle.push(cur);
231
+ else break;
232
+ }
233
+ return cycle.reverse();
234
+ }
235
+ if (color.get(neighbor) === WHITE) {
236
+ parent.set(neighbor, nodeId);
237
+ const result = dfs(neighbor);
238
+ if (result) return result;
239
+ }
240
+ }
241
+ color.set(nodeId, BLACK);
242
+ return null;
243
+ };
244
+ for (const node of flow.nodes) {
245
+ if (color.get(node.id) === WHITE) {
246
+ const cycle = dfs(node.id);
247
+ if (cycle) {
248
+ throw new Error(`Flow contains a cycle: ${cycle.join(" \u2192 ")}. Only DAG flows are allowed.`);
249
+ }
250
+ }
251
+ }
252
+ }
253
+ /**
254
+ * Get the runtime type name of a value for schema validation.
255
+ */
256
+ getValueType(value) {
257
+ if (Array.isArray(value)) return "array";
258
+ if (typeof value === "object" && value !== null) return "object";
259
+ return typeof value;
260
+ }
261
+ /**
262
+ * Validate node input schemas before execution.
263
+ * Checks that node config matches declared inputSchema if present.
264
+ */
265
+ validateNodeInputSchemas(flow, _variables) {
266
+ for (const node of flow.nodes) {
267
+ if (node.inputSchema && node.config) {
268
+ for (const [paramName, paramDef] of Object.entries(node.inputSchema)) {
269
+ if (paramDef.required && !(paramName in node.config)) {
270
+ throw new Error(
271
+ `Node '${node.id}' missing required input parameter '${paramName}'`
272
+ );
273
+ }
274
+ const value = node.config[paramName];
275
+ if (value !== void 0) {
276
+ const actualType = this.getValueType(value);
277
+ if (actualType !== paramDef.type) {
278
+ throw new Error(
279
+ `Node '${node.id}' parameter '${paramName}' expected type '${paramDef.type}' but got '${actualType}'`
280
+ );
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+ /**
288
+ * Execute a node with timeout support, fault edge handling, and step logging.
289
+ */
290
+ async executeNode(node, flow, variables, context, steps) {
105
291
  if (node.type === "end") return;
292
+ const stepStart = Date.now();
293
+ const stepStartedAt = (/* @__PURE__ */ new Date()).toISOString();
106
294
  const executor = this.nodeExecutors.get(node.type);
107
295
  if (!executor) {
108
296
  if (node.type !== "start") {
297
+ steps.push({
298
+ nodeId: node.id,
299
+ nodeType: node.type,
300
+ status: "failure",
301
+ startedAt: stepStartedAt,
302
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
303
+ durationMs: Date.now() - stepStart,
304
+ error: { code: "NO_EXECUTOR", message: `No executor registered for node type '${node.type}'` }
305
+ });
109
306
  throw new Error(`No executor registered for node type '${node.type}'`);
110
307
  }
308
+ steps.push({
309
+ nodeId: node.id,
310
+ nodeType: node.type,
311
+ status: "success",
312
+ startedAt: stepStartedAt,
313
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
314
+ durationMs: Date.now() - stepStart
315
+ });
111
316
  } else {
112
- const result = await executor.execute(node, variables, context);
317
+ let result;
318
+ try {
319
+ if (node.timeoutMs && node.timeoutMs > 0) {
320
+ result = await this.executeWithTimeout(
321
+ executor.execute(node, variables, context),
322
+ node.timeoutMs,
323
+ node.id
324
+ );
325
+ } else {
326
+ result = await executor.execute(node, variables, context);
327
+ }
328
+ } catch (execErr) {
329
+ const errMsg = execErr instanceof Error ? execErr.message : String(execErr);
330
+ steps.push({
331
+ nodeId: node.id,
332
+ nodeType: node.type,
333
+ status: "failure",
334
+ startedAt: stepStartedAt,
335
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
336
+ durationMs: Date.now() - stepStart,
337
+ error: { code: "EXECUTION_ERROR", message: errMsg }
338
+ });
339
+ const faultEdge = flow.edges.find((e) => e.source === node.id && e.type === "fault");
340
+ if (faultEdge) {
341
+ variables.set("$error", { nodeId: node.id, message: errMsg });
342
+ const faultTarget = flow.nodes.find((n) => n.id === faultEdge.target);
343
+ if (faultTarget) {
344
+ await this.executeNode(faultTarget, flow, variables, context, steps);
345
+ return;
346
+ }
347
+ }
348
+ throw execErr;
349
+ }
113
350
  if (!result.success) {
114
- throw new Error(`Node '${node.id}' failed: ${result.error}`);
351
+ const errMsg = result.error ?? "Unknown error";
352
+ steps.push({
353
+ nodeId: node.id,
354
+ nodeType: node.type,
355
+ status: "failure",
356
+ startedAt: stepStartedAt,
357
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
358
+ durationMs: Date.now() - stepStart,
359
+ error: { code: "NODE_FAILURE", message: errMsg }
360
+ });
361
+ variables.set("$error", { nodeId: node.id, message: errMsg, output: result.output });
362
+ const faultEdge = flow.edges.find((e) => e.source === node.id && e.type === "fault");
363
+ if (faultEdge) {
364
+ const faultTarget = flow.nodes.find((n) => n.id === faultEdge.target);
365
+ if (faultTarget) {
366
+ await this.executeNode(faultTarget, flow, variables, context, steps);
367
+ return;
368
+ }
369
+ }
370
+ throw new Error(`Node '${node.id}' failed: ${errMsg}`);
115
371
  }
372
+ steps.push({
373
+ nodeId: node.id,
374
+ nodeType: node.type,
375
+ status: "success",
376
+ startedAt: stepStartedAt,
377
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
378
+ durationMs: Date.now() - stepStart
379
+ });
116
380
  if (result.output) {
117
381
  for (const [key, value] of Object.entries(result.output)) {
118
382
  variables.set(`${node.id}.${key}`, value);
119
383
  }
120
384
  }
121
385
  }
122
- const outEdges = flow.edges.filter((e) => e.source === node.id);
386
+ const outEdges = flow.edges.filter(
387
+ (e) => e.source === node.id && e.type !== "fault"
388
+ );
389
+ const conditionalEdges = [];
390
+ const unconditionalEdges = [];
123
391
  for (const edge of outEdges) {
124
- if (edge.condition && !this.evaluateCondition(edge.condition, variables)) {
125
- continue;
392
+ if (edge.condition) {
393
+ conditionalEdges.push(edge);
394
+ } else {
395
+ unconditionalEdges.push(edge);
126
396
  }
127
- const nextNode = flow.nodes.find((n) => n.id === edge.target);
128
- if (nextNode) {
129
- await this.executeNode(nextNode, flow, variables, context);
397
+ }
398
+ for (const edge of conditionalEdges) {
399
+ if (this.evaluateCondition(edge.condition, variables)) {
400
+ const nextNode = flow.nodes.find((n) => n.id === edge.target);
401
+ if (nextNode) {
402
+ await this.executeNode(nextNode, flow, variables, context, steps);
403
+ }
130
404
  }
131
405
  }
406
+ if (unconditionalEdges.length > 0) {
407
+ const parallelTasks = unconditionalEdges.map((edge) => flow.nodes.find((n) => n.id === edge.target)).filter((n) => n != null).map((nextNode) => this.executeNode(nextNode, flow, variables, context, steps));
408
+ await Promise.all(parallelTasks);
409
+ }
410
+ }
411
+ /**
412
+ * Execute a promise with timeout using Promise.race.
413
+ */
414
+ executeWithTimeout(promise, timeoutMs, nodeId) {
415
+ return Promise.race([
416
+ promise,
417
+ new Promise(
418
+ (_, reject) => setTimeout(() => reject(new Error(`Node '${nodeId}' timed out after ${timeoutMs}ms`)), timeoutMs)
419
+ )
420
+ ]);
132
421
  }
422
+ /**
423
+ * Safe expression evaluator.
424
+ * Uses simple operator-based parsing without `new Function`.
425
+ * Supports: comparisons (>, <, >=, <=, ==, !=, ===, !==),
426
+ * boolean literals (true, false), and basic arithmetic.
427
+ */
133
428
  evaluateCondition(expression, variables) {
134
429
  let resolved = expression;
135
430
  for (const [key, value] of variables) {
136
431
  resolved = resolved.split(`{${key}}`).join(String(value));
137
432
  }
433
+ resolved = resolved.trim();
138
434
  try {
139
- return new Function(`return (${resolved})`)();
435
+ if (resolved === "true") return true;
436
+ if (resolved === "false") return false;
437
+ const operators = ["===", "!==", ">=", "<=", "!=", "==", ">", "<"];
438
+ for (const op of operators) {
439
+ const idx = resolved.indexOf(op);
440
+ if (idx !== -1) {
441
+ const left = resolved.slice(0, idx).trim();
442
+ const right = resolved.slice(idx + op.length).trim();
443
+ return this.compareValues(left, op, right);
444
+ }
445
+ }
446
+ const numVal = Number(resolved);
447
+ if (!isNaN(numVal)) return numVal !== 0;
448
+ return false;
140
449
  } catch {
141
450
  return false;
142
451
  }
143
452
  }
453
+ /**
454
+ * Compare two string-represented values with an operator.
455
+ */
456
+ compareValues(left, op, right) {
457
+ const lNum = Number(left);
458
+ const rNum = Number(right);
459
+ const bothNumeric = !isNaN(lNum) && !isNaN(rNum) && left !== "" && right !== "";
460
+ if (bothNumeric) {
461
+ switch (op) {
462
+ case ">":
463
+ return lNum > rNum;
464
+ case "<":
465
+ return lNum < rNum;
466
+ case ">=":
467
+ return lNum >= rNum;
468
+ case "<=":
469
+ return lNum <= rNum;
470
+ case "==":
471
+ case "===":
472
+ return lNum === rNum;
473
+ case "!=":
474
+ case "!==":
475
+ return lNum !== rNum;
476
+ default:
477
+ return false;
478
+ }
479
+ }
480
+ switch (op) {
481
+ case "==":
482
+ case "===":
483
+ return left === right;
484
+ case "!=":
485
+ case "!==":
486
+ return left !== right;
487
+ case ">":
488
+ return left > right;
489
+ case "<":
490
+ return left < right;
491
+ case ">=":
492
+ return left >= right;
493
+ case "<=":
494
+ return left <= right;
495
+ default:
496
+ return false;
497
+ }
498
+ }
499
+ /**
500
+ * Retry execution with exponential backoff, jitter, and recursive protection.
501
+ * Uses an iterative loop with an internal retry flag to prevent recursive call stacking.
502
+ */
144
503
  async retryExecution(flowName, context, startTime, errorHandling) {
145
504
  const maxRetries = errorHandling.maxRetries ?? 3;
146
- const delay = errorHandling.retryDelayMs ?? 1e3;
505
+ const baseDelay = errorHandling.retryDelayMs ?? 1e3;
506
+ const multiplier = errorHandling.backoffMultiplier ?? 1;
507
+ const maxDelay = errorHandling.maxRetryDelayMs ?? 3e4;
508
+ const useJitter = errorHandling.jitter ?? false;
509
+ let lastError = "Max retries exceeded";
147
510
  for (let i = 0; i < maxRetries; i++) {
511
+ let delay = Math.min(baseDelay * Math.pow(multiplier, i), maxDelay);
512
+ if (useJitter) {
513
+ delay = delay * (0.5 + Math.random() * 0.5);
514
+ }
148
515
  await new Promise((r) => setTimeout(r, delay));
149
- const result = await this.execute(flowName, context);
516
+ const result = await this.executeWithoutRetry(flowName, context);
150
517
  if (result.success) return result;
518
+ lastError = result.error ?? "Unknown error";
519
+ }
520
+ return { success: false, error: lastError, durationMs: Date.now() - startTime };
521
+ }
522
+ /**
523
+ * Execute a flow without triggering retry logic (used by retryExecution to prevent recursion).
524
+ */
525
+ async executeWithoutRetry(flowName, context) {
526
+ const startTime = Date.now();
527
+ const flow = this.flows.get(flowName);
528
+ if (!flow) {
529
+ return { success: false, error: `Flow '${flowName}' not found` };
530
+ }
531
+ if (this.flowEnabled.get(flowName) === false) {
532
+ return { success: false, error: `Flow '${flowName}' is disabled` };
533
+ }
534
+ const variables = /* @__PURE__ */ new Map();
535
+ if (flow.variables) {
536
+ for (const v of flow.variables) {
537
+ if (v.isInput && context?.params?.[v.name] !== void 0) {
538
+ variables.set(v.name, context.params[v.name]);
539
+ }
540
+ }
541
+ }
542
+ if (context?.record) {
543
+ variables.set("$record", context.record);
544
+ }
545
+ const runId = `run_${++this.runCounter}`;
546
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
547
+ const steps = [];
548
+ try {
549
+ const startNode = flow.nodes.find((n) => n.type === "start");
550
+ if (!startNode) {
551
+ return { success: false, error: "Flow has no start node" };
552
+ }
553
+ await this.executeNode(startNode, flow, variables, context ?? {}, steps);
554
+ const output = {};
555
+ if (flow.variables) {
556
+ for (const v of flow.variables) {
557
+ if (v.isOutput) {
558
+ output[v.name] = variables.get(v.name);
559
+ }
560
+ }
561
+ }
562
+ const durationMs = Date.now() - startTime;
563
+ this.recordLog({
564
+ id: runId,
565
+ flowName,
566
+ flowVersion: flow.version,
567
+ status: "completed",
568
+ startedAt,
569
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
570
+ durationMs,
571
+ trigger: {
572
+ type: context?.event ?? "manual",
573
+ userId: context?.userId,
574
+ object: context?.object
575
+ },
576
+ steps,
577
+ output
578
+ });
579
+ return { success: true, output, durationMs };
580
+ } catch (err) {
581
+ const errorMessage = err instanceof Error ? err.message : String(err);
582
+ const durationMs = Date.now() - startTime;
583
+ this.recordLog({
584
+ id: runId,
585
+ flowName,
586
+ flowVersion: flow.version,
587
+ status: "failed",
588
+ startedAt,
589
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
590
+ durationMs,
591
+ trigger: {
592
+ type: context?.event ?? "manual",
593
+ userId: context?.userId,
594
+ object: context?.object
595
+ },
596
+ steps,
597
+ error: errorMessage
598
+ });
599
+ return { success: false, error: errorMessage, durationMs };
151
600
  }
152
- return { success: false, error: "Max retries exceeded", durationMs: Date.now() - startTime };
153
601
  }
154
602
  };
155
603
 
@@ -247,15 +695,8 @@ var LogicNodesPlugin = class {
247
695
  const config = node.config;
248
696
  const conditions = config?.conditions ?? [];
249
697
  for (const cond of conditions) {
250
- let expr = cond.expression;
251
- for (const [k, v] of variables) {
252
- expr = expr.split(`{${k}}`).join(String(v));
253
- }
254
- try {
255
- if (new Function(`return (${expr})`)()) {
256
- return { success: true, branchLabel: cond.label };
257
- }
258
- } catch {
698
+ if (engine.evaluateCondition(cond.expression, variables)) {
699
+ return { success: true, branchLabel: cond.label };
259
700
  }
260
701
  }
261
702
  return { success: true, branchLabel: "default" };