@objectstack/service-automation 7.5.0 → 7.7.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
@@ -22,6 +22,9 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AutomationEngine: () => AutomationEngine,
24
24
  AutomationServicePlugin: () => AutomationServicePlugin,
25
+ InMemorySuspendedRunStore: () => InMemorySuspendedRunStore,
26
+ ObjectStoreSuspendedRunStore: () => ObjectStoreSuspendedRunStore,
27
+ SysAutomationRun: () => SysAutomationRun,
25
28
  installBuiltinNodes: () => installBuiltinNodes,
26
29
  registerConnectorNodes: () => registerConnectorNodes,
27
30
  registerCrudNodes: () => registerCrudNodes,
@@ -47,7 +50,7 @@ function isSuspendSignal(err) {
47
50
  return typeof err === "object" && err !== null && err.__flowSuspend === true;
48
51
  }
49
52
  var AutomationEngine = class {
50
- constructor(logger) {
53
+ constructor(logger, store) {
51
54
  this.flows = /* @__PURE__ */ new Map();
52
55
  this.flowEnabled = /* @__PURE__ */ new Map();
53
56
  this.flowVersionHistory = /* @__PURE__ */ new Map();
@@ -64,10 +67,74 @@ var AutomationEngine = class {
64
67
  this.connectors = /* @__PURE__ */ new Map();
65
68
  this.executionLogs = [];
66
69
  this.maxLogSize = 1e3;
67
- this.runCounter = 0;
68
- /** Runs paused at a node, keyed by runId (ADR-0019). In-memory, see {@link SuspendedRun}. */
70
+ /**
71
+ * Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache
72
+ * mirrored to {@link store} when one is configured, so a pause survives a
73
+ * process restart. See {@link SuspendedRun}.
74
+ */
69
75
  this.suspendedRuns = /* @__PURE__ */ new Map();
76
+ /**
77
+ * Run ids currently mid-resume — an in-process idempotency guard so a
78
+ * duplicate `resume(runId)` can't re-enter and double-run side effects.
79
+ */
80
+ this.resuming = /* @__PURE__ */ new Set();
70
81
  this.logger = logger;
82
+ this.store = store;
83
+ }
84
+ /**
85
+ * Attach (or replace) the durable {@link SuspendedRunStore}. Used by the
86
+ * service plugin to upgrade the engine to DB-backed persistence once the
87
+ * ObjectQL engine is available (the engine is constructed earlier, during
88
+ * `init`, before services are wired).
89
+ */
90
+ setSuspendedRunStore(store) {
91
+ this.store = store;
92
+ }
93
+ /**
94
+ * Generate a process-unique run id. Includes a random component so ids do
95
+ * not collide with runs persisted by a previous process lifetime (a plain
96
+ * incrementing counter would reissue `run_1` after a restart, clashing with
97
+ * a still-suspended durable run).
98
+ */
99
+ nextRunId() {
100
+ const g = globalThis;
101
+ const rand = g.crypto?.randomUUID ? g.crypto.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
102
+ return `run_${rand}`;
103
+ }
104
+ /**
105
+ * Persist a suspended run to the in-memory cache and (best-effort) the
106
+ * durable store. A store failure is logged but does not fail the run — the
107
+ * in-memory copy still allows in-process resume; only cross-restart
108
+ * durability is lost.
109
+ */
110
+ async persistSuspendedRun(run) {
111
+ this.suspendedRuns.set(run.runId, run);
112
+ if (this.store) {
113
+ try {
114
+ await this.store.save(run);
115
+ } catch (err) {
116
+ this.logger.warn(
117
+ `[automation] failed to persist suspended run '${run.runId}' to durable store (kept in memory only): ${err.message}`
118
+ );
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Drop a suspended run from the in-memory cache and (best-effort) the
124
+ * durable store. Called once the run is claimed for resume or reaches a
125
+ * terminal state.
126
+ */
127
+ async forgetSuspendedRun(runId) {
128
+ this.suspendedRuns.delete(runId);
129
+ if (this.store) {
130
+ try {
131
+ await this.store.delete(runId);
132
+ } catch (err) {
133
+ this.logger.warn(
134
+ `[automation] failed to delete suspended run '${runId}' from durable store: ${err.message}`
135
+ );
136
+ }
137
+ }
71
138
  }
72
139
  // ── Plugin Extension API ──────────────────────────────
73
140
  /** Register a node executor (called by plugins) */
@@ -87,6 +154,58 @@ var AutomationEngine = class {
87
154
  }
88
155
  this.logger.info(`Node executor registered: ${executor.type}`);
89
156
  }
157
+ /**
158
+ * Register a **deprecated alias** of a canonical node type (ADR-0018 M3).
159
+ *
160
+ * The alias is a real registered executor, so old saved flows whose nodes
161
+ * use the alias type keep validating and running with no migration. At
162
+ * execute time it delegates to the canonical executor (resolved live, so the
163
+ * canonical may be registered before or after the alias), logging a one-time
164
+ * deprecation warning. Its published descriptor is flagged `deprecated` +
165
+ * `aliasOf` so the designer palette can hide or mark it while the canonical
166
+ * type is the one offered for new authoring.
167
+ *
168
+ * This is how ADR-0018 collapses the five outbound verbs onto `http` /
169
+ * `notify`: `http_request` / `http_call` / `webhook` become aliases of
170
+ * `http`.
171
+ */
172
+ registerNodeAlias(alias, canonicalType, meta) {
173
+ const engine = this;
174
+ let warned = false;
175
+ this.registerNodeExecutor({
176
+ type: alias,
177
+ descriptor: (0, import_automation.defineActionDescriptor)({
178
+ type: alias,
179
+ version: "1.0.0",
180
+ name: meta?.name ?? alias,
181
+ description: `Deprecated alias of '${canonicalType}' (ADR-0018 M3). Author new flows with '${canonicalType}'.`,
182
+ category: meta?.category ?? "io",
183
+ source: "builtin",
184
+ paradigms: meta?.paradigms ?? ["flow", "workflow_rule", "approval"],
185
+ supportsRetry: true,
186
+ needsOutbox: meta?.needsOutbox ?? false,
187
+ deprecated: true,
188
+ aliasOf: canonicalType
189
+ }),
190
+ async execute(node, variables, context) {
191
+ if (!warned) {
192
+ warned = true;
193
+ engine.logger.warn(
194
+ `Node type '${alias}' is deprecated; use '${canonicalType}' (ADR-0018 M3). Existing flows keep running via the alias.`
195
+ );
196
+ }
197
+ const target = engine.nodeExecutors.get(canonicalType);
198
+ if (!target) {
199
+ return {
200
+ success: false,
201
+ error: `alias '${alias}' \u2192 '${canonicalType}': canonical executor not registered`
202
+ };
203
+ }
204
+ return target.execute(node, variables, context);
205
+ }
206
+ });
207
+ this.logger.info(`Node alias registered: ${alias} \u2192 ${canonicalType} (deprecated)`);
208
+ }
90
209
  /** Unregister a node executor (hot-unplug) */
91
210
  unregisterNodeExecutor(type) {
92
211
  const executor = this.nodeExecutors.get(type);
@@ -278,7 +397,9 @@ var AutomationEngine = class {
278
397
  registerFlow(name, definition) {
279
398
  const parsed = import_automation.FlowSchema.parse(definition);
280
399
  this.detectCycles(parsed);
400
+ (0, import_automation.validateControlFlow)(parsed);
281
401
  this.validateNodeTypes(name, parsed);
402
+ this.validateFlowExpressions(name, parsed);
282
403
  const history = this.flowVersionHistory.get(name) ?? [];
283
404
  history.push({
284
405
  version: parsed.version,
@@ -373,7 +494,7 @@ var AutomationEngine = class {
373
494
  if (context?.previous) {
374
495
  variables.set("previous", context.previous);
375
496
  }
376
- const runId = `run_${++this.runCounter}`;
497
+ const runId = this.nextRunId();
377
498
  variables.set("$runId", runId);
378
499
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
379
500
  const steps = [];
@@ -425,7 +546,7 @@ var AutomationEngine = class {
425
546
  } catch (err) {
426
547
  if (isSuspendSignal(err)) {
427
548
  const durationMs2 = Date.now() - startTime;
428
- this.suspendedRuns.set(runId, {
549
+ await this.persistSuspendedRun({
429
550
  runId,
430
551
  flowName,
431
552
  flowVersion: flow.version,
@@ -497,109 +618,131 @@ var AutomationEngine = class {
497
618
  * returns `{ status: 'paused', runId }` afresh.
498
619
  */
499
620
  async resume(runId, signal) {
500
- const run = this.suspendedRuns.get(runId);
501
- if (!run) {
502
- return { success: false, error: `No suspended run '${runId}'` };
621
+ if (this.resuming.has(runId)) {
622
+ return { success: false, error: `Run '${runId}' is already being resumed` };
503
623
  }
504
- const flow = this.flows.get(run.flowName);
505
- if (!flow) {
506
- return { success: false, error: `Flow '${run.flowName}' not found for run '${runId}'` };
507
- }
508
- const node = flow.nodes.find((n) => n.id === run.nodeId);
509
- if (!node) {
510
- return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
511
- }
512
- this.suspendedRuns.delete(runId);
513
- const variables = new Map(Object.entries(run.variables));
514
- if (signal?.output) {
515
- for (const [key, value] of Object.entries(signal.output)) {
516
- variables.set(`${run.nodeId}.${key}`, value);
624
+ this.resuming.add(runId);
625
+ try {
626
+ let run = this.suspendedRuns.get(runId) ?? null;
627
+ if (!run && this.store) {
628
+ try {
629
+ run = await this.store.load(runId);
630
+ } catch (err) {
631
+ this.logger.warn(
632
+ `[automation] failed to load suspended run '${runId}' from durable store: ${err.message}`
633
+ );
634
+ }
517
635
  }
518
- }
519
- if (signal?.variables) {
520
- for (const [key, value] of Object.entries(signal.variables)) {
521
- variables.set(key, value);
636
+ if (!run) {
637
+ return { success: false, error: `No suspended run '${runId}'` };
522
638
  }
523
- }
524
- const steps = run.steps;
525
- const context = run.context;
526
- try {
527
- await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
528
- const output = {};
529
- if (flow.variables) {
530
- for (const v of flow.variables) {
531
- if (v.isOutput) output[v.name] = variables.get(v.name);
639
+ const flow = this.flows.get(run.flowName);
640
+ if (!flow) {
641
+ return { success: false, error: `Flow '${run.flowName}' not found for run '${runId}'` };
642
+ }
643
+ const node = flow.nodes.find((n) => n.id === run.nodeId);
644
+ if (!node) {
645
+ return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
646
+ }
647
+ await this.forgetSuspendedRun(runId);
648
+ const variables = new Map(Object.entries(run.variables));
649
+ if (signal?.output) {
650
+ for (const [key, value] of Object.entries(signal.output)) {
651
+ variables.set(`${run.nodeId}.${key}`, value);
532
652
  }
533
653
  }
534
- const durationMs = Date.now() - run.startTime;
535
- this.recordLog({
536
- id: runId,
537
- flowName: run.flowName,
538
- flowVersion: run.flowVersion,
539
- status: "completed",
540
- startedAt: run.startedAt,
541
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
542
- durationMs,
543
- trigger: {
544
- type: context.event ?? "manual",
545
- userId: context.userId,
546
- object: context.object
547
- },
548
- steps,
549
- output
550
- });
551
- return { success: true, output, durationMs };
552
- } catch (err) {
553
- if (isSuspendSignal(err)) {
554
- const durationMs2 = Date.now() - run.startTime;
555
- this.suspendedRuns.set(runId, {
556
- ...run,
557
- nodeId: err.nodeId,
558
- variables: Object.fromEntries(variables),
654
+ if (signal?.variables) {
655
+ for (const [key, value] of Object.entries(signal.variables)) {
656
+ variables.set(key, value);
657
+ }
658
+ }
659
+ const steps = run.steps;
660
+ const context = run.context;
661
+ try {
662
+ await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
663
+ const output = {};
664
+ if (flow.variables) {
665
+ for (const v of flow.variables) {
666
+ if (v.isOutput) output[v.name] = variables.get(v.name);
667
+ }
668
+ }
669
+ const durationMs = Date.now() - run.startTime;
670
+ this.recordLog({
671
+ id: runId,
672
+ flowName: run.flowName,
673
+ flowVersion: run.flowVersion,
674
+ status: "completed",
675
+ startedAt: run.startedAt,
676
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
677
+ durationMs,
678
+ trigger: {
679
+ type: context.event ?? "manual",
680
+ userId: context.userId,
681
+ object: context.object
682
+ },
559
683
  steps,
560
- correlation: err.correlation,
561
- screen: err.screen
684
+ output
562
685
  });
686
+ return { success: true, output, durationMs };
687
+ } catch (err) {
688
+ if (isSuspendSignal(err)) {
689
+ const durationMs2 = Date.now() - run.startTime;
690
+ await this.persistSuspendedRun({
691
+ ...run,
692
+ nodeId: err.nodeId,
693
+ variables: Object.fromEntries(variables),
694
+ steps,
695
+ correlation: err.correlation,
696
+ screen: err.screen
697
+ });
698
+ this.recordLog({
699
+ id: runId,
700
+ flowName: run.flowName,
701
+ flowVersion: run.flowVersion,
702
+ status: "paused",
703
+ startedAt: run.startedAt,
704
+ durationMs: durationMs2,
705
+ trigger: {
706
+ type: context.event ?? "manual",
707
+ userId: context.userId,
708
+ object: context.object
709
+ },
710
+ steps
711
+ });
712
+ return { success: true, status: "paused", runId, durationMs: durationMs2, screen: err.screen };
713
+ }
714
+ const errorMessage = err instanceof Error ? err.message : String(err);
715
+ const durationMs = Date.now() - run.startTime;
563
716
  this.recordLog({
564
717
  id: runId,
565
718
  flowName: run.flowName,
566
719
  flowVersion: run.flowVersion,
567
- status: "paused",
720
+ status: "failed",
568
721
  startedAt: run.startedAt,
569
- durationMs: durationMs2,
722
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
723
+ durationMs,
570
724
  trigger: {
571
725
  type: context.event ?? "manual",
572
726
  userId: context.userId,
573
727
  object: context.object
574
728
  },
575
- steps
729
+ steps,
730
+ error: errorMessage
576
731
  });
577
- return { success: true, status: "paused", runId, durationMs: durationMs2, screen: err.screen };
732
+ return { success: false, error: errorMessage, durationMs };
578
733
  }
579
- const errorMessage = err instanceof Error ? err.message : String(err);
580
- const durationMs = Date.now() - run.startTime;
581
- this.recordLog({
582
- id: runId,
583
- flowName: run.flowName,
584
- flowVersion: run.flowVersion,
585
- status: "failed",
586
- startedAt: run.startedAt,
587
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
588
- durationMs,
589
- trigger: {
590
- type: context.event ?? "manual",
591
- userId: context.userId,
592
- object: context.object
593
- },
594
- steps,
595
- error: errorMessage
596
- });
597
- return { success: false, error: errorMessage, durationMs };
734
+ } finally {
735
+ this.resuming.delete(runId);
598
736
  }
599
737
  }
600
738
  /**
601
739
  * List the runs currently suspended awaiting {@link resume} (ADR-0019).
602
740
  * Backs operability surfaces such as a "pending approvals" view.
741
+ *
742
+ * Synchronous — reads the in-memory cache only, so after a process restart
743
+ * runs that suspended in a prior lifetime are not listed here even though
744
+ * they remain durably stored and resumable by id. Use
745
+ * {@link listSuspendedRunsDurable} to include those.
603
746
  */
604
747
  listSuspendedRuns() {
605
748
  return [...this.suspendedRuns.values()].map((r) => ({
@@ -609,6 +752,28 @@ var AutomationEngine = class {
609
752
  correlation: r.correlation
610
753
  }));
611
754
  }
755
+ /**
756
+ * Like {@link listSuspendedRuns} but includes runs held only in the durable
757
+ * {@link SuspendedRunStore} (e.g. suspended before a restart). The in-memory
758
+ * cache takes precedence on id collisions. Falls back to the in-memory list
759
+ * when no store is configured.
760
+ */
761
+ async listSuspendedRunsDurable() {
762
+ const byId = /* @__PURE__ */ new Map();
763
+ if (this.store) {
764
+ try {
765
+ for (const r of await this.store.list()) {
766
+ byId.set(r.runId, { runId: r.runId, flowName: r.flowName, nodeId: r.nodeId, correlation: r.correlation });
767
+ }
768
+ } catch (err) {
769
+ this.logger.warn(`[automation] failed to list suspended runs from durable store: ${err.message}`);
770
+ }
771
+ }
772
+ for (const r of this.suspendedRuns.values()) {
773
+ byId.set(r.runId, { runId: r.runId, flowName: r.flowName, nodeId: r.nodeId, correlation: r.correlation });
774
+ }
775
+ return [...byId.values()];
776
+ }
612
777
  /**
613
778
  * The screen a paused run is currently waiting on (screen-flow runtime), or
614
779
  * `null` if the run isn't suspended / didn't pause at a screen node. Lets a
@@ -647,6 +812,41 @@ var AutomationEngine = class {
647
812
  );
648
813
  }
649
814
  }
815
+ /**
816
+ * ADR-0032 §Decision 1a — parse-validate every predicate in the flow at
817
+ * registration. Predicates are bare CEL; this catches the #1491 class
818
+ * (`{record.x}` template braces in a condition → CEL parse error) and any
819
+ * other malformed predicate LOUDLY, with the offending location + source +
820
+ * a corrective hint, instead of letting it fail silently at run time.
821
+ *
822
+ * Only the *predicate* surfaces are checked here (start/node `config.condition`
823
+ * and `edge.condition`) — node string fields are templates (a different
824
+ * dialect) and are validated by the template engine, not as CEL.
825
+ */
826
+ validateFlowExpressions(flowName, flow) {
827
+ const failures = [];
828
+ const check = (where, raw) => {
829
+ if (raw == null) return;
830
+ const result = (0, import_formula.validateExpression)("predicate", raw);
831
+ for (const e of result.errors) {
832
+ failures.push(` \u2022 ${where}: ${e.message}
833
+ source: \`${e.source}\``);
834
+ }
835
+ };
836
+ for (const node of flow.nodes) {
837
+ const cfg = node.config ?? {};
838
+ check(`node '${node.id}' (${node.type}) condition`, cfg.condition);
839
+ }
840
+ for (const edge of flow.edges) {
841
+ check(`edge '${edge.id}' (${edge.source}\u2192${edge.target}) condition`, edge.condition);
842
+ }
843
+ if (failures.length > 0) {
844
+ throw new Error(
845
+ `Flow '${flowName}' has ${failures.length} invalid condition${failures.length > 1 ? "s" : ""} (ADR-0032 \xA71a). Conditions are bare CEL \u2014 do not wrap field references in \`{\u2026}\` template braces:
846
+ ${failures.join("\n")}`
847
+ );
848
+ }
849
+ }
650
850
  /**
651
851
  * Detect cycles in the flow graph (DAG validation).
652
852
  * Uses DFS with coloring (white/gray/black) to detect back edges.
@@ -874,6 +1074,45 @@ var AutomationEngine = class {
874
1074
  await Promise.all(parallelTasks);
875
1075
  }
876
1076
  }
1077
+ /**
1078
+ * Execute a structured control-flow **region** (ADR-0031) — the nested
1079
+ * body of a `loop` container (or, later, a `parallel` branch / `try_catch`
1080
+ * region). The region is a self-contained single-entry/single-exit
1081
+ * sub-graph carried in the container's `config`; it runs in the **enclosing
1082
+ * variable scope** (the caller's `variables` map), so the iterator variable
1083
+ * and any body mutations are visible to the surrounding flow — a region is
1084
+ * NOT a separate `subflow` invocation.
1085
+ *
1086
+ * The region executes against a synthetic flow view of its own
1087
+ * nodes/edges, so the main DAG traversal (`traverseNext`) is never aware of
1088
+ * scope markers — keeping the shared traversal untouched.
1089
+ *
1090
+ * Body step logs are kept in a region-local array (not yet merged into the
1091
+ * parent run log); surfacing per-iteration steps is a follow-up.
1092
+ *
1093
+ * Durable pause (`suspend`) inside a region is not supported in this
1094
+ * iteration — it is converted into a clear error (mirrors the `subflow`
1095
+ * nested-pause guard).
1096
+ */
1097
+ async runRegion(region, variables, context) {
1098
+ const entryId = (0, import_automation.findRegionEntry)(region);
1099
+ const entry = region.nodes.find((n) => n.id === entryId);
1100
+ if (!entry) {
1101
+ throw new Error(`region entry node '${entryId}' not found`);
1102
+ }
1103
+ const subFlow = { nodes: region.nodes, edges: region.edges ?? [] };
1104
+ const regionSteps = [];
1105
+ try {
1106
+ await this.executeNode(entry, subFlow, variables, context, regionSteps);
1107
+ } catch (err) {
1108
+ if (isSuspendSignal(err)) {
1109
+ throw new Error(
1110
+ `durable pause inside a structured region (node '${err.nodeId}') is not supported`
1111
+ );
1112
+ }
1113
+ throw err;
1114
+ }
1115
+ }
877
1116
  /**
878
1117
  * Execute a promise with timeout using Promise.race.
879
1118
  */
@@ -916,10 +1155,17 @@ var AutomationEngine = class {
916
1155
  { dialect: "cel", source: exprStr },
917
1156
  { extra: { ...vars, vars }, record: vars }
918
1157
  );
919
- if (!result.ok) return false;
1158
+ if (!result.ok) {
1159
+ throw new Error(
1160
+ `condition failed to evaluate as CEL: ${result.error?.message ?? "unknown error"} \u2014 source: \`${exprStr}\`. Conditions are bare CEL (e.g. \`record.rating >= 4\`); do not wrap field references in \`{\u2026}\` template braces.`
1161
+ );
1162
+ }
920
1163
  return Boolean(result.value);
921
- } catch {
922
- return false;
1164
+ } catch (err) {
1165
+ const msg = err?.message ?? String(err);
1166
+ throw new Error(
1167
+ msg.includes("source:") ? msg : `condition evaluation error: ${msg} \u2014 source: \`${exprStr}\``
1168
+ );
923
1169
  }
924
1170
  }
925
1171
  let resolved = exprStr;
@@ -1038,7 +1284,7 @@ var AutomationEngine = class {
1038
1284
  if (context?.record) {
1039
1285
  variables.set("$record", context.record);
1040
1286
  }
1041
- const runId = `run_${++this.runCounter}`;
1287
+ const runId = this.nextRunId();
1042
1288
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1043
1289
  const steps = [];
1044
1290
  try {
@@ -1097,6 +1343,243 @@ var AutomationEngine = class {
1097
1343
  }
1098
1344
  };
1099
1345
 
1346
+ // src/suspended-run-store.ts
1347
+ var TABLE = "sys_automation_run";
1348
+ var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
1349
+ function jsonClone(value) {
1350
+ return JSON.parse(JSON.stringify(value));
1351
+ }
1352
+ function parseJson(raw, fallback) {
1353
+ if (raw == null || raw === "") return fallback;
1354
+ if (typeof raw === "string") {
1355
+ try {
1356
+ return JSON.parse(raw);
1357
+ } catch {
1358
+ return fallback;
1359
+ }
1360
+ }
1361
+ return raw;
1362
+ }
1363
+ var InMemorySuspendedRunStore = class {
1364
+ constructor() {
1365
+ this.runs = /* @__PURE__ */ new Map();
1366
+ }
1367
+ async save(run) {
1368
+ this.runs.set(run.runId, jsonClone(run));
1369
+ }
1370
+ async load(runId) {
1371
+ const run = this.runs.get(runId);
1372
+ return run ? jsonClone(run) : null;
1373
+ }
1374
+ async delete(runId) {
1375
+ this.runs.delete(runId);
1376
+ }
1377
+ async list() {
1378
+ return [...this.runs.values()].map(jsonClone);
1379
+ }
1380
+ };
1381
+ var ObjectStoreSuspendedRunStore = class {
1382
+ constructor(engine, logger) {
1383
+ this.engine = engine;
1384
+ this.logger = logger;
1385
+ }
1386
+ async save(run) {
1387
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1388
+ const row = this.serialize(run);
1389
+ const existing = await this.engine.find(TABLE, {
1390
+ where: { id: run.runId },
1391
+ limit: 1,
1392
+ context: SYSTEM_CTX
1393
+ });
1394
+ if (Array.isArray(existing) && existing[0]) {
1395
+ await this.engine.update(
1396
+ TABLE,
1397
+ { ...row, updated_at: now },
1398
+ { where: { id: run.runId }, context: SYSTEM_CTX }
1399
+ );
1400
+ } else {
1401
+ await this.engine.insert(
1402
+ TABLE,
1403
+ { ...row, created_at: now, updated_at: now },
1404
+ { context: SYSTEM_CTX }
1405
+ );
1406
+ }
1407
+ }
1408
+ async load(runId) {
1409
+ const rows = await this.engine.find(TABLE, {
1410
+ where: { id: runId },
1411
+ limit: 1,
1412
+ context: SYSTEM_CTX
1413
+ });
1414
+ const row = Array.isArray(rows) ? rows[0] : null;
1415
+ return row ? this.deserialize(row) : null;
1416
+ }
1417
+ async delete(runId) {
1418
+ if (typeof this.engine.delete !== "function") {
1419
+ this.logger?.warn?.(
1420
+ `[automation] ObjectStoreSuspendedRunStore: engine has no delete(); suspended run '${runId}' row not removed`
1421
+ );
1422
+ return;
1423
+ }
1424
+ await this.engine.delete(TABLE, { where: { id: runId }, context: SYSTEM_CTX });
1425
+ }
1426
+ async list() {
1427
+ const rows = await this.engine.find(TABLE, {
1428
+ where: { status: "paused" },
1429
+ limit: 1e3,
1430
+ context: SYSTEM_CTX
1431
+ });
1432
+ return (Array.isArray(rows) ? rows : []).map((r) => this.deserialize(r));
1433
+ }
1434
+ /** Flatten a run into a `sys_automation_run` row (state columns JSON-encoded). */
1435
+ serialize(run) {
1436
+ const ctx = run.context ?? {};
1437
+ const org = ctx.organizationId ?? ctx.tenantId ?? null;
1438
+ return {
1439
+ id: run.runId,
1440
+ organization_id: org,
1441
+ flow_name: run.flowName,
1442
+ flow_version: run.flowVersion ?? null,
1443
+ node_id: run.nodeId,
1444
+ status: "paused",
1445
+ correlation: run.correlation ?? null,
1446
+ user_id: ctx.userId ?? null,
1447
+ variables_json: JSON.stringify(run.variables ?? {}),
1448
+ steps_json: JSON.stringify(run.steps ?? []),
1449
+ context_json: JSON.stringify(run.context ?? {}),
1450
+ screen_json: run.screen ? JSON.stringify(run.screen) : null,
1451
+ started_at: run.startedAt,
1452
+ start_time: run.startTime ?? null
1453
+ };
1454
+ }
1455
+ /** Rebuild a run from a `sys_automation_run` row. */
1456
+ deserialize(row) {
1457
+ const startedAt = row.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
1458
+ return {
1459
+ runId: String(row.id),
1460
+ flowName: String(row.flow_name ?? ""),
1461
+ flowVersion: row.flow_version ?? void 0,
1462
+ nodeId: String(row.node_id ?? ""),
1463
+ variables: parseJson(row.variables_json, {}),
1464
+ steps: parseJson(row.steps_json, []),
1465
+ context: parseJson(row.context_json, {}),
1466
+ startedAt,
1467
+ startTime: typeof row.start_time === "number" ? row.start_time : Date.parse(startedAt) || Date.now(),
1468
+ correlation: row.correlation ?? void 0,
1469
+ screen: parseJson(row.screen_json, void 0)
1470
+ };
1471
+ }
1472
+ };
1473
+
1474
+ // src/sys-automation-run.object.ts
1475
+ var import_data = require("@objectstack/spec/data");
1476
+ var SysAutomationRun = import_data.ObjectSchema.create({
1477
+ name: "sys_automation_run",
1478
+ label: "Automation Run",
1479
+ pluralLabel: "Automation Runs",
1480
+ icon: "pause-circle",
1481
+ isSystem: true,
1482
+ managedBy: "system",
1483
+ description: "Durable state of a suspended automation flow run (ADR-0019)",
1484
+ displayNameField: "id",
1485
+ titleFormat: "{flow_name} \xB7 {node_id}",
1486
+ compactLayout: ["flow_name", "node_id", "status", "correlation", "started_at", "updated_at"],
1487
+ fields: {
1488
+ id: import_data.Field.text({ label: "Run ID", required: true, readonly: true, group: "System" }),
1489
+ organization_id: import_data.Field.lookup("sys_organization", {
1490
+ label: "Organization",
1491
+ required: false,
1492
+ group: "System",
1493
+ description: "Tenant that owns this run (propagated from the trigger context)"
1494
+ }),
1495
+ flow_name: import_data.Field.text({
1496
+ label: "Flow",
1497
+ required: true,
1498
+ maxLength: 255,
1499
+ searchable: true,
1500
+ group: "Identity"
1501
+ }),
1502
+ flow_version: import_data.Field.number({ label: "Flow Version", required: false, group: "Identity" }),
1503
+ node_id: import_data.Field.text({
1504
+ label: "Paused Node",
1505
+ required: true,
1506
+ maxLength: 255,
1507
+ description: "Node the run is suspended at; resume continues from its out-edges.",
1508
+ group: "State"
1509
+ }),
1510
+ status: import_data.Field.select(
1511
+ ["paused"],
1512
+ {
1513
+ label: "Status",
1514
+ required: true,
1515
+ defaultValue: "paused",
1516
+ description: "Only suspended runs are persisted; the row is deleted on terminal completion.",
1517
+ group: "State"
1518
+ }
1519
+ ),
1520
+ correlation: import_data.Field.text({
1521
+ label: "Correlation",
1522
+ required: false,
1523
+ maxLength: 255,
1524
+ description: "Correlation key from the pausing node (e.g. approval request id).",
1525
+ group: "State"
1526
+ }),
1527
+ user_id: import_data.Field.text({
1528
+ label: "User",
1529
+ required: false,
1530
+ maxLength: 255,
1531
+ description: "User who triggered the run (from context.userId).",
1532
+ group: "State"
1533
+ }),
1534
+ variables_json: import_data.Field.textarea({
1535
+ label: "Variables",
1536
+ required: false,
1537
+ description: "JSON snapshot of the flow variable map at suspend time.",
1538
+ group: "State"
1539
+ }),
1540
+ steps_json: import_data.Field.textarea({
1541
+ label: "Steps",
1542
+ required: false,
1543
+ description: "JSON snapshot of the executed step logs so far.",
1544
+ group: "State"
1545
+ }),
1546
+ context_json: import_data.Field.textarea({
1547
+ label: "Context",
1548
+ required: false,
1549
+ description: "JSON snapshot of the trigger / automation context.",
1550
+ group: "State"
1551
+ }),
1552
+ screen_json: import_data.Field.textarea({
1553
+ label: "Screen",
1554
+ required: false,
1555
+ description: "JSON snapshot of the screen spec the run is waiting on (screen-flow runtime).",
1556
+ group: "State"
1557
+ }),
1558
+ started_at: import_data.Field.datetime({ label: "Started At", required: true, group: "State" }),
1559
+ start_time: import_data.Field.number({
1560
+ label: "Start Time (epoch ms)",
1561
+ required: false,
1562
+ description: "Epoch ms when the run started; used to compute duration on resume.",
1563
+ group: "State"
1564
+ }),
1565
+ created_at: import_data.Field.datetime({
1566
+ label: "Created At",
1567
+ required: true,
1568
+ defaultValue: "NOW()",
1569
+ readonly: true,
1570
+ group: "System"
1571
+ }),
1572
+ updated_at: import_data.Field.datetime({ label: "Updated At", required: false, group: "System" })
1573
+ },
1574
+ indexes: [
1575
+ // "Which runs are suspended for this flow?" — operability / resume sweeps.
1576
+ { fields: ["flow_name", "status"] },
1577
+ { fields: ["status", "updated_at"] },
1578
+ // Look up a suspended run by the pausing node's correlation key.
1579
+ { fields: ["correlation"] }
1580
+ ]
1581
+ });
1582
+
1100
1583
  // src/builtin/logic-nodes.ts
1101
1584
  var import_automation2 = require("@objectstack/spec/automation");
1102
1585
  function registerLogicNodes(engine, ctx) {
@@ -1141,34 +1624,10 @@ function registerLogicNodes(engine, ctx) {
1141
1624
  return { success: true };
1142
1625
  }
1143
1626
  });
1144
- engine.registerNodeExecutor({
1145
- type: "loop",
1146
- descriptor: (0, import_automation2.defineActionDescriptor)({
1147
- type: "loop",
1148
- version: "1.0.0",
1149
- name: "Loop",
1150
- description: "Iterate over a collection.",
1151
- icon: "repeat",
1152
- category: "logic",
1153
- source: "builtin"
1154
- }),
1155
- async execute(node, variables, _context) {
1156
- const config = node.config;
1157
- const collectionName = config?.collection;
1158
- if (collectionName) {
1159
- const collection = variables.get(collectionName);
1160
- if (Array.isArray(collection)) {
1161
- variables.set("$loopItems", collection);
1162
- variables.set("$loopIndex", 0);
1163
- }
1164
- }
1165
- return { success: true };
1166
- }
1167
- });
1168
- ctx.logger.info("[Logic Nodes] 3 built-in node executors registered");
1627
+ ctx.logger.info("[Logic Nodes] 2 built-in node executors registered");
1169
1628
  }
1170
1629
 
1171
- // src/builtin/crud-nodes.ts
1630
+ // src/builtin/loop-node.ts
1172
1631
  var import_automation3 = require("@objectstack/spec/automation");
1173
1632
 
1174
1633
  // src/builtin/template.ts
@@ -1268,7 +1727,232 @@ function interpolate(value, variables, context) {
1268
1727
  return value;
1269
1728
  }
1270
1729
 
1730
+ // src/builtin/loop-node.ts
1731
+ function registerLoopNode(engine, ctx) {
1732
+ engine.registerNodeExecutor({
1733
+ type: "loop",
1734
+ descriptor: (0, import_automation3.defineActionDescriptor)({
1735
+ type: "loop",
1736
+ version: "2.0.0",
1737
+ name: "Loop",
1738
+ description: "Iterate a body region over a collection (bounded, structured container).",
1739
+ icon: "repeat",
1740
+ category: "logic",
1741
+ source: "builtin",
1742
+ configSchema: {
1743
+ type: "object",
1744
+ properties: {
1745
+ collection: { type: "string", description: "Template/variable resolving to the array to iterate" },
1746
+ iteratorVariable: { type: "string", description: "Loop variable holding the current item" },
1747
+ indexVariable: { type: "string", description: "Optional loop variable holding the current index" },
1748
+ maxIterations: { type: "integer", minimum: 1, maximum: import_automation3.LOOP_MAX_ITERATIONS_CEILING },
1749
+ body: {
1750
+ type: "object",
1751
+ description: "Loop body region (single-entry/single-exit sub-graph)",
1752
+ properties: { nodes: { type: "array" }, edges: { type: "array" } }
1753
+ }
1754
+ },
1755
+ required: ["collection"]
1756
+ }
1757
+ }),
1758
+ async execute(node, variables, context) {
1759
+ const cfg = node.config ?? {};
1760
+ const body = cfg.body;
1761
+ if (body == null) {
1762
+ const collectionName = typeof cfg.collection === "string" ? cfg.collection : void 0;
1763
+ if (collectionName) {
1764
+ const legacy = variables.get(collectionName);
1765
+ if (Array.isArray(legacy)) {
1766
+ variables.set("$loopItems", legacy);
1767
+ variables.set("$loopIndex", 0);
1768
+ }
1769
+ }
1770
+ return { success: true };
1771
+ }
1772
+ const iteratorVariable = typeof cfg.iteratorVariable === "string" && cfg.iteratorVariable ? cfg.iteratorVariable : "item";
1773
+ const indexVariable = typeof cfg.indexVariable === "string" && cfg.indexVariable ? cfg.indexVariable : void 0;
1774
+ const rawCollection = cfg.collection;
1775
+ let collection;
1776
+ if (Array.isArray(rawCollection)) {
1777
+ collection = rawCollection;
1778
+ } else if (typeof rawCollection === "string") {
1779
+ collection = interpolate(rawCollection, variables, context ?? {});
1780
+ if (collection == null && variables.has(rawCollection)) {
1781
+ collection = variables.get(rawCollection);
1782
+ }
1783
+ }
1784
+ if (!Array.isArray(collection)) {
1785
+ return {
1786
+ success: false,
1787
+ error: `loop '${node.id}': collection '${String(rawCollection)}' did not resolve to an array`
1788
+ };
1789
+ }
1790
+ const requested = typeof cfg.maxIterations === "number" ? cfg.maxIterations : import_automation3.LOOP_MAX_ITERATIONS_CEILING;
1791
+ const maxIterations = Math.min(requested, import_automation3.LOOP_MAX_ITERATIONS_CEILING);
1792
+ if (collection.length > maxIterations) {
1793
+ return {
1794
+ success: false,
1795
+ error: `loop '${node.id}': collection length ${collection.length} exceeds maxIterations ${maxIterations}`
1796
+ };
1797
+ }
1798
+ let iterations = 0;
1799
+ for (let i = 0; i < collection.length; i++) {
1800
+ variables.set(iteratorVariable, collection[i]);
1801
+ if (indexVariable) variables.set(indexVariable, i);
1802
+ await engine.runRegion(body, variables, context ?? {});
1803
+ iterations++;
1804
+ }
1805
+ return { success: true, output: { iterations } };
1806
+ }
1807
+ });
1808
+ ctx.logger.info("[Loop Node] 1 built-in node executor registered");
1809
+ }
1810
+
1811
+ // src/builtin/parallel-node.ts
1812
+ var import_automation4 = require("@objectstack/spec/automation");
1813
+ function registerParallelNode(engine, ctx) {
1814
+ engine.registerNodeExecutor({
1815
+ type: "parallel",
1816
+ descriptor: (0, import_automation4.defineActionDescriptor)({
1817
+ type: "parallel",
1818
+ version: "1.0.0",
1819
+ name: "Parallel",
1820
+ description: "Run N branch regions concurrently and join implicitly when all complete.",
1821
+ icon: "git-fork",
1822
+ category: "logic",
1823
+ source: "builtin",
1824
+ configSchema: {
1825
+ type: "object",
1826
+ properties: {
1827
+ branches: {
1828
+ type: "array",
1829
+ minItems: 2,
1830
+ description: "Branch regions executed concurrently; implicit join at block end",
1831
+ items: {
1832
+ type: "object",
1833
+ properties: {
1834
+ name: { type: "string" },
1835
+ nodes: { type: "array" },
1836
+ edges: { type: "array" }
1837
+ }
1838
+ }
1839
+ }
1840
+ },
1841
+ required: ["branches"]
1842
+ }
1843
+ }),
1844
+ async execute(node, variables, context) {
1845
+ const cfg = node.config ?? {};
1846
+ const branches = cfg.branches;
1847
+ if (!Array.isArray(branches) || branches.length < 2) {
1848
+ return {
1849
+ success: false,
1850
+ error: `parallel '${node.id}': config.branches must declare at least 2 branch regions`
1851
+ };
1852
+ }
1853
+ try {
1854
+ await Promise.all(
1855
+ branches.map((branch) => engine.runRegion(branch, variables, context ?? {}))
1856
+ );
1857
+ } catch (err) {
1858
+ const message = err instanceof Error ? err.message : String(err);
1859
+ return { success: false, error: `parallel '${node.id}': branch failed \u2014 ${message}` };
1860
+ }
1861
+ return { success: true, output: { branches: branches.length } };
1862
+ }
1863
+ });
1864
+ ctx.logger.info("[Parallel Node] 1 built-in node executor registered");
1865
+ }
1866
+
1867
+ // src/builtin/try-catch-node.ts
1868
+ var import_automation5 = require("@objectstack/spec/automation");
1869
+ function registerTryCatchNode(engine, ctx) {
1870
+ engine.registerNodeExecutor({
1871
+ type: "try_catch",
1872
+ descriptor: (0, import_automation5.defineActionDescriptor)({
1873
+ type: "try_catch",
1874
+ version: "1.0.0",
1875
+ name: "Try / Catch",
1876
+ description: "Run a protected region with optional retry and a catch handler (structured error handling).",
1877
+ icon: "shield-alert",
1878
+ category: "logic",
1879
+ source: "builtin",
1880
+ supportsRetry: true,
1881
+ configSchema: {
1882
+ type: "object",
1883
+ properties: {
1884
+ try: {
1885
+ type: "object",
1886
+ description: "Protected region (single-entry/single-exit sub-graph)",
1887
+ properties: { nodes: { type: "array" }, edges: { type: "array" } }
1888
+ },
1889
+ catch: {
1890
+ type: "object",
1891
+ description: "Handler region run when the try region fails",
1892
+ properties: { nodes: { type: "array" }, edges: { type: "array" } }
1893
+ },
1894
+ errorVariable: { type: "string", description: "Variable holding the caught error in the catch region" },
1895
+ retry: {
1896
+ type: "object",
1897
+ properties: {
1898
+ maxRetries: { type: "integer", minimum: 0, maximum: 10 },
1899
+ retryDelayMs: { type: "integer", minimum: 0 },
1900
+ backoffMultiplier: { type: "number", minimum: 1 },
1901
+ maxRetryDelayMs: { type: "integer", minimum: 0 },
1902
+ jitter: { type: "boolean" }
1903
+ }
1904
+ }
1905
+ },
1906
+ required: ["try"]
1907
+ }
1908
+ }),
1909
+ async execute(node, variables, context) {
1910
+ const cfg = node.config ?? {};
1911
+ const tryRegion = cfg.try;
1912
+ const catchRegion = cfg.catch;
1913
+ const errorVariable = typeof cfg.errorVariable === "string" && cfg.errorVariable ? cfg.errorVariable : "$error";
1914
+ const retry = cfg.retry ?? {};
1915
+ if (tryRegion == null) {
1916
+ return { success: false, error: `try_catch '${node.id}': config.try region is required` };
1917
+ }
1918
+ const ctxOrEmpty = context ?? {};
1919
+ const maxRetries = typeof retry.maxRetries === "number" ? retry.maxRetries : 0;
1920
+ const baseDelay = typeof retry.retryDelayMs === "number" ? retry.retryDelayMs : 0;
1921
+ const multiplier = typeof retry.backoffMultiplier === "number" ? retry.backoffMultiplier : 1;
1922
+ const maxDelay = typeof retry.maxRetryDelayMs === "number" ? retry.maxRetryDelayMs : 3e4;
1923
+ const useJitter = retry.jitter === true;
1924
+ let lastError = "unknown error";
1925
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1926
+ if (attempt > 0) {
1927
+ let delay = Math.min(baseDelay * Math.pow(multiplier, attempt - 1), maxDelay);
1928
+ if (useJitter) delay = delay * (0.5 + Math.random() * 0.5);
1929
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay));
1930
+ }
1931
+ try {
1932
+ await engine.runRegion(tryRegion, variables, ctxOrEmpty);
1933
+ return { success: true, output: { attempts: attempt + 1, caught: false } };
1934
+ } catch (err) {
1935
+ lastError = err instanceof Error ? err.message : String(err);
1936
+ }
1937
+ }
1938
+ if (catchRegion != null) {
1939
+ variables.set(errorVariable, { nodeId: node.id, message: lastError });
1940
+ try {
1941
+ await engine.runRegion(catchRegion, variables, ctxOrEmpty);
1942
+ return { success: true, output: { attempts: maxRetries + 1, caught: true, error: lastError } };
1943
+ } catch (catchErr) {
1944
+ const catchMsg = catchErr instanceof Error ? catchErr.message : String(catchErr);
1945
+ return { success: false, error: `try_catch '${node.id}': catch region failed \u2014 ${catchMsg}` };
1946
+ }
1947
+ }
1948
+ return { success: false, error: `try_catch '${node.id}': try region failed \u2014 ${lastError}` };
1949
+ }
1950
+ });
1951
+ ctx.logger.info("[TryCatch Node] 1 built-in node executor registered");
1952
+ }
1953
+
1271
1954
  // src/builtin/crud-nodes.ts
1955
+ var import_automation6 = require("@objectstack/spec/automation");
1272
1956
  function registerCrudNodes(engine, ctx) {
1273
1957
  const getData = () => {
1274
1958
  try {
@@ -1279,7 +1963,7 @@ function registerCrudNodes(engine, ctx) {
1279
1963
  };
1280
1964
  engine.registerNodeExecutor({
1281
1965
  type: "get_record",
1282
- descriptor: (0, import_automation3.defineActionDescriptor)({
1966
+ descriptor: (0, import_automation6.defineActionDescriptor)({
1283
1967
  type: "get_record",
1284
1968
  version: "1.0.0",
1285
1969
  name: "Get Records",
@@ -1317,7 +2001,7 @@ function registerCrudNodes(engine, ctx) {
1317
2001
  });
1318
2002
  engine.registerNodeExecutor({
1319
2003
  type: "create_record",
1320
- descriptor: (0, import_automation3.defineActionDescriptor)({
2004
+ descriptor: (0, import_automation6.defineActionDescriptor)({
1321
2005
  type: "create_record",
1322
2006
  version: "1.0.0",
1323
2007
  name: "Create Record",
@@ -1350,7 +2034,7 @@ function registerCrudNodes(engine, ctx) {
1350
2034
  });
1351
2035
  engine.registerNodeExecutor({
1352
2036
  type: "update_record",
1353
- descriptor: (0, import_automation3.defineActionDescriptor)({
2037
+ descriptor: (0, import_automation6.defineActionDescriptor)({
1354
2038
  type: "update_record",
1355
2039
  version: "1.0.0",
1356
2040
  name: "Update Records",
@@ -1380,7 +2064,7 @@ function registerCrudNodes(engine, ctx) {
1380
2064
  });
1381
2065
  engine.registerNodeExecutor({
1382
2066
  type: "delete_record",
1383
- descriptor: (0, import_automation3.defineActionDescriptor)({
2067
+ descriptor: (0, import_automation6.defineActionDescriptor)({
1384
2068
  type: "delete_record",
1385
2069
  version: "1.0.0",
1386
2070
  name: "Delete Records",
@@ -1408,11 +2092,11 @@ function registerCrudNodes(engine, ctx) {
1408
2092
  }
1409
2093
 
1410
2094
  // src/builtin/screen-nodes.ts
1411
- var import_automation4 = require("@objectstack/spec/automation");
2095
+ var import_automation7 = require("@objectstack/spec/automation");
1412
2096
  function registerScreenNodes(engine, ctx) {
1413
2097
  engine.registerNodeExecutor({
1414
2098
  type: "screen",
1415
- descriptor: (0, import_automation4.defineActionDescriptor)({
2099
+ descriptor: (0, import_automation7.defineActionDescriptor)({
1416
2100
  type: "screen",
1417
2101
  version: "1.0.0",
1418
2102
  name: "Screen",
@@ -1455,7 +2139,7 @@ function registerScreenNodes(engine, ctx) {
1455
2139
  });
1456
2140
  engine.registerNodeExecutor({
1457
2141
  type: "script",
1458
- descriptor: (0, import_automation4.defineActionDescriptor)({
2142
+ descriptor: (0, import_automation7.defineActionDescriptor)({
1459
2143
  type: "script",
1460
2144
  version: "1.0.0",
1461
2145
  name: "Script",
@@ -1488,55 +2172,133 @@ function registerScreenNodes(engine, ctx) {
1488
2172
  }
1489
2173
 
1490
2174
  // src/builtin/http-nodes.ts
1491
- var import_automation5 = require("@objectstack/spec/automation");
2175
+ var import_automation8 = require("@objectstack/spec/automation");
2176
+ var import_node_crypto = require("crypto");
2177
+ var HTTP_TYPE = "http";
1492
2178
  function registerHttpNodes(engine, ctx) {
2179
+ const getMessaging = () => {
2180
+ try {
2181
+ return ctx.getService("messaging");
2182
+ } catch {
2183
+ return void 0;
2184
+ }
2185
+ };
1493
2186
  engine.registerNodeExecutor({
1494
- type: "http_request",
1495
- descriptor: (0, import_automation5.defineActionDescriptor)({
1496
- type: "http_request",
2187
+ type: HTTP_TYPE,
2188
+ descriptor: (0, import_automation8.defineActionDescriptor)({
2189
+ type: HTTP_TYPE,
1497
2190
  version: "1.0.0",
1498
- name: "HTTP Request",
1499
- description: "Call an external HTTP endpoint. (ADR-0018: migrates to outbox-backed `http`.)",
2191
+ name: "HTTP",
2192
+ description: "Call an external HTTP endpoint. With `durable: true`, the call is enqueued on the messaging outbox with retry / dead-letter; otherwise it runs inline and returns the response.",
1500
2193
  icon: "globe",
1501
2194
  category: "io",
1502
2195
  source: "builtin",
1503
- // ADR-0018 §M3 target: route via service-messaging outbox for
1504
- // retry/idempotency/dead-letter. Today this is a bare fetch().
1505
- needsOutbox: false,
2196
+ // Capable of outbox-backed durable delivery (used when durable:true
2197
+ // and the messaging HTTP outbox is wired).
2198
+ needsOutbox: true,
1506
2199
  supportsRetry: true,
1507
- paradigms: ["flow", "workflow_rule", "approval"]
2200
+ paradigms: ["flow", "workflow_rule", "approval"],
2201
+ configSchema: {
2202
+ type: "object",
2203
+ required: ["url"],
2204
+ properties: {
2205
+ url: { type: "string", description: "Target URL" },
2206
+ method: { type: "string", description: "HTTP method (default GET; POST when durable)" },
2207
+ headers: { type: "object", description: "Request headers" },
2208
+ body: { description: "Request body (JSON-serialised)" },
2209
+ durable: {
2210
+ type: "boolean",
2211
+ description: "Fire-and-forget via the durable outbox (retry/dead-letter) instead of inline request/response"
2212
+ },
2213
+ timeoutMs: { type: "number", description: "Per-request timeout (ms)" },
2214
+ signingSecret: { type: "string", description: "HMAC-SHA256 secret \u2192 X-Objectstack-Signature" }
2215
+ }
2216
+ }
1508
2217
  }),
1509
- async execute(node, _variables, _context) {
1510
- const config = node.config;
1511
- const url = config?.url;
1512
- const method = config?.method ?? "GET";
1513
- const headers = config?.headers;
1514
- const body = config?.body;
1515
- if (!url) {
1516
- return { success: false, error: "http_request: url is required" };
1517
- }
1518
- const response = await fetch(url, {
1519
- method,
1520
- headers,
1521
- body: body ? JSON.stringify(body) : void 0
1522
- });
1523
- const data = await response.json();
1524
- return {
1525
- success: response.ok,
1526
- output: { response: data, status: response.status },
1527
- error: response.ok ? void 0 : `HTTP ${response.status}`
1528
- };
2218
+ async execute(node, variables, context) {
2219
+ const raw = node.config ?? {};
2220
+ const cfg = interpolate(raw, variables, context);
2221
+ const url = cfg.url;
2222
+ if (!url) return { success: false, error: "http: url is required" };
2223
+ const durable = cfg.durable === true;
2224
+ const headers = cfg.headers;
2225
+ const body = cfg.body;
2226
+ const timeoutMs = typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : void 0;
2227
+ const signingSecret = cfg.signingSecret;
2228
+ if (durable) {
2229
+ const messaging = getMessaging();
2230
+ if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
2231
+ try {
2232
+ const deliveryId = await messaging.enqueueHttp({
2233
+ source: "flow",
2234
+ refId: node.id,
2235
+ dedupKey: (0, import_node_crypto.randomUUID)(),
2236
+ label: `flow:${node.id}`,
2237
+ url,
2238
+ method: cfg.method ?? "POST",
2239
+ headers,
2240
+ signingSecret,
2241
+ timeoutMs,
2242
+ payload: body ?? {}
2243
+ });
2244
+ return { success: true, output: { deliveryId, enqueued: true } };
2245
+ } catch (err) {
2246
+ return { success: false, error: `http (durable) failed to enqueue: ${err.message}` };
2247
+ }
2248
+ }
2249
+ ctx.logger.warn(
2250
+ `[http] node '${node.id}' requested durable delivery but no messaging HTTP outbox is wired; falling back to inline fetch`
2251
+ );
2252
+ }
2253
+ const method = cfg.method ?? "GET";
2254
+ const controller = new AbortController();
2255
+ const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
2256
+ try {
2257
+ const response = await fetch(url, {
2258
+ method,
2259
+ headers,
2260
+ body: body !== void 0 && body !== null ? JSON.stringify(body) : void 0,
2261
+ signal: controller.signal
2262
+ });
2263
+ const data = await readBody(response);
2264
+ return {
2265
+ success: response.ok,
2266
+ output: { response: data, status: response.status },
2267
+ error: response.ok ? void 0 : `HTTP ${response.status}`
2268
+ };
2269
+ } catch (err) {
2270
+ const e = err;
2271
+ const msg = e?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
2272
+ return { success: false, error: `http: ${msg}` };
2273
+ } finally {
2274
+ if (timer) clearTimeout(timer);
2275
+ }
1529
2276
  }
1530
2277
  });
1531
- ctx.logger.info("[HTTP] 1 built-in node executor registered (http_request)");
2278
+ engine.registerNodeAlias("http_request", HTTP_TYPE, { name: "HTTP Request", needsOutbox: true });
2279
+ engine.registerNodeAlias("http_call", HTTP_TYPE, { name: "HTTP Call", needsOutbox: true });
2280
+ engine.registerNodeAlias("webhook", HTTP_TYPE, { name: "Webhook", needsOutbox: true });
2281
+ ctx.logger.info("[HTTP] http executor registered (+ deprecated aliases: http_request, http_call, webhook)");
2282
+ }
2283
+ async function readBody(response) {
2284
+ try {
2285
+ return await response.json();
2286
+ } catch {
2287
+ try {
2288
+ const text = await response.text();
2289
+ return text || null;
2290
+ } catch {
2291
+ return null;
2292
+ }
2293
+ }
1532
2294
  }
1533
2295
 
1534
2296
  // src/builtin/connector-nodes.ts
1535
- var import_automation6 = require("@objectstack/spec/automation");
2297
+ var import_automation9 = require("@objectstack/spec/automation");
1536
2298
  function registerConnectorNodes(engine, ctx) {
1537
2299
  engine.registerNodeExecutor({
1538
2300
  type: "connector_action",
1539
- descriptor: (0, import_automation6.defineActionDescriptor)({
2301
+ descriptor: (0, import_automation9.defineActionDescriptor)({
1540
2302
  type: "connector_action",
1541
2303
  version: "1.0.0",
1542
2304
  name: "Connector Action",
@@ -1593,7 +2355,7 @@ function registerConnectorNodes(engine, ctx) {
1593
2355
  }
1594
2356
 
1595
2357
  // src/builtin/notify-node.ts
1596
- var import_automation7 = require("@objectstack/spec/automation");
2358
+ var import_automation10 = require("@objectstack/spec/automation");
1597
2359
  function toStringList(value) {
1598
2360
  if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
1599
2361
  if (typeof value === "string" && value.trim()) return [value.trim()];
@@ -1609,7 +2371,7 @@ function registerNotifyNode(engine, ctx) {
1609
2371
  };
1610
2372
  engine.registerNodeExecutor({
1611
2373
  type: "notify",
1612
- descriptor: (0, import_automation7.defineActionDescriptor)({
2374
+ descriptor: (0, import_automation10.defineActionDescriptor)({
1613
2375
  type: "notify",
1614
2376
  version: "1.0.0",
1615
2377
  name: "Notify",
@@ -1618,6 +2380,9 @@ function registerNotifyNode(engine, ctx) {
1618
2380
  category: "io",
1619
2381
  source: "builtin",
1620
2382
  supportsRetry: true,
2383
+ // Delivery is outbox-backed inside the messaging service (ADR-0030
2384
+ // emit → sys_notification_delivery), so it inherits retry/dead-letter.
2385
+ needsOutbox: true,
1621
2386
  paradigms: ["flow", "workflow_rule", "approval"]
1622
2387
  }),
1623
2388
  async execute(node, variables, context) {
@@ -1669,7 +2434,7 @@ function registerNotifyNode(engine, ctx) {
1669
2434
  }
1670
2435
 
1671
2436
  // src/builtin/wait-node.ts
1672
- var import_automation8 = require("@objectstack/spec/automation");
2437
+ var import_automation11 = require("@objectstack/spec/automation");
1673
2438
  function registerWaitNode(engine, ctx) {
1674
2439
  const getJobService = () => {
1675
2440
  try {
@@ -1680,7 +2445,7 @@ function registerWaitNode(engine, ctx) {
1680
2445
  };
1681
2446
  engine.registerNodeExecutor({
1682
2447
  type: "wait",
1683
- descriptor: (0, import_automation8.defineActionDescriptor)({
2448
+ descriptor: (0, import_automation11.defineActionDescriptor)({
1684
2449
  type: "wait",
1685
2450
  version: "1.0.0",
1686
2451
  name: "Wait",
@@ -1752,12 +2517,12 @@ function parseIsoDuration(input) {
1752
2517
  }
1753
2518
 
1754
2519
  // src/builtin/subflow-node.ts
1755
- var import_automation9 = require("@objectstack/spec/automation");
2520
+ var import_automation12 = require("@objectstack/spec/automation");
1756
2521
  var MAX_SUBFLOW_DEPTH = 16;
1757
2522
  function registerSubflowNode(engine, ctx) {
1758
2523
  engine.registerNodeExecutor({
1759
2524
  type: "subflow",
1760
- descriptor: (0, import_automation9.defineActionDescriptor)({
2525
+ descriptor: (0, import_automation12.defineActionDescriptor)({
1761
2526
  type: "subflow",
1762
2527
  version: "1.0.0",
1763
2528
  name: "Subflow",
@@ -1807,6 +2572,9 @@ function registerSubflowNode(engine, ctx) {
1807
2572
  // src/builtin/index.ts
1808
2573
  function installBuiltinNodes(engine, ctx) {
1809
2574
  registerLogicNodes(engine, ctx);
2575
+ registerLoopNode(engine, ctx);
2576
+ registerParallelNode(engine, ctx);
2577
+ registerTryCatchNode(engine, ctx);
1810
2578
  registerCrudNodes(engine, ctx);
1811
2579
  registerScreenNodes(engine, ctx);
1812
2580
  registerHttpNodes(engine, ctx);
@@ -1835,6 +2603,24 @@ var AutomationServicePlugin = class {
1835
2603
  async init(ctx) {
1836
2604
  this.engine = new AutomationEngine(ctx.logger);
1837
2605
  ctx.registerService("automation", this.engine);
2606
+ if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
2607
+ try {
2608
+ ctx.getService("manifest").register({
2609
+ id: "com.objectstack.service-automation",
2610
+ name: "Automation Service",
2611
+ version: "1.0.0",
2612
+ type: "plugin",
2613
+ scope: "system",
2614
+ defaultDatasource: "cloud",
2615
+ namespace: "sys",
2616
+ objects: [SysAutomationRun]
2617
+ });
2618
+ } catch (err) {
2619
+ ctx.logger.warn(
2620
+ `[Automation] manifest service unavailable; sys_automation_run not registered (suspended runs stay in-memory): ${err.message}`
2621
+ );
2622
+ }
2623
+ }
1838
2624
  installBuiltinNodes(this.engine, ctx);
1839
2625
  if (this.options.debug) {
1840
2626
  ctx.hook("automation:beforeExecute", async (flowName) => {
@@ -1854,6 +2640,23 @@ var AutomationServicePlugin = class {
1854
2640
  ctx.logger.info(
1855
2641
  `[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
1856
2642
  );
2643
+ if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
2644
+ let dataEngine = null;
2645
+ try {
2646
+ dataEngine = ctx.getService("objectql");
2647
+ } catch {
2648
+ try {
2649
+ dataEngine = ctx.getService("data");
2650
+ } catch {
2651
+ }
2652
+ }
2653
+ if (dataEngine && typeof dataEngine.find === "function" && typeof dataEngine.insert === "function") {
2654
+ this.engine.setSuspendedRunStore(new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger));
2655
+ ctx.logger.info("[Automation] Suspended-run persistence enabled (sys_automation_run)");
2656
+ } else {
2657
+ ctx.logger.info("[Automation] No ObjectQL engine \u2014 suspended runs kept in-memory only");
2658
+ }
2659
+ }
1857
2660
  try {
1858
2661
  const ql = ctx.getService("objectql");
1859
2662
  if (!ql) {
@@ -1893,6 +2696,9 @@ var AutomationServicePlugin = class {
1893
2696
  0 && (module.exports = {
1894
2697
  AutomationEngine,
1895
2698
  AutomationServicePlugin,
2699
+ InMemorySuspendedRunStore,
2700
+ ObjectStoreSuspendedRunStore,
2701
+ SysAutomationRun,
1896
2702
  installBuiltinNodes,
1897
2703
  registerConnectorNodes,
1898
2704
  registerCrudNodes,