@objectstack/service-automation 9.2.0 → 9.4.0

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.cjs CHANGED
@@ -49,7 +49,7 @@ var FlowSuspendSignal = class {
49
49
  function isSuspendSignal(err) {
50
50
  return typeof err === "object" && err !== null && err.__flowSuspend === true;
51
51
  }
52
- var AutomationEngine = class {
52
+ var _AutomationEngine = class _AutomationEngine {
53
53
  constructor(logger, store) {
54
54
  this.flows = /* @__PURE__ */ new Map();
55
55
  this.flowEnabled = /* @__PURE__ */ new Map();
@@ -244,7 +244,7 @@ var AutomationEngine = class {
244
244
  }
245
245
  /**
246
246
  * Derive a flow's trigger binding from its `start` node, or `undefined` if
247
- * the flow has no auto-trigger (manual / screen / api). The convention —
247
+ * the flow has no auto-trigger (manual / screen). The convention —
248
248
  * established by the showcase flows — is that the start node carries the
249
249
  * trigger details in its `config`: `{ objectName, triggerType, condition }`
250
250
  * for record-change, or a `schedule` descriptor for time-based flows.
@@ -273,6 +273,12 @@ var AutomationEngine = class {
273
273
  binding: { flowName, schedule: config.schedule, condition: config.condition ?? void 0, config }
274
274
  };
275
275
  }
276
+ if (flow.type === "api" || triggerType === "api") {
277
+ return {
278
+ triggerType: "api",
279
+ binding: { flowName, condition: config.condition ?? void 0, config }
280
+ };
281
+ }
276
282
  return void 0;
277
283
  }
278
284
  /**
@@ -856,6 +862,46 @@ var AutomationEngine = class {
856
862
  error
857
863
  });
858
864
  }
865
+ /**
866
+ * Cancel a suspended run (ADR-0044): consume its continuation and record a
867
+ * terminal `cancelled` log so it stops surfacing as resumable. The
868
+ * engine-level primitive behind "the submitter abandoned the revision
869
+ * window" — recalling there leaves the run paused at a wait node with no
870
+ * reject edge to resume down, so the run must end, not continue. Returns
871
+ * `false` when no suspended run exists under the id (already terminal /
872
+ * unknown), which callers treat as idempotent success.
873
+ */
874
+ async cancelRun(runId, reason) {
875
+ let run = this.suspendedRuns.get(runId) ?? null;
876
+ if (!run && this.store) {
877
+ try {
878
+ run = await this.store.load(runId);
879
+ } catch (err) {
880
+ this.logger.warn(
881
+ `[automation] cancelRun: failed to load suspended run '${runId}' from durable store: ${err.message}`
882
+ );
883
+ }
884
+ }
885
+ if (!run) return false;
886
+ await this.forgetSuspendedRun(runId);
887
+ this.recordLog({
888
+ id: run.runId,
889
+ flowName: run.flowName,
890
+ flowVersion: run.flowVersion,
891
+ status: "cancelled",
892
+ startedAt: run.startedAt,
893
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
894
+ durationMs: Date.now() - run.startTime,
895
+ trigger: {
896
+ type: run.context?.event ?? "manual",
897
+ userId: run.context?.userId,
898
+ object: run.context?.object
899
+ },
900
+ steps: run.steps,
901
+ error: reason
902
+ });
903
+ return true;
904
+ }
859
905
  /**
860
906
  * Walk a failed run's `$parentRunId` chain and fail each suspended
861
907
  * ancestor (see {@link failSuspendedRun}). Bounded so a corrupt context
@@ -987,6 +1033,13 @@ ${failures.join("\n")}`
987
1033
  * Detect cycles in the flow graph (DAG validation).
988
1034
  * Uses DFS with coloring (white/gray/black) to detect back edges.
989
1035
  * Throws an error with cycle details if a cycle is found.
1036
+ *
1037
+ * ADR-0044: edges explicitly typed `back` (declared back-edges — e.g. a
1038
+ * revise/rework loop re-entering an approval node) are excluded from the
1039
+ * analysis: the graph **minus `back` edges** must be a DAG. An unmarked
1040
+ * cycle is still rejected — authors opt in edge by edge. At run time a
1041
+ * `back` edge traverses like any default edge; the re-entry runaway guard
1042
+ * lives in {@link executeNode}.
990
1043
  */
991
1044
  detectCycles(flow) {
992
1045
  const WHITE = 0, GRAY = 1, BLACK = 2;
@@ -998,6 +1051,7 @@ ${failures.join("\n")}`
998
1051
  adj.set(node.id, []);
999
1052
  }
1000
1053
  for (const edge of flow.edges) {
1054
+ if (edge.type === "back") continue;
1001
1055
  const targets = adj.get(edge.source);
1002
1056
  if (targets) targets.push(edge.target);
1003
1057
  }
@@ -1027,7 +1081,9 @@ ${failures.join("\n")}`
1027
1081
  if (color.get(node.id) === WHITE) {
1028
1082
  const cycle = dfs(node.id);
1029
1083
  if (cycle) {
1030
- throw new Error(`Flow contains a cycle: ${cycle.join(" \u2192 ")}. Only DAG flows are allowed.`);
1084
+ throw new Error(
1085
+ `Flow contains a cycle: ${cycle.join(" \u2192 ")}. Only DAG flows are allowed \u2014 to author an intentional rework loop, mark the cycle-closing edge with type: 'back' (ADR-0044).`
1086
+ );
1031
1087
  }
1032
1088
  }
1033
1089
  }
@@ -1071,6 +1127,15 @@ ${failures.join("\n")}`
1071
1127
  */
1072
1128
  async executeNode(node, flow, variables, context, steps) {
1073
1129
  if (node.type === "end") return;
1130
+ const priorVisits = steps.reduce(
1131
+ (n, s) => s.nodeId === node.id && s.parentNodeId === void 0 ? n + 1 : n,
1132
+ 0
1133
+ );
1134
+ if (priorVisits >= _AutomationEngine.MAX_NODE_REENTRIES) {
1135
+ throw new Error(
1136
+ `Node '${node.id}' was entered ${priorVisits} times in one run \u2014 aborting as a runaway loop (back-edge cycles must terminate; see ADR-0044)`
1137
+ );
1138
+ }
1074
1139
  const stepStart = Date.now();
1075
1140
  const stepStartedAt = (/* @__PURE__ */ new Date()).toISOString();
1076
1141
  const executor = this.nodeExecutors.get(node.type);
@@ -1496,6 +1561,14 @@ ${failures.join("\n")}`
1496
1561
  }
1497
1562
  }
1498
1563
  };
1564
+ /**
1565
+ * ADR-0044: maximum times a single node may be (re-)entered at the top
1566
+ * level of one run before the engine aborts it as a runaway back-edge
1567
+ * loop. Generous on purpose — the product guard (`maxRevisions`) sits
1568
+ * orders of magnitude lower.
1569
+ */
1570
+ _AutomationEngine.MAX_NODE_REENTRIES = 100;
1571
+ var AutomationEngine = _AutomationEngine;
1499
1572
 
1500
1573
  // src/suspended-run-store.ts
1501
1574
  var TABLE = "sys_automation_run";