@objectstack/service-automation 7.4.1 → 7.6.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.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/engine.ts
2
- import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES } from "@objectstack/spec/automation";
2
+ import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, findRegionEntry, defineActionDescriptor } from "@objectstack/spec/automation";
3
3
  import { ConnectorSchema } from "@objectstack/spec/integration";
4
- import { ExpressionEngine } from "@objectstack/formula";
4
+ import { ExpressionEngine, validateExpression } from "@objectstack/formula";
5
5
  var FlowSuspendSignal = class {
6
6
  constructor(nodeId, correlation, screen) {
7
7
  this.nodeId = nodeId;
@@ -14,7 +14,7 @@ function isSuspendSignal(err) {
14
14
  return typeof err === "object" && err !== null && err.__flowSuspend === true;
15
15
  }
16
16
  var AutomationEngine = class {
17
- constructor(logger) {
17
+ constructor(logger, store) {
18
18
  this.flows = /* @__PURE__ */ new Map();
19
19
  this.flowEnabled = /* @__PURE__ */ new Map();
20
20
  this.flowVersionHistory = /* @__PURE__ */ new Map();
@@ -31,10 +31,74 @@ var AutomationEngine = class {
31
31
  this.connectors = /* @__PURE__ */ new Map();
32
32
  this.executionLogs = [];
33
33
  this.maxLogSize = 1e3;
34
- this.runCounter = 0;
35
- /** Runs paused at a node, keyed by runId (ADR-0019). In-memory, see {@link SuspendedRun}. */
34
+ /**
35
+ * Runs paused at a node, keyed by runId (ADR-0019). In-memory hot cache
36
+ * mirrored to {@link store} when one is configured, so a pause survives a
37
+ * process restart. See {@link SuspendedRun}.
38
+ */
36
39
  this.suspendedRuns = /* @__PURE__ */ new Map();
40
+ /**
41
+ * Run ids currently mid-resume — an in-process idempotency guard so a
42
+ * duplicate `resume(runId)` can't re-enter and double-run side effects.
43
+ */
44
+ this.resuming = /* @__PURE__ */ new Set();
37
45
  this.logger = logger;
46
+ this.store = store;
47
+ }
48
+ /**
49
+ * Attach (or replace) the durable {@link SuspendedRunStore}. Used by the
50
+ * service plugin to upgrade the engine to DB-backed persistence once the
51
+ * ObjectQL engine is available (the engine is constructed earlier, during
52
+ * `init`, before services are wired).
53
+ */
54
+ setSuspendedRunStore(store) {
55
+ this.store = store;
56
+ }
57
+ /**
58
+ * Generate a process-unique run id. Includes a random component so ids do
59
+ * not collide with runs persisted by a previous process lifetime (a plain
60
+ * incrementing counter would reissue `run_1` after a restart, clashing with
61
+ * a still-suspended durable run).
62
+ */
63
+ nextRunId() {
64
+ const g = globalThis;
65
+ const rand = g.crypto?.randomUUID ? g.crypto.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
66
+ return `run_${rand}`;
67
+ }
68
+ /**
69
+ * Persist a suspended run to the in-memory cache and (best-effort) the
70
+ * durable store. A store failure is logged but does not fail the run — the
71
+ * in-memory copy still allows in-process resume; only cross-restart
72
+ * durability is lost.
73
+ */
74
+ async persistSuspendedRun(run) {
75
+ this.suspendedRuns.set(run.runId, run);
76
+ if (this.store) {
77
+ try {
78
+ await this.store.save(run);
79
+ } catch (err) {
80
+ this.logger.warn(
81
+ `[automation] failed to persist suspended run '${run.runId}' to durable store (kept in memory only): ${err.message}`
82
+ );
83
+ }
84
+ }
85
+ }
86
+ /**
87
+ * Drop a suspended run from the in-memory cache and (best-effort) the
88
+ * durable store. Called once the run is claimed for resume or reaches a
89
+ * terminal state.
90
+ */
91
+ async forgetSuspendedRun(runId) {
92
+ this.suspendedRuns.delete(runId);
93
+ if (this.store) {
94
+ try {
95
+ await this.store.delete(runId);
96
+ } catch (err) {
97
+ this.logger.warn(
98
+ `[automation] failed to delete suspended run '${runId}' from durable store: ${err.message}`
99
+ );
100
+ }
101
+ }
38
102
  }
39
103
  // ── Plugin Extension API ──────────────────────────────
40
104
  /** Register a node executor (called by plugins) */
@@ -54,6 +118,58 @@ var AutomationEngine = class {
54
118
  }
55
119
  this.logger.info(`Node executor registered: ${executor.type}`);
56
120
  }
121
+ /**
122
+ * Register a **deprecated alias** of a canonical node type (ADR-0018 M3).
123
+ *
124
+ * The alias is a real registered executor, so old saved flows whose nodes
125
+ * use the alias type keep validating and running with no migration. At
126
+ * execute time it delegates to the canonical executor (resolved live, so the
127
+ * canonical may be registered before or after the alias), logging a one-time
128
+ * deprecation warning. Its published descriptor is flagged `deprecated` +
129
+ * `aliasOf` so the designer palette can hide or mark it while the canonical
130
+ * type is the one offered for new authoring.
131
+ *
132
+ * This is how ADR-0018 collapses the five outbound verbs onto `http` /
133
+ * `notify`: `http_request` / `http_call` / `webhook` become aliases of
134
+ * `http`.
135
+ */
136
+ registerNodeAlias(alias, canonicalType, meta) {
137
+ const engine = this;
138
+ let warned = false;
139
+ this.registerNodeExecutor({
140
+ type: alias,
141
+ descriptor: defineActionDescriptor({
142
+ type: alias,
143
+ version: "1.0.0",
144
+ name: meta?.name ?? alias,
145
+ description: `Deprecated alias of '${canonicalType}' (ADR-0018 M3). Author new flows with '${canonicalType}'.`,
146
+ category: meta?.category ?? "io",
147
+ source: "builtin",
148
+ paradigms: meta?.paradigms ?? ["flow", "workflow_rule", "approval"],
149
+ supportsRetry: true,
150
+ needsOutbox: meta?.needsOutbox ?? false,
151
+ deprecated: true,
152
+ aliasOf: canonicalType
153
+ }),
154
+ async execute(node, variables, context) {
155
+ if (!warned) {
156
+ warned = true;
157
+ engine.logger.warn(
158
+ `Node type '${alias}' is deprecated; use '${canonicalType}' (ADR-0018 M3). Existing flows keep running via the alias.`
159
+ );
160
+ }
161
+ const target = engine.nodeExecutors.get(canonicalType);
162
+ if (!target) {
163
+ return {
164
+ success: false,
165
+ error: `alias '${alias}' \u2192 '${canonicalType}': canonical executor not registered`
166
+ };
167
+ }
168
+ return target.execute(node, variables, context);
169
+ }
170
+ });
171
+ this.logger.info(`Node alias registered: ${alias} \u2192 ${canonicalType} (deprecated)`);
172
+ }
57
173
  /** Unregister a node executor (hot-unplug) */
58
174
  unregisterNodeExecutor(type) {
59
175
  const executor = this.nodeExecutors.get(type);
@@ -245,7 +361,9 @@ var AutomationEngine = class {
245
361
  registerFlow(name, definition) {
246
362
  const parsed = FlowSchema.parse(definition);
247
363
  this.detectCycles(parsed);
364
+ validateControlFlow(parsed);
248
365
  this.validateNodeTypes(name, parsed);
366
+ this.validateFlowExpressions(name, parsed);
249
367
  const history = this.flowVersionHistory.get(name) ?? [];
250
368
  history.push({
251
369
  version: parsed.version,
@@ -340,7 +458,7 @@ var AutomationEngine = class {
340
458
  if (context?.previous) {
341
459
  variables.set("previous", context.previous);
342
460
  }
343
- const runId = `run_${++this.runCounter}`;
461
+ const runId = this.nextRunId();
344
462
  variables.set("$runId", runId);
345
463
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
346
464
  const steps = [];
@@ -392,7 +510,7 @@ var AutomationEngine = class {
392
510
  } catch (err) {
393
511
  if (isSuspendSignal(err)) {
394
512
  const durationMs2 = Date.now() - startTime;
395
- this.suspendedRuns.set(runId, {
513
+ await this.persistSuspendedRun({
396
514
  runId,
397
515
  flowName,
398
516
  flowVersion: flow.version,
@@ -464,109 +582,131 @@ var AutomationEngine = class {
464
582
  * returns `{ status: 'paused', runId }` afresh.
465
583
  */
466
584
  async resume(runId, signal) {
467
- const run = this.suspendedRuns.get(runId);
468
- if (!run) {
469
- return { success: false, error: `No suspended run '${runId}'` };
470
- }
471
- const flow = this.flows.get(run.flowName);
472
- if (!flow) {
473
- return { success: false, error: `Flow '${run.flowName}' not found for run '${runId}'` };
474
- }
475
- const node = flow.nodes.find((n) => n.id === run.nodeId);
476
- if (!node) {
477
- return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
585
+ if (this.resuming.has(runId)) {
586
+ return { success: false, error: `Run '${runId}' is already being resumed` };
478
587
  }
479
- this.suspendedRuns.delete(runId);
480
- const variables = new Map(Object.entries(run.variables));
481
- if (signal?.output) {
482
- for (const [key, value] of Object.entries(signal.output)) {
483
- variables.set(`${run.nodeId}.${key}`, value);
588
+ this.resuming.add(runId);
589
+ try {
590
+ let run = this.suspendedRuns.get(runId) ?? null;
591
+ if (!run && this.store) {
592
+ try {
593
+ run = await this.store.load(runId);
594
+ } catch (err) {
595
+ this.logger.warn(
596
+ `[automation] failed to load suspended run '${runId}' from durable store: ${err.message}`
597
+ );
598
+ }
484
599
  }
485
- }
486
- if (signal?.variables) {
487
- for (const [key, value] of Object.entries(signal.variables)) {
488
- variables.set(key, value);
600
+ if (!run) {
601
+ return { success: false, error: `No suspended run '${runId}'` };
489
602
  }
490
- }
491
- const steps = run.steps;
492
- const context = run.context;
493
- try {
494
- await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
495
- const output = {};
496
- if (flow.variables) {
497
- for (const v of flow.variables) {
498
- if (v.isOutput) output[v.name] = variables.get(v.name);
603
+ const flow = this.flows.get(run.flowName);
604
+ if (!flow) {
605
+ return { success: false, error: `Flow '${run.flowName}' not found for run '${runId}'` };
606
+ }
607
+ const node = flow.nodes.find((n) => n.id === run.nodeId);
608
+ if (!node) {
609
+ return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
610
+ }
611
+ await this.forgetSuspendedRun(runId);
612
+ const variables = new Map(Object.entries(run.variables));
613
+ if (signal?.output) {
614
+ for (const [key, value] of Object.entries(signal.output)) {
615
+ variables.set(`${run.nodeId}.${key}`, value);
499
616
  }
500
617
  }
501
- const durationMs = Date.now() - run.startTime;
502
- this.recordLog({
503
- id: runId,
504
- flowName: run.flowName,
505
- flowVersion: run.flowVersion,
506
- status: "completed",
507
- startedAt: run.startedAt,
508
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
509
- durationMs,
510
- trigger: {
511
- type: context.event ?? "manual",
512
- userId: context.userId,
513
- object: context.object
514
- },
515
- steps,
516
- output
517
- });
518
- return { success: true, output, durationMs };
519
- } catch (err) {
520
- if (isSuspendSignal(err)) {
521
- const durationMs2 = Date.now() - run.startTime;
522
- this.suspendedRuns.set(runId, {
523
- ...run,
524
- nodeId: err.nodeId,
525
- variables: Object.fromEntries(variables),
618
+ if (signal?.variables) {
619
+ for (const [key, value] of Object.entries(signal.variables)) {
620
+ variables.set(key, value);
621
+ }
622
+ }
623
+ const steps = run.steps;
624
+ const context = run.context;
625
+ try {
626
+ await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
627
+ const output = {};
628
+ if (flow.variables) {
629
+ for (const v of flow.variables) {
630
+ if (v.isOutput) output[v.name] = variables.get(v.name);
631
+ }
632
+ }
633
+ const durationMs = Date.now() - run.startTime;
634
+ this.recordLog({
635
+ id: runId,
636
+ flowName: run.flowName,
637
+ flowVersion: run.flowVersion,
638
+ status: "completed",
639
+ startedAt: run.startedAt,
640
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
641
+ durationMs,
642
+ trigger: {
643
+ type: context.event ?? "manual",
644
+ userId: context.userId,
645
+ object: context.object
646
+ },
526
647
  steps,
527
- correlation: err.correlation,
528
- screen: err.screen
648
+ output
529
649
  });
650
+ return { success: true, output, durationMs };
651
+ } catch (err) {
652
+ if (isSuspendSignal(err)) {
653
+ const durationMs2 = Date.now() - run.startTime;
654
+ await this.persistSuspendedRun({
655
+ ...run,
656
+ nodeId: err.nodeId,
657
+ variables: Object.fromEntries(variables),
658
+ steps,
659
+ correlation: err.correlation,
660
+ screen: err.screen
661
+ });
662
+ this.recordLog({
663
+ id: runId,
664
+ flowName: run.flowName,
665
+ flowVersion: run.flowVersion,
666
+ status: "paused",
667
+ startedAt: run.startedAt,
668
+ durationMs: durationMs2,
669
+ trigger: {
670
+ type: context.event ?? "manual",
671
+ userId: context.userId,
672
+ object: context.object
673
+ },
674
+ steps
675
+ });
676
+ return { success: true, status: "paused", runId, durationMs: durationMs2, screen: err.screen };
677
+ }
678
+ const errorMessage = err instanceof Error ? err.message : String(err);
679
+ const durationMs = Date.now() - run.startTime;
530
680
  this.recordLog({
531
681
  id: runId,
532
682
  flowName: run.flowName,
533
683
  flowVersion: run.flowVersion,
534
- status: "paused",
684
+ status: "failed",
535
685
  startedAt: run.startedAt,
536
- durationMs: durationMs2,
686
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
687
+ durationMs,
537
688
  trigger: {
538
689
  type: context.event ?? "manual",
539
690
  userId: context.userId,
540
691
  object: context.object
541
692
  },
542
- steps
693
+ steps,
694
+ error: errorMessage
543
695
  });
544
- return { success: true, status: "paused", runId, durationMs: durationMs2, screen: err.screen };
696
+ return { success: false, error: errorMessage, durationMs };
545
697
  }
546
- const errorMessage = err instanceof Error ? err.message : String(err);
547
- const durationMs = Date.now() - run.startTime;
548
- this.recordLog({
549
- id: runId,
550
- flowName: run.flowName,
551
- flowVersion: run.flowVersion,
552
- status: "failed",
553
- startedAt: run.startedAt,
554
- completedAt: (/* @__PURE__ */ new Date()).toISOString(),
555
- durationMs,
556
- trigger: {
557
- type: context.event ?? "manual",
558
- userId: context.userId,
559
- object: context.object
560
- },
561
- steps,
562
- error: errorMessage
563
- });
564
- return { success: false, error: errorMessage, durationMs };
698
+ } finally {
699
+ this.resuming.delete(runId);
565
700
  }
566
701
  }
567
702
  /**
568
703
  * List the runs currently suspended awaiting {@link resume} (ADR-0019).
569
704
  * Backs operability surfaces such as a "pending approvals" view.
705
+ *
706
+ * Synchronous — reads the in-memory cache only, so after a process restart
707
+ * runs that suspended in a prior lifetime are not listed here even though
708
+ * they remain durably stored and resumable by id. Use
709
+ * {@link listSuspendedRunsDurable} to include those.
570
710
  */
571
711
  listSuspendedRuns() {
572
712
  return [...this.suspendedRuns.values()].map((r) => ({
@@ -576,6 +716,28 @@ var AutomationEngine = class {
576
716
  correlation: r.correlation
577
717
  }));
578
718
  }
719
+ /**
720
+ * Like {@link listSuspendedRuns} but includes runs held only in the durable
721
+ * {@link SuspendedRunStore} (e.g. suspended before a restart). The in-memory
722
+ * cache takes precedence on id collisions. Falls back to the in-memory list
723
+ * when no store is configured.
724
+ */
725
+ async listSuspendedRunsDurable() {
726
+ const byId = /* @__PURE__ */ new Map();
727
+ if (this.store) {
728
+ try {
729
+ for (const r of await this.store.list()) {
730
+ byId.set(r.runId, { runId: r.runId, flowName: r.flowName, nodeId: r.nodeId, correlation: r.correlation });
731
+ }
732
+ } catch (err) {
733
+ this.logger.warn(`[automation] failed to list suspended runs from durable store: ${err.message}`);
734
+ }
735
+ }
736
+ for (const r of this.suspendedRuns.values()) {
737
+ byId.set(r.runId, { runId: r.runId, flowName: r.flowName, nodeId: r.nodeId, correlation: r.correlation });
738
+ }
739
+ return [...byId.values()];
740
+ }
579
741
  /**
580
742
  * The screen a paused run is currently waiting on (screen-flow runtime), or
581
743
  * `null` if the run isn't suspended / didn't pause at a screen node. Lets a
@@ -614,6 +776,41 @@ var AutomationEngine = class {
614
776
  );
615
777
  }
616
778
  }
779
+ /**
780
+ * ADR-0032 §Decision 1a — parse-validate every predicate in the flow at
781
+ * registration. Predicates are bare CEL; this catches the #1491 class
782
+ * (`{record.x}` template braces in a condition → CEL parse error) and any
783
+ * other malformed predicate LOUDLY, with the offending location + source +
784
+ * a corrective hint, instead of letting it fail silently at run time.
785
+ *
786
+ * Only the *predicate* surfaces are checked here (start/node `config.condition`
787
+ * and `edge.condition`) — node string fields are templates (a different
788
+ * dialect) and are validated by the template engine, not as CEL.
789
+ */
790
+ validateFlowExpressions(flowName, flow) {
791
+ const failures = [];
792
+ const check = (where, raw) => {
793
+ if (raw == null) return;
794
+ const result = validateExpression("predicate", raw);
795
+ for (const e of result.errors) {
796
+ failures.push(` \u2022 ${where}: ${e.message}
797
+ source: \`${e.source}\``);
798
+ }
799
+ };
800
+ for (const node of flow.nodes) {
801
+ const cfg = node.config ?? {};
802
+ check(`node '${node.id}' (${node.type}) condition`, cfg.condition);
803
+ }
804
+ for (const edge of flow.edges) {
805
+ check(`edge '${edge.id}' (${edge.source}\u2192${edge.target}) condition`, edge.condition);
806
+ }
807
+ if (failures.length > 0) {
808
+ throw new Error(
809
+ `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:
810
+ ${failures.join("\n")}`
811
+ );
812
+ }
813
+ }
617
814
  /**
618
815
  * Detect cycles in the flow graph (DAG validation).
619
816
  * Uses DFS with coloring (white/gray/black) to detect back edges.
@@ -841,6 +1038,45 @@ var AutomationEngine = class {
841
1038
  await Promise.all(parallelTasks);
842
1039
  }
843
1040
  }
1041
+ /**
1042
+ * Execute a structured control-flow **region** (ADR-0031) — the nested
1043
+ * body of a `loop` container (or, later, a `parallel` branch / `try_catch`
1044
+ * region). The region is a self-contained single-entry/single-exit
1045
+ * sub-graph carried in the container's `config`; it runs in the **enclosing
1046
+ * variable scope** (the caller's `variables` map), so the iterator variable
1047
+ * and any body mutations are visible to the surrounding flow — a region is
1048
+ * NOT a separate `subflow` invocation.
1049
+ *
1050
+ * The region executes against a synthetic flow view of its own
1051
+ * nodes/edges, so the main DAG traversal (`traverseNext`) is never aware of
1052
+ * scope markers — keeping the shared traversal untouched.
1053
+ *
1054
+ * Body step logs are kept in a region-local array (not yet merged into the
1055
+ * parent run log); surfacing per-iteration steps is a follow-up.
1056
+ *
1057
+ * Durable pause (`suspend`) inside a region is not supported in this
1058
+ * iteration — it is converted into a clear error (mirrors the `subflow`
1059
+ * nested-pause guard).
1060
+ */
1061
+ async runRegion(region, variables, context) {
1062
+ const entryId = findRegionEntry(region);
1063
+ const entry = region.nodes.find((n) => n.id === entryId);
1064
+ if (!entry) {
1065
+ throw new Error(`region entry node '${entryId}' not found`);
1066
+ }
1067
+ const subFlow = { nodes: region.nodes, edges: region.edges ?? [] };
1068
+ const regionSteps = [];
1069
+ try {
1070
+ await this.executeNode(entry, subFlow, variables, context, regionSteps);
1071
+ } catch (err) {
1072
+ if (isSuspendSignal(err)) {
1073
+ throw new Error(
1074
+ `durable pause inside a structured region (node '${err.nodeId}') is not supported`
1075
+ );
1076
+ }
1077
+ throw err;
1078
+ }
1079
+ }
844
1080
  /**
845
1081
  * Execute a promise with timeout using Promise.race.
846
1082
  */
@@ -883,10 +1119,17 @@ var AutomationEngine = class {
883
1119
  { dialect: "cel", source: exprStr },
884
1120
  { extra: { ...vars, vars }, record: vars }
885
1121
  );
886
- if (!result.ok) return false;
1122
+ if (!result.ok) {
1123
+ throw new Error(
1124
+ `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.`
1125
+ );
1126
+ }
887
1127
  return Boolean(result.value);
888
- } catch {
889
- return false;
1128
+ } catch (err) {
1129
+ const msg = err?.message ?? String(err);
1130
+ throw new Error(
1131
+ msg.includes("source:") ? msg : `condition evaluation error: ${msg} \u2014 source: \`${exprStr}\``
1132
+ );
890
1133
  }
891
1134
  }
892
1135
  let resolved = exprStr;
@@ -1005,7 +1248,7 @@ var AutomationEngine = class {
1005
1248
  if (context?.record) {
1006
1249
  variables.set("$record", context.record);
1007
1250
  }
1008
- const runId = `run_${++this.runCounter}`;
1251
+ const runId = this.nextRunId();
1009
1252
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1010
1253
  const steps = [];
1011
1254
  try {
@@ -1064,12 +1307,249 @@ var AutomationEngine = class {
1064
1307
  }
1065
1308
  };
1066
1309
 
1310
+ // src/suspended-run-store.ts
1311
+ var TABLE = "sys_automation_run";
1312
+ var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
1313
+ function jsonClone(value) {
1314
+ return JSON.parse(JSON.stringify(value));
1315
+ }
1316
+ function parseJson(raw, fallback) {
1317
+ if (raw == null || raw === "") return fallback;
1318
+ if (typeof raw === "string") {
1319
+ try {
1320
+ return JSON.parse(raw);
1321
+ } catch {
1322
+ return fallback;
1323
+ }
1324
+ }
1325
+ return raw;
1326
+ }
1327
+ var InMemorySuspendedRunStore = class {
1328
+ constructor() {
1329
+ this.runs = /* @__PURE__ */ new Map();
1330
+ }
1331
+ async save(run) {
1332
+ this.runs.set(run.runId, jsonClone(run));
1333
+ }
1334
+ async load(runId) {
1335
+ const run = this.runs.get(runId);
1336
+ return run ? jsonClone(run) : null;
1337
+ }
1338
+ async delete(runId) {
1339
+ this.runs.delete(runId);
1340
+ }
1341
+ async list() {
1342
+ return [...this.runs.values()].map(jsonClone);
1343
+ }
1344
+ };
1345
+ var ObjectStoreSuspendedRunStore = class {
1346
+ constructor(engine, logger) {
1347
+ this.engine = engine;
1348
+ this.logger = logger;
1349
+ }
1350
+ async save(run) {
1351
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1352
+ const row = this.serialize(run);
1353
+ const existing = await this.engine.find(TABLE, {
1354
+ where: { id: run.runId },
1355
+ limit: 1,
1356
+ context: SYSTEM_CTX
1357
+ });
1358
+ if (Array.isArray(existing) && existing[0]) {
1359
+ await this.engine.update(
1360
+ TABLE,
1361
+ { ...row, updated_at: now },
1362
+ { where: { id: run.runId }, context: SYSTEM_CTX }
1363
+ );
1364
+ } else {
1365
+ await this.engine.insert(
1366
+ TABLE,
1367
+ { ...row, created_at: now, updated_at: now },
1368
+ { context: SYSTEM_CTX }
1369
+ );
1370
+ }
1371
+ }
1372
+ async load(runId) {
1373
+ const rows = await this.engine.find(TABLE, {
1374
+ where: { id: runId },
1375
+ limit: 1,
1376
+ context: SYSTEM_CTX
1377
+ });
1378
+ const row = Array.isArray(rows) ? rows[0] : null;
1379
+ return row ? this.deserialize(row) : null;
1380
+ }
1381
+ async delete(runId) {
1382
+ if (typeof this.engine.delete !== "function") {
1383
+ this.logger?.warn?.(
1384
+ `[automation] ObjectStoreSuspendedRunStore: engine has no delete(); suspended run '${runId}' row not removed`
1385
+ );
1386
+ return;
1387
+ }
1388
+ await this.engine.delete(TABLE, { where: { id: runId }, context: SYSTEM_CTX });
1389
+ }
1390
+ async list() {
1391
+ const rows = await this.engine.find(TABLE, {
1392
+ where: { status: "paused" },
1393
+ limit: 1e3,
1394
+ context: SYSTEM_CTX
1395
+ });
1396
+ return (Array.isArray(rows) ? rows : []).map((r) => this.deserialize(r));
1397
+ }
1398
+ /** Flatten a run into a `sys_automation_run` row (state columns JSON-encoded). */
1399
+ serialize(run) {
1400
+ const ctx = run.context ?? {};
1401
+ const org = ctx.organizationId ?? ctx.tenantId ?? null;
1402
+ return {
1403
+ id: run.runId,
1404
+ organization_id: org,
1405
+ flow_name: run.flowName,
1406
+ flow_version: run.flowVersion ?? null,
1407
+ node_id: run.nodeId,
1408
+ status: "paused",
1409
+ correlation: run.correlation ?? null,
1410
+ user_id: ctx.userId ?? null,
1411
+ variables_json: JSON.stringify(run.variables ?? {}),
1412
+ steps_json: JSON.stringify(run.steps ?? []),
1413
+ context_json: JSON.stringify(run.context ?? {}),
1414
+ screen_json: run.screen ? JSON.stringify(run.screen) : null,
1415
+ started_at: run.startedAt,
1416
+ start_time: run.startTime ?? null
1417
+ };
1418
+ }
1419
+ /** Rebuild a run from a `sys_automation_run` row. */
1420
+ deserialize(row) {
1421
+ const startedAt = row.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
1422
+ return {
1423
+ runId: String(row.id),
1424
+ flowName: String(row.flow_name ?? ""),
1425
+ flowVersion: row.flow_version ?? void 0,
1426
+ nodeId: String(row.node_id ?? ""),
1427
+ variables: parseJson(row.variables_json, {}),
1428
+ steps: parseJson(row.steps_json, []),
1429
+ context: parseJson(row.context_json, {}),
1430
+ startedAt,
1431
+ startTime: typeof row.start_time === "number" ? row.start_time : Date.parse(startedAt) || Date.now(),
1432
+ correlation: row.correlation ?? void 0,
1433
+ screen: parseJson(row.screen_json, void 0)
1434
+ };
1435
+ }
1436
+ };
1437
+
1438
+ // src/sys-automation-run.object.ts
1439
+ import { ObjectSchema, Field } from "@objectstack/spec/data";
1440
+ var SysAutomationRun = ObjectSchema.create({
1441
+ name: "sys_automation_run",
1442
+ label: "Automation Run",
1443
+ pluralLabel: "Automation Runs",
1444
+ icon: "pause-circle",
1445
+ isSystem: true,
1446
+ managedBy: "system",
1447
+ description: "Durable state of a suspended automation flow run (ADR-0019)",
1448
+ displayNameField: "id",
1449
+ titleFormat: "{flow_name} \xB7 {node_id}",
1450
+ compactLayout: ["flow_name", "node_id", "status", "correlation", "started_at", "updated_at"],
1451
+ fields: {
1452
+ id: Field.text({ label: "Run ID", required: true, readonly: true, group: "System" }),
1453
+ organization_id: Field.lookup("sys_organization", {
1454
+ label: "Organization",
1455
+ required: false,
1456
+ group: "System",
1457
+ description: "Tenant that owns this run (propagated from the trigger context)"
1458
+ }),
1459
+ flow_name: Field.text({
1460
+ label: "Flow",
1461
+ required: true,
1462
+ maxLength: 255,
1463
+ searchable: true,
1464
+ group: "Identity"
1465
+ }),
1466
+ flow_version: Field.number({ label: "Flow Version", required: false, group: "Identity" }),
1467
+ node_id: Field.text({
1468
+ label: "Paused Node",
1469
+ required: true,
1470
+ maxLength: 255,
1471
+ description: "Node the run is suspended at; resume continues from its out-edges.",
1472
+ group: "State"
1473
+ }),
1474
+ status: Field.select(
1475
+ ["paused"],
1476
+ {
1477
+ label: "Status",
1478
+ required: true,
1479
+ defaultValue: "paused",
1480
+ description: "Only suspended runs are persisted; the row is deleted on terminal completion.",
1481
+ group: "State"
1482
+ }
1483
+ ),
1484
+ correlation: Field.text({
1485
+ label: "Correlation",
1486
+ required: false,
1487
+ maxLength: 255,
1488
+ description: "Correlation key from the pausing node (e.g. approval request id).",
1489
+ group: "State"
1490
+ }),
1491
+ user_id: Field.text({
1492
+ label: "User",
1493
+ required: false,
1494
+ maxLength: 255,
1495
+ description: "User who triggered the run (from context.userId).",
1496
+ group: "State"
1497
+ }),
1498
+ variables_json: Field.textarea({
1499
+ label: "Variables",
1500
+ required: false,
1501
+ description: "JSON snapshot of the flow variable map at suspend time.",
1502
+ group: "State"
1503
+ }),
1504
+ steps_json: Field.textarea({
1505
+ label: "Steps",
1506
+ required: false,
1507
+ description: "JSON snapshot of the executed step logs so far.",
1508
+ group: "State"
1509
+ }),
1510
+ context_json: Field.textarea({
1511
+ label: "Context",
1512
+ required: false,
1513
+ description: "JSON snapshot of the trigger / automation context.",
1514
+ group: "State"
1515
+ }),
1516
+ screen_json: Field.textarea({
1517
+ label: "Screen",
1518
+ required: false,
1519
+ description: "JSON snapshot of the screen spec the run is waiting on (screen-flow runtime).",
1520
+ group: "State"
1521
+ }),
1522
+ started_at: Field.datetime({ label: "Started At", required: true, group: "State" }),
1523
+ start_time: Field.number({
1524
+ label: "Start Time (epoch ms)",
1525
+ required: false,
1526
+ description: "Epoch ms when the run started; used to compute duration on resume.",
1527
+ group: "State"
1528
+ }),
1529
+ created_at: Field.datetime({
1530
+ label: "Created At",
1531
+ required: true,
1532
+ defaultValue: "NOW()",
1533
+ readonly: true,
1534
+ group: "System"
1535
+ }),
1536
+ updated_at: Field.datetime({ label: "Updated At", required: false, group: "System" })
1537
+ },
1538
+ indexes: [
1539
+ // "Which runs are suspended for this flow?" — operability / resume sweeps.
1540
+ { fields: ["flow_name", "status"] },
1541
+ { fields: ["status", "updated_at"] },
1542
+ // Look up a suspended run by the pausing node's correlation key.
1543
+ { fields: ["correlation"] }
1544
+ ]
1545
+ });
1546
+
1067
1547
  // src/builtin/logic-nodes.ts
1068
- import { defineActionDescriptor } from "@objectstack/spec/automation";
1548
+ import { defineActionDescriptor as defineActionDescriptor2 } from "@objectstack/spec/automation";
1069
1549
  function registerLogicNodes(engine, ctx) {
1070
1550
  engine.registerNodeExecutor({
1071
1551
  type: "decision",
1072
- descriptor: defineActionDescriptor({
1552
+ descriptor: defineActionDescriptor2({
1073
1553
  type: "decision",
1074
1554
  version: "1.0.0",
1075
1555
  name: "Decision",
@@ -1091,7 +1571,7 @@ function registerLogicNodes(engine, ctx) {
1091
1571
  });
1092
1572
  engine.registerNodeExecutor({
1093
1573
  type: "assignment",
1094
- descriptor: defineActionDescriptor({
1574
+ descriptor: defineActionDescriptor2({
1095
1575
  type: "assignment",
1096
1576
  version: "1.0.0",
1097
1577
  name: "Assignment",
@@ -1108,35 +1588,11 @@ function registerLogicNodes(engine, ctx) {
1108
1588
  return { success: true };
1109
1589
  }
1110
1590
  });
1111
- engine.registerNodeExecutor({
1112
- type: "loop",
1113
- descriptor: defineActionDescriptor({
1114
- type: "loop",
1115
- version: "1.0.0",
1116
- name: "Loop",
1117
- description: "Iterate over a collection.",
1118
- icon: "repeat",
1119
- category: "logic",
1120
- source: "builtin"
1121
- }),
1122
- async execute(node, variables, _context) {
1123
- const config = node.config;
1124
- const collectionName = config?.collection;
1125
- if (collectionName) {
1126
- const collection = variables.get(collectionName);
1127
- if (Array.isArray(collection)) {
1128
- variables.set("$loopItems", collection);
1129
- variables.set("$loopIndex", 0);
1130
- }
1131
- }
1132
- return { success: true };
1133
- }
1134
- });
1135
- ctx.logger.info("[Logic Nodes] 3 built-in node executors registered");
1591
+ ctx.logger.info("[Logic Nodes] 2 built-in node executors registered");
1136
1592
  }
1137
1593
 
1138
- // src/builtin/crud-nodes.ts
1139
- import { defineActionDescriptor as defineActionDescriptor2 } from "@objectstack/spec/automation";
1594
+ // src/builtin/loop-node.ts
1595
+ import { defineActionDescriptor as defineActionDescriptor3, LOOP_MAX_ITERATIONS_CEILING } from "@objectstack/spec/automation";
1140
1596
 
1141
1597
  // src/builtin/template.ts
1142
1598
  function resolvePath(base, path) {
@@ -1235,7 +1691,232 @@ function interpolate(value, variables, context) {
1235
1691
  return value;
1236
1692
  }
1237
1693
 
1694
+ // src/builtin/loop-node.ts
1695
+ function registerLoopNode(engine, ctx) {
1696
+ engine.registerNodeExecutor({
1697
+ type: "loop",
1698
+ descriptor: defineActionDescriptor3({
1699
+ type: "loop",
1700
+ version: "2.0.0",
1701
+ name: "Loop",
1702
+ description: "Iterate a body region over a collection (bounded, structured container).",
1703
+ icon: "repeat",
1704
+ category: "logic",
1705
+ source: "builtin",
1706
+ configSchema: {
1707
+ type: "object",
1708
+ properties: {
1709
+ collection: { type: "string", description: "Template/variable resolving to the array to iterate" },
1710
+ iteratorVariable: { type: "string", description: "Loop variable holding the current item" },
1711
+ indexVariable: { type: "string", description: "Optional loop variable holding the current index" },
1712
+ maxIterations: { type: "integer", minimum: 1, maximum: LOOP_MAX_ITERATIONS_CEILING },
1713
+ body: {
1714
+ type: "object",
1715
+ description: "Loop body region (single-entry/single-exit sub-graph)",
1716
+ properties: { nodes: { type: "array" }, edges: { type: "array" } }
1717
+ }
1718
+ },
1719
+ required: ["collection"]
1720
+ }
1721
+ }),
1722
+ async execute(node, variables, context) {
1723
+ const cfg = node.config ?? {};
1724
+ const body = cfg.body;
1725
+ if (body == null) {
1726
+ const collectionName = typeof cfg.collection === "string" ? cfg.collection : void 0;
1727
+ if (collectionName) {
1728
+ const legacy = variables.get(collectionName);
1729
+ if (Array.isArray(legacy)) {
1730
+ variables.set("$loopItems", legacy);
1731
+ variables.set("$loopIndex", 0);
1732
+ }
1733
+ }
1734
+ return { success: true };
1735
+ }
1736
+ const iteratorVariable = typeof cfg.iteratorVariable === "string" && cfg.iteratorVariable ? cfg.iteratorVariable : "item";
1737
+ const indexVariable = typeof cfg.indexVariable === "string" && cfg.indexVariable ? cfg.indexVariable : void 0;
1738
+ const rawCollection = cfg.collection;
1739
+ let collection;
1740
+ if (Array.isArray(rawCollection)) {
1741
+ collection = rawCollection;
1742
+ } else if (typeof rawCollection === "string") {
1743
+ collection = interpolate(rawCollection, variables, context ?? {});
1744
+ if (collection == null && variables.has(rawCollection)) {
1745
+ collection = variables.get(rawCollection);
1746
+ }
1747
+ }
1748
+ if (!Array.isArray(collection)) {
1749
+ return {
1750
+ success: false,
1751
+ error: `loop '${node.id}': collection '${String(rawCollection)}' did not resolve to an array`
1752
+ };
1753
+ }
1754
+ const requested = typeof cfg.maxIterations === "number" ? cfg.maxIterations : LOOP_MAX_ITERATIONS_CEILING;
1755
+ const maxIterations = Math.min(requested, LOOP_MAX_ITERATIONS_CEILING);
1756
+ if (collection.length > maxIterations) {
1757
+ return {
1758
+ success: false,
1759
+ error: `loop '${node.id}': collection length ${collection.length} exceeds maxIterations ${maxIterations}`
1760
+ };
1761
+ }
1762
+ let iterations = 0;
1763
+ for (let i = 0; i < collection.length; i++) {
1764
+ variables.set(iteratorVariable, collection[i]);
1765
+ if (indexVariable) variables.set(indexVariable, i);
1766
+ await engine.runRegion(body, variables, context ?? {});
1767
+ iterations++;
1768
+ }
1769
+ return { success: true, output: { iterations } };
1770
+ }
1771
+ });
1772
+ ctx.logger.info("[Loop Node] 1 built-in node executor registered");
1773
+ }
1774
+
1775
+ // src/builtin/parallel-node.ts
1776
+ import { defineActionDescriptor as defineActionDescriptor4 } from "@objectstack/spec/automation";
1777
+ function registerParallelNode(engine, ctx) {
1778
+ engine.registerNodeExecutor({
1779
+ type: "parallel",
1780
+ descriptor: defineActionDescriptor4({
1781
+ type: "parallel",
1782
+ version: "1.0.0",
1783
+ name: "Parallel",
1784
+ description: "Run N branch regions concurrently and join implicitly when all complete.",
1785
+ icon: "git-fork",
1786
+ category: "logic",
1787
+ source: "builtin",
1788
+ configSchema: {
1789
+ type: "object",
1790
+ properties: {
1791
+ branches: {
1792
+ type: "array",
1793
+ minItems: 2,
1794
+ description: "Branch regions executed concurrently; implicit join at block end",
1795
+ items: {
1796
+ type: "object",
1797
+ properties: {
1798
+ name: { type: "string" },
1799
+ nodes: { type: "array" },
1800
+ edges: { type: "array" }
1801
+ }
1802
+ }
1803
+ }
1804
+ },
1805
+ required: ["branches"]
1806
+ }
1807
+ }),
1808
+ async execute(node, variables, context) {
1809
+ const cfg = node.config ?? {};
1810
+ const branches = cfg.branches;
1811
+ if (!Array.isArray(branches) || branches.length < 2) {
1812
+ return {
1813
+ success: false,
1814
+ error: `parallel '${node.id}': config.branches must declare at least 2 branch regions`
1815
+ };
1816
+ }
1817
+ try {
1818
+ await Promise.all(
1819
+ branches.map((branch) => engine.runRegion(branch, variables, context ?? {}))
1820
+ );
1821
+ } catch (err) {
1822
+ const message = err instanceof Error ? err.message : String(err);
1823
+ return { success: false, error: `parallel '${node.id}': branch failed \u2014 ${message}` };
1824
+ }
1825
+ return { success: true, output: { branches: branches.length } };
1826
+ }
1827
+ });
1828
+ ctx.logger.info("[Parallel Node] 1 built-in node executor registered");
1829
+ }
1830
+
1831
+ // src/builtin/try-catch-node.ts
1832
+ import { defineActionDescriptor as defineActionDescriptor5 } from "@objectstack/spec/automation";
1833
+ function registerTryCatchNode(engine, ctx) {
1834
+ engine.registerNodeExecutor({
1835
+ type: "try_catch",
1836
+ descriptor: defineActionDescriptor5({
1837
+ type: "try_catch",
1838
+ version: "1.0.0",
1839
+ name: "Try / Catch",
1840
+ description: "Run a protected region with optional retry and a catch handler (structured error handling).",
1841
+ icon: "shield-alert",
1842
+ category: "logic",
1843
+ source: "builtin",
1844
+ supportsRetry: true,
1845
+ configSchema: {
1846
+ type: "object",
1847
+ properties: {
1848
+ try: {
1849
+ type: "object",
1850
+ description: "Protected region (single-entry/single-exit sub-graph)",
1851
+ properties: { nodes: { type: "array" }, edges: { type: "array" } }
1852
+ },
1853
+ catch: {
1854
+ type: "object",
1855
+ description: "Handler region run when the try region fails",
1856
+ properties: { nodes: { type: "array" }, edges: { type: "array" } }
1857
+ },
1858
+ errorVariable: { type: "string", description: "Variable holding the caught error in the catch region" },
1859
+ retry: {
1860
+ type: "object",
1861
+ properties: {
1862
+ maxRetries: { type: "integer", minimum: 0, maximum: 10 },
1863
+ retryDelayMs: { type: "integer", minimum: 0 },
1864
+ backoffMultiplier: { type: "number", minimum: 1 },
1865
+ maxRetryDelayMs: { type: "integer", minimum: 0 },
1866
+ jitter: { type: "boolean" }
1867
+ }
1868
+ }
1869
+ },
1870
+ required: ["try"]
1871
+ }
1872
+ }),
1873
+ async execute(node, variables, context) {
1874
+ const cfg = node.config ?? {};
1875
+ const tryRegion = cfg.try;
1876
+ const catchRegion = cfg.catch;
1877
+ const errorVariable = typeof cfg.errorVariable === "string" && cfg.errorVariable ? cfg.errorVariable : "$error";
1878
+ const retry = cfg.retry ?? {};
1879
+ if (tryRegion == null) {
1880
+ return { success: false, error: `try_catch '${node.id}': config.try region is required` };
1881
+ }
1882
+ const ctxOrEmpty = context ?? {};
1883
+ const maxRetries = typeof retry.maxRetries === "number" ? retry.maxRetries : 0;
1884
+ const baseDelay = typeof retry.retryDelayMs === "number" ? retry.retryDelayMs : 0;
1885
+ const multiplier = typeof retry.backoffMultiplier === "number" ? retry.backoffMultiplier : 1;
1886
+ const maxDelay = typeof retry.maxRetryDelayMs === "number" ? retry.maxRetryDelayMs : 3e4;
1887
+ const useJitter = retry.jitter === true;
1888
+ let lastError = "unknown error";
1889
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1890
+ if (attempt > 0) {
1891
+ let delay = Math.min(baseDelay * Math.pow(multiplier, attempt - 1), maxDelay);
1892
+ if (useJitter) delay = delay * (0.5 + Math.random() * 0.5);
1893
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay));
1894
+ }
1895
+ try {
1896
+ await engine.runRegion(tryRegion, variables, ctxOrEmpty);
1897
+ return { success: true, output: { attempts: attempt + 1, caught: false } };
1898
+ } catch (err) {
1899
+ lastError = err instanceof Error ? err.message : String(err);
1900
+ }
1901
+ }
1902
+ if (catchRegion != null) {
1903
+ variables.set(errorVariable, { nodeId: node.id, message: lastError });
1904
+ try {
1905
+ await engine.runRegion(catchRegion, variables, ctxOrEmpty);
1906
+ return { success: true, output: { attempts: maxRetries + 1, caught: true, error: lastError } };
1907
+ } catch (catchErr) {
1908
+ const catchMsg = catchErr instanceof Error ? catchErr.message : String(catchErr);
1909
+ return { success: false, error: `try_catch '${node.id}': catch region failed \u2014 ${catchMsg}` };
1910
+ }
1911
+ }
1912
+ return { success: false, error: `try_catch '${node.id}': try region failed \u2014 ${lastError}` };
1913
+ }
1914
+ });
1915
+ ctx.logger.info("[TryCatch Node] 1 built-in node executor registered");
1916
+ }
1917
+
1238
1918
  // src/builtin/crud-nodes.ts
1919
+ import { defineActionDescriptor as defineActionDescriptor6 } from "@objectstack/spec/automation";
1239
1920
  function registerCrudNodes(engine, ctx) {
1240
1921
  const getData = () => {
1241
1922
  try {
@@ -1246,7 +1927,7 @@ function registerCrudNodes(engine, ctx) {
1246
1927
  };
1247
1928
  engine.registerNodeExecutor({
1248
1929
  type: "get_record",
1249
- descriptor: defineActionDescriptor2({
1930
+ descriptor: defineActionDescriptor6({
1250
1931
  type: "get_record",
1251
1932
  version: "1.0.0",
1252
1933
  name: "Get Records",
@@ -1284,7 +1965,7 @@ function registerCrudNodes(engine, ctx) {
1284
1965
  });
1285
1966
  engine.registerNodeExecutor({
1286
1967
  type: "create_record",
1287
- descriptor: defineActionDescriptor2({
1968
+ descriptor: defineActionDescriptor6({
1288
1969
  type: "create_record",
1289
1970
  version: "1.0.0",
1290
1971
  name: "Create Record",
@@ -1317,7 +1998,7 @@ function registerCrudNodes(engine, ctx) {
1317
1998
  });
1318
1999
  engine.registerNodeExecutor({
1319
2000
  type: "update_record",
1320
- descriptor: defineActionDescriptor2({
2001
+ descriptor: defineActionDescriptor6({
1321
2002
  type: "update_record",
1322
2003
  version: "1.0.0",
1323
2004
  name: "Update Records",
@@ -1347,7 +2028,7 @@ function registerCrudNodes(engine, ctx) {
1347
2028
  });
1348
2029
  engine.registerNodeExecutor({
1349
2030
  type: "delete_record",
1350
- descriptor: defineActionDescriptor2({
2031
+ descriptor: defineActionDescriptor6({
1351
2032
  type: "delete_record",
1352
2033
  version: "1.0.0",
1353
2034
  name: "Delete Records",
@@ -1375,11 +2056,11 @@ function registerCrudNodes(engine, ctx) {
1375
2056
  }
1376
2057
 
1377
2058
  // src/builtin/screen-nodes.ts
1378
- import { defineActionDescriptor as defineActionDescriptor3 } from "@objectstack/spec/automation";
2059
+ import { defineActionDescriptor as defineActionDescriptor7 } from "@objectstack/spec/automation";
1379
2060
  function registerScreenNodes(engine, ctx) {
1380
2061
  engine.registerNodeExecutor({
1381
2062
  type: "screen",
1382
- descriptor: defineActionDescriptor3({
2063
+ descriptor: defineActionDescriptor7({
1383
2064
  type: "screen",
1384
2065
  version: "1.0.0",
1385
2066
  name: "Screen",
@@ -1422,7 +2103,7 @@ function registerScreenNodes(engine, ctx) {
1422
2103
  });
1423
2104
  engine.registerNodeExecutor({
1424
2105
  type: "script",
1425
- descriptor: defineActionDescriptor3({
2106
+ descriptor: defineActionDescriptor7({
1426
2107
  type: "script",
1427
2108
  version: "1.0.0",
1428
2109
  name: "Script",
@@ -1455,55 +2136,133 @@ function registerScreenNodes(engine, ctx) {
1455
2136
  }
1456
2137
 
1457
2138
  // src/builtin/http-nodes.ts
1458
- import { defineActionDescriptor as defineActionDescriptor4 } from "@objectstack/spec/automation";
2139
+ import { defineActionDescriptor as defineActionDescriptor8 } from "@objectstack/spec/automation";
2140
+ import { randomUUID } from "crypto";
2141
+ var HTTP_TYPE = "http";
1459
2142
  function registerHttpNodes(engine, ctx) {
2143
+ const getMessaging = () => {
2144
+ try {
2145
+ return ctx.getService("messaging");
2146
+ } catch {
2147
+ return void 0;
2148
+ }
2149
+ };
1460
2150
  engine.registerNodeExecutor({
1461
- type: "http_request",
1462
- descriptor: defineActionDescriptor4({
1463
- type: "http_request",
2151
+ type: HTTP_TYPE,
2152
+ descriptor: defineActionDescriptor8({
2153
+ type: HTTP_TYPE,
1464
2154
  version: "1.0.0",
1465
- name: "HTTP Request",
1466
- description: "Call an external HTTP endpoint. (ADR-0018: migrates to outbox-backed `http`.)",
2155
+ name: "HTTP",
2156
+ 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.",
1467
2157
  icon: "globe",
1468
2158
  category: "io",
1469
2159
  source: "builtin",
1470
- // ADR-0018 §M3 target: route via service-messaging outbox for
1471
- // retry/idempotency/dead-letter. Today this is a bare fetch().
1472
- needsOutbox: false,
2160
+ // Capable of outbox-backed durable delivery (used when durable:true
2161
+ // and the messaging HTTP outbox is wired).
2162
+ needsOutbox: true,
1473
2163
  supportsRetry: true,
1474
- paradigms: ["flow", "workflow_rule", "approval"]
2164
+ paradigms: ["flow", "workflow_rule", "approval"],
2165
+ configSchema: {
2166
+ type: "object",
2167
+ required: ["url"],
2168
+ properties: {
2169
+ url: { type: "string", description: "Target URL" },
2170
+ method: { type: "string", description: "HTTP method (default GET; POST when durable)" },
2171
+ headers: { type: "object", description: "Request headers" },
2172
+ body: { description: "Request body (JSON-serialised)" },
2173
+ durable: {
2174
+ type: "boolean",
2175
+ description: "Fire-and-forget via the durable outbox (retry/dead-letter) instead of inline request/response"
2176
+ },
2177
+ timeoutMs: { type: "number", description: "Per-request timeout (ms)" },
2178
+ signingSecret: { type: "string", description: "HMAC-SHA256 secret \u2192 X-Objectstack-Signature" }
2179
+ }
2180
+ }
1475
2181
  }),
1476
- async execute(node, _variables, _context) {
1477
- const config = node.config;
1478
- const url = config?.url;
1479
- const method = config?.method ?? "GET";
1480
- const headers = config?.headers;
1481
- const body = config?.body;
1482
- if (!url) {
1483
- return { success: false, error: "http_request: url is required" };
1484
- }
1485
- const response = await fetch(url, {
1486
- method,
1487
- headers,
1488
- body: body ? JSON.stringify(body) : void 0
1489
- });
1490
- const data = await response.json();
1491
- return {
1492
- success: response.ok,
1493
- output: { response: data, status: response.status },
1494
- error: response.ok ? void 0 : `HTTP ${response.status}`
1495
- };
2182
+ async execute(node, variables, context) {
2183
+ const raw = node.config ?? {};
2184
+ const cfg = interpolate(raw, variables, context);
2185
+ const url = cfg.url;
2186
+ if (!url) return { success: false, error: "http: url is required" };
2187
+ const durable = cfg.durable === true;
2188
+ const headers = cfg.headers;
2189
+ const body = cfg.body;
2190
+ const timeoutMs = typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : void 0;
2191
+ const signingSecret = cfg.signingSecret;
2192
+ if (durable) {
2193
+ const messaging = getMessaging();
2194
+ if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
2195
+ try {
2196
+ const deliveryId = await messaging.enqueueHttp({
2197
+ source: "flow",
2198
+ refId: node.id,
2199
+ dedupKey: randomUUID(),
2200
+ label: `flow:${node.id}`,
2201
+ url,
2202
+ method: cfg.method ?? "POST",
2203
+ headers,
2204
+ signingSecret,
2205
+ timeoutMs,
2206
+ payload: body ?? {}
2207
+ });
2208
+ return { success: true, output: { deliveryId, enqueued: true } };
2209
+ } catch (err) {
2210
+ return { success: false, error: `http (durable) failed to enqueue: ${err.message}` };
2211
+ }
2212
+ }
2213
+ ctx.logger.warn(
2214
+ `[http] node '${node.id}' requested durable delivery but no messaging HTTP outbox is wired; falling back to inline fetch`
2215
+ );
2216
+ }
2217
+ const method = cfg.method ?? "GET";
2218
+ const controller = new AbortController();
2219
+ const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
2220
+ try {
2221
+ const response = await fetch(url, {
2222
+ method,
2223
+ headers,
2224
+ body: body !== void 0 && body !== null ? JSON.stringify(body) : void 0,
2225
+ signal: controller.signal
2226
+ });
2227
+ const data = await readBody(response);
2228
+ return {
2229
+ success: response.ok,
2230
+ output: { response: data, status: response.status },
2231
+ error: response.ok ? void 0 : `HTTP ${response.status}`
2232
+ };
2233
+ } catch (err) {
2234
+ const e = err;
2235
+ const msg = e?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e?.message ?? String(err);
2236
+ return { success: false, error: `http: ${msg}` };
2237
+ } finally {
2238
+ if (timer) clearTimeout(timer);
2239
+ }
1496
2240
  }
1497
2241
  });
1498
- ctx.logger.info("[HTTP] 1 built-in node executor registered (http_request)");
2242
+ engine.registerNodeAlias("http_request", HTTP_TYPE, { name: "HTTP Request", needsOutbox: true });
2243
+ engine.registerNodeAlias("http_call", HTTP_TYPE, { name: "HTTP Call", needsOutbox: true });
2244
+ engine.registerNodeAlias("webhook", HTTP_TYPE, { name: "Webhook", needsOutbox: true });
2245
+ ctx.logger.info("[HTTP] http executor registered (+ deprecated aliases: http_request, http_call, webhook)");
2246
+ }
2247
+ async function readBody(response) {
2248
+ try {
2249
+ return await response.json();
2250
+ } catch {
2251
+ try {
2252
+ const text = await response.text();
2253
+ return text || null;
2254
+ } catch {
2255
+ return null;
2256
+ }
2257
+ }
1499
2258
  }
1500
2259
 
1501
2260
  // src/builtin/connector-nodes.ts
1502
- import { defineActionDescriptor as defineActionDescriptor5 } from "@objectstack/spec/automation";
2261
+ import { defineActionDescriptor as defineActionDescriptor9 } from "@objectstack/spec/automation";
1503
2262
  function registerConnectorNodes(engine, ctx) {
1504
2263
  engine.registerNodeExecutor({
1505
2264
  type: "connector_action",
1506
- descriptor: defineActionDescriptor5({
2265
+ descriptor: defineActionDescriptor9({
1507
2266
  type: "connector_action",
1508
2267
  version: "1.0.0",
1509
2268
  name: "Connector Action",
@@ -1560,7 +2319,7 @@ function registerConnectorNodes(engine, ctx) {
1560
2319
  }
1561
2320
 
1562
2321
  // src/builtin/notify-node.ts
1563
- import { defineActionDescriptor as defineActionDescriptor6 } from "@objectstack/spec/automation";
2322
+ import { defineActionDescriptor as defineActionDescriptor10 } from "@objectstack/spec/automation";
1564
2323
  function toStringList(value) {
1565
2324
  if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
1566
2325
  if (typeof value === "string" && value.trim()) return [value.trim()];
@@ -1576,7 +2335,7 @@ function registerNotifyNode(engine, ctx) {
1576
2335
  };
1577
2336
  engine.registerNodeExecutor({
1578
2337
  type: "notify",
1579
- descriptor: defineActionDescriptor6({
2338
+ descriptor: defineActionDescriptor10({
1580
2339
  type: "notify",
1581
2340
  version: "1.0.0",
1582
2341
  name: "Notify",
@@ -1585,6 +2344,9 @@ function registerNotifyNode(engine, ctx) {
1585
2344
  category: "io",
1586
2345
  source: "builtin",
1587
2346
  supportsRetry: true,
2347
+ // Delivery is outbox-backed inside the messaging service (ADR-0030
2348
+ // emit → sys_notification_delivery), so it inherits retry/dead-letter.
2349
+ needsOutbox: true,
1588
2350
  paradigms: ["flow", "workflow_rule", "approval"]
1589
2351
  }),
1590
2352
  async execute(node, variables, context) {
@@ -1635,14 +2397,155 @@ function registerNotifyNode(engine, ctx) {
1635
2397
  ctx.logger.info("[Notify] 1 built-in node executor registered (notify)");
1636
2398
  }
1637
2399
 
2400
+ // src/builtin/wait-node.ts
2401
+ import { defineActionDescriptor as defineActionDescriptor11 } from "@objectstack/spec/automation";
2402
+ function registerWaitNode(engine, ctx) {
2403
+ const getJobService = () => {
2404
+ try {
2405
+ return ctx.getService("job");
2406
+ } catch {
2407
+ return void 0;
2408
+ }
2409
+ };
2410
+ engine.registerNodeExecutor({
2411
+ type: "wait",
2412
+ descriptor: defineActionDescriptor11({
2413
+ type: "wait",
2414
+ version: "1.0.0",
2415
+ name: "Wait",
2416
+ description: "Pause the flow until a timer elapses or a named signal arrives.",
2417
+ icon: "timer-reset",
2418
+ category: "logic",
2419
+ source: "builtin",
2420
+ // Durable pause — the run suspends and resumes later (timer/signal).
2421
+ supportsPause: true,
2422
+ isAsync: true
2423
+ }),
2424
+ async execute(node, variables, _context) {
2425
+ const loose = node.config ?? {};
2426
+ const wec = node.waitEventConfig ?? {};
2427
+ const eventType = String(wec.eventType ?? loose.eventType ?? "timer");
2428
+ const runId = variables.get("$runId");
2429
+ if (eventType === "timer") {
2430
+ const durationMs = parseIsoDuration(wec.timerDuration ?? loose.timerDuration ?? loose.duration) ?? (typeof wec.timeoutMs === "number" ? wec.timeoutMs : void 0) ?? (typeof loose.timeoutMs === "number" ? loose.timeoutMs : void 0);
2431
+ const job = getJobService();
2432
+ if (job && runId != null && durationMs && durationMs > 0) {
2433
+ const jobName = `flow-wait:${String(runId)}:${node.id}`;
2434
+ const at = new Date(Date.now() + durationMs).toISOString();
2435
+ try {
2436
+ await job.schedule(jobName, { type: "once", at }, async () => {
2437
+ try {
2438
+ await engine.resume(String(runId));
2439
+ } finally {
2440
+ try {
2441
+ await job.cancel?.(jobName);
2442
+ } catch {
2443
+ }
2444
+ }
2445
+ });
2446
+ return { success: true, suspend: true, correlation: jobName };
2447
+ } catch (err) {
2448
+ ctx.logger.warn(
2449
+ `[wait] node '${node.id}': failed to schedule timer resume (${err?.message ?? err}); suspending without auto-resume (resume it via resume(runId))`
2450
+ );
2451
+ }
2452
+ } else if (!job) {
2453
+ ctx.logger.warn(
2454
+ `[wait] node '${node.id}': no job service registered \u2014 suspending without an auto-resume timer (resume it via resume(runId), or install the job service for durable timers)`
2455
+ );
2456
+ }
2457
+ return { success: true, suspend: true, correlation: `timer:${node.id}` };
2458
+ }
2459
+ const signal = String(wec.signalName ?? loose.signalName ?? loose.signal ?? `wait:${node.id}`);
2460
+ return { success: true, suspend: true, correlation: signal };
2461
+ }
2462
+ });
2463
+ ctx.logger.info("[Wait Node] 1 built-in node executor registered");
2464
+ }
2465
+ function parseIsoDuration(input) {
2466
+ if (typeof input === "number" && Number.isFinite(input)) return input > 0 ? input : void 0;
2467
+ if (typeof input !== "string") return void 0;
2468
+ const s = input.trim();
2469
+ if (!s) return void 0;
2470
+ if (/^\d+(?:\.\d+)?$/.test(s)) {
2471
+ const n = Number(s);
2472
+ return n > 0 ? n : void 0;
2473
+ }
2474
+ const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(s);
2475
+ if (!m) return void 0;
2476
+ const [, w, d, h, min, sec] = m;
2477
+ if (!w && !d && !h && !min && !sec) return void 0;
2478
+ const totalSec = Number(w ?? 0) * 7 * 86400 + Number(d ?? 0) * 86400 + Number(h ?? 0) * 3600 + Number(min ?? 0) * 60 + Number(sec ?? 0);
2479
+ const ms = totalSec * 1e3;
2480
+ return ms > 0 ? ms : void 0;
2481
+ }
2482
+
2483
+ // src/builtin/subflow-node.ts
2484
+ import { defineActionDescriptor as defineActionDescriptor12 } from "@objectstack/spec/automation";
2485
+ var MAX_SUBFLOW_DEPTH = 16;
2486
+ function registerSubflowNode(engine, ctx) {
2487
+ engine.registerNodeExecutor({
2488
+ type: "subflow",
2489
+ descriptor: defineActionDescriptor12({
2490
+ type: "subflow",
2491
+ version: "1.0.0",
2492
+ name: "Subflow",
2493
+ description: "Invoke another flow as a reusable step and capture its output.",
2494
+ icon: "workflow",
2495
+ category: "logic",
2496
+ source: "builtin"
2497
+ }),
2498
+ async execute(node, variables, context) {
2499
+ const cfg = node.config ?? {};
2500
+ const flowName = typeof cfg.flowName === "string" ? cfg.flowName : typeof cfg.flow === "string" ? cfg.flow : void 0;
2501
+ if (!flowName) {
2502
+ return { success: false, error: `subflow '${node.id}': config.flowName is required` };
2503
+ }
2504
+ const depth = Number(context?.$subflowDepth ?? 0);
2505
+ if (depth >= MAX_SUBFLOW_DEPTH) {
2506
+ return {
2507
+ success: false,
2508
+ error: `subflow '${flowName}': max nesting depth (${MAX_SUBFLOW_DEPTH}) exceeded \u2014 recursive subflow?`
2509
+ };
2510
+ }
2511
+ const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
2512
+ const params = interpolate(rawInput, variables, context ?? {});
2513
+ const childContext = {
2514
+ ...context ?? {},
2515
+ $subflowDepth: depth + 1,
2516
+ params
2517
+ };
2518
+ const child = await engine.execute(flowName, childContext);
2519
+ if (child.status === "paused") {
2520
+ return {
2521
+ success: false,
2522
+ error: `subflow '${flowName}' suspended at a pausing node \u2014 a nested approval/screen/wait pause from a subflow is not yet supported`
2523
+ };
2524
+ }
2525
+ if (!child.success) {
2526
+ return { success: false, error: `subflow '${flowName}' failed: ${child.error ?? "unknown error"}` };
2527
+ }
2528
+ const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
2529
+ if (outVar) variables.set(outVar, child.output ?? null);
2530
+ return { success: true, output: { output: child.output ?? null } };
2531
+ }
2532
+ });
2533
+ ctx.logger.info("[Subflow Node] 1 built-in node executor registered");
2534
+ }
2535
+
1638
2536
  // src/builtin/index.ts
1639
2537
  function installBuiltinNodes(engine, ctx) {
1640
2538
  registerLogicNodes(engine, ctx);
2539
+ registerLoopNode(engine, ctx);
2540
+ registerParallelNode(engine, ctx);
2541
+ registerTryCatchNode(engine, ctx);
1641
2542
  registerCrudNodes(engine, ctx);
1642
2543
  registerScreenNodes(engine, ctx);
1643
2544
  registerHttpNodes(engine, ctx);
1644
2545
  registerConnectorNodes(engine, ctx);
1645
2546
  registerNotifyNode(engine, ctx);
2547
+ registerWaitNode(engine, ctx);
2548
+ registerSubflowNode(engine, ctx);
1646
2549
  const types = engine.getRegisteredNodeTypes();
1647
2550
  ctx.logger.info(
1648
2551
  `[Automation] ${types.length} built-in node executors installed: ${types.join(", ")}`
@@ -1664,6 +2567,24 @@ var AutomationServicePlugin = class {
1664
2567
  async init(ctx) {
1665
2568
  this.engine = new AutomationEngine(ctx.logger);
1666
2569
  ctx.registerService("automation", this.engine);
2570
+ if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
2571
+ try {
2572
+ ctx.getService("manifest").register({
2573
+ id: "com.objectstack.service-automation",
2574
+ name: "Automation Service",
2575
+ version: "1.0.0",
2576
+ type: "plugin",
2577
+ scope: "system",
2578
+ defaultDatasource: "cloud",
2579
+ namespace: "sys",
2580
+ objects: [SysAutomationRun]
2581
+ });
2582
+ } catch (err) {
2583
+ ctx.logger.warn(
2584
+ `[Automation] manifest service unavailable; sys_automation_run not registered (suspended runs stay in-memory): ${err.message}`
2585
+ );
2586
+ }
2587
+ }
1667
2588
  installBuiltinNodes(this.engine, ctx);
1668
2589
  if (this.options.debug) {
1669
2590
  ctx.hook("automation:beforeExecute", async (flowName) => {
@@ -1683,6 +2604,23 @@ var AutomationServicePlugin = class {
1683
2604
  ctx.logger.info(
1684
2605
  `[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
1685
2606
  );
2607
+ if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
2608
+ let dataEngine = null;
2609
+ try {
2610
+ dataEngine = ctx.getService("objectql");
2611
+ } catch {
2612
+ try {
2613
+ dataEngine = ctx.getService("data");
2614
+ } catch {
2615
+ }
2616
+ }
2617
+ if (dataEngine && typeof dataEngine.find === "function" && typeof dataEngine.insert === "function") {
2618
+ this.engine.setSuspendedRunStore(new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger));
2619
+ ctx.logger.info("[Automation] Suspended-run persistence enabled (sys_automation_run)");
2620
+ } else {
2621
+ ctx.logger.info("[Automation] No ObjectQL engine \u2014 suspended runs kept in-memory only");
2622
+ }
2623
+ }
1686
2624
  try {
1687
2625
  const ql = ctx.getService("objectql");
1688
2626
  if (!ql) {
@@ -1721,6 +2659,9 @@ var AutomationServicePlugin = class {
1721
2659
  export {
1722
2660
  AutomationEngine,
1723
2661
  AutomationServicePlugin,
2662
+ InMemorySuspendedRunStore,
2663
+ ObjectStoreSuspendedRunStore,
2664
+ SysAutomationRun,
1724
2665
  installBuiltinNodes,
1725
2666
  registerConnectorNodes,
1726
2667
  registerCrudNodes,