@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.cjs +1100 -156
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3764 -22
- package/dist/index.d.ts +3764 -22
- package/dist/index.js +1103 -162
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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
|
-
|
|
68
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
return { success: false, error: `No suspended run '${runId}'` };
|
|
503
|
-
}
|
|
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}'` };
|
|
621
|
+
if (this.resuming.has(runId)) {
|
|
622
|
+
return { success: false, error: `Run '${runId}' is already being resumed` };
|
|
511
623
|
}
|
|
512
|
-
this.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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: "
|
|
720
|
+
status: "failed",
|
|
568
721
|
startedAt: run.startedAt,
|
|
569
|
-
|
|
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:
|
|
732
|
+
return { success: false, error: errorMessage, durationMs };
|
|
578
733
|
}
|
|
579
|
-
|
|
580
|
-
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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/
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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:
|
|
1495
|
-
descriptor: (0,
|
|
1496
|
-
type:
|
|
2187
|
+
type: HTTP_TYPE,
|
|
2188
|
+
descriptor: (0, import_automation8.defineActionDescriptor)({
|
|
2189
|
+
type: HTTP_TYPE,
|
|
1497
2190
|
version: "1.0.0",
|
|
1498
|
-
name: "HTTP
|
|
1499
|
-
description: "Call an external HTTP endpoint.
|
|
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
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
needsOutbox:
|
|
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,
|
|
1510
|
-
const
|
|
1511
|
-
const
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
const
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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) {
|
|
@@ -1668,14 +2433,155 @@ function registerNotifyNode(engine, ctx) {
|
|
|
1668
2433
|
ctx.logger.info("[Notify] 1 built-in node executor registered (notify)");
|
|
1669
2434
|
}
|
|
1670
2435
|
|
|
2436
|
+
// src/builtin/wait-node.ts
|
|
2437
|
+
var import_automation11 = require("@objectstack/spec/automation");
|
|
2438
|
+
function registerWaitNode(engine, ctx) {
|
|
2439
|
+
const getJobService = () => {
|
|
2440
|
+
try {
|
|
2441
|
+
return ctx.getService("job");
|
|
2442
|
+
} catch {
|
|
2443
|
+
return void 0;
|
|
2444
|
+
}
|
|
2445
|
+
};
|
|
2446
|
+
engine.registerNodeExecutor({
|
|
2447
|
+
type: "wait",
|
|
2448
|
+
descriptor: (0, import_automation11.defineActionDescriptor)({
|
|
2449
|
+
type: "wait",
|
|
2450
|
+
version: "1.0.0",
|
|
2451
|
+
name: "Wait",
|
|
2452
|
+
description: "Pause the flow until a timer elapses or a named signal arrives.",
|
|
2453
|
+
icon: "timer-reset",
|
|
2454
|
+
category: "logic",
|
|
2455
|
+
source: "builtin",
|
|
2456
|
+
// Durable pause — the run suspends and resumes later (timer/signal).
|
|
2457
|
+
supportsPause: true,
|
|
2458
|
+
isAsync: true
|
|
2459
|
+
}),
|
|
2460
|
+
async execute(node, variables, _context) {
|
|
2461
|
+
const loose = node.config ?? {};
|
|
2462
|
+
const wec = node.waitEventConfig ?? {};
|
|
2463
|
+
const eventType = String(wec.eventType ?? loose.eventType ?? "timer");
|
|
2464
|
+
const runId = variables.get("$runId");
|
|
2465
|
+
if (eventType === "timer") {
|
|
2466
|
+
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);
|
|
2467
|
+
const job = getJobService();
|
|
2468
|
+
if (job && runId != null && durationMs && durationMs > 0) {
|
|
2469
|
+
const jobName = `flow-wait:${String(runId)}:${node.id}`;
|
|
2470
|
+
const at = new Date(Date.now() + durationMs).toISOString();
|
|
2471
|
+
try {
|
|
2472
|
+
await job.schedule(jobName, { type: "once", at }, async () => {
|
|
2473
|
+
try {
|
|
2474
|
+
await engine.resume(String(runId));
|
|
2475
|
+
} finally {
|
|
2476
|
+
try {
|
|
2477
|
+
await job.cancel?.(jobName);
|
|
2478
|
+
} catch {
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
});
|
|
2482
|
+
return { success: true, suspend: true, correlation: jobName };
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
ctx.logger.warn(
|
|
2485
|
+
`[wait] node '${node.id}': failed to schedule timer resume (${err?.message ?? err}); suspending without auto-resume (resume it via resume(runId))`
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
} else if (!job) {
|
|
2489
|
+
ctx.logger.warn(
|
|
2490
|
+
`[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)`
|
|
2491
|
+
);
|
|
2492
|
+
}
|
|
2493
|
+
return { success: true, suspend: true, correlation: `timer:${node.id}` };
|
|
2494
|
+
}
|
|
2495
|
+
const signal = String(wec.signalName ?? loose.signalName ?? loose.signal ?? `wait:${node.id}`);
|
|
2496
|
+
return { success: true, suspend: true, correlation: signal };
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
ctx.logger.info("[Wait Node] 1 built-in node executor registered");
|
|
2500
|
+
}
|
|
2501
|
+
function parseIsoDuration(input) {
|
|
2502
|
+
if (typeof input === "number" && Number.isFinite(input)) return input > 0 ? input : void 0;
|
|
2503
|
+
if (typeof input !== "string") return void 0;
|
|
2504
|
+
const s = input.trim();
|
|
2505
|
+
if (!s) return void 0;
|
|
2506
|
+
if (/^\d+(?:\.\d+)?$/.test(s)) {
|
|
2507
|
+
const n = Number(s);
|
|
2508
|
+
return n > 0 ? n : void 0;
|
|
2509
|
+
}
|
|
2510
|
+
const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(s);
|
|
2511
|
+
if (!m) return void 0;
|
|
2512
|
+
const [, w, d, h, min, sec] = m;
|
|
2513
|
+
if (!w && !d && !h && !min && !sec) return void 0;
|
|
2514
|
+
const totalSec = Number(w ?? 0) * 7 * 86400 + Number(d ?? 0) * 86400 + Number(h ?? 0) * 3600 + Number(min ?? 0) * 60 + Number(sec ?? 0);
|
|
2515
|
+
const ms = totalSec * 1e3;
|
|
2516
|
+
return ms > 0 ? ms : void 0;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// src/builtin/subflow-node.ts
|
|
2520
|
+
var import_automation12 = require("@objectstack/spec/automation");
|
|
2521
|
+
var MAX_SUBFLOW_DEPTH = 16;
|
|
2522
|
+
function registerSubflowNode(engine, ctx) {
|
|
2523
|
+
engine.registerNodeExecutor({
|
|
2524
|
+
type: "subflow",
|
|
2525
|
+
descriptor: (0, import_automation12.defineActionDescriptor)({
|
|
2526
|
+
type: "subflow",
|
|
2527
|
+
version: "1.0.0",
|
|
2528
|
+
name: "Subflow",
|
|
2529
|
+
description: "Invoke another flow as a reusable step and capture its output.",
|
|
2530
|
+
icon: "workflow",
|
|
2531
|
+
category: "logic",
|
|
2532
|
+
source: "builtin"
|
|
2533
|
+
}),
|
|
2534
|
+
async execute(node, variables, context) {
|
|
2535
|
+
const cfg = node.config ?? {};
|
|
2536
|
+
const flowName = typeof cfg.flowName === "string" ? cfg.flowName : typeof cfg.flow === "string" ? cfg.flow : void 0;
|
|
2537
|
+
if (!flowName) {
|
|
2538
|
+
return { success: false, error: `subflow '${node.id}': config.flowName is required` };
|
|
2539
|
+
}
|
|
2540
|
+
const depth = Number(context?.$subflowDepth ?? 0);
|
|
2541
|
+
if (depth >= MAX_SUBFLOW_DEPTH) {
|
|
2542
|
+
return {
|
|
2543
|
+
success: false,
|
|
2544
|
+
error: `subflow '${flowName}': max nesting depth (${MAX_SUBFLOW_DEPTH}) exceeded \u2014 recursive subflow?`
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
|
|
2548
|
+
const params = interpolate(rawInput, variables, context ?? {});
|
|
2549
|
+
const childContext = {
|
|
2550
|
+
...context ?? {},
|
|
2551
|
+
$subflowDepth: depth + 1,
|
|
2552
|
+
params
|
|
2553
|
+
};
|
|
2554
|
+
const child = await engine.execute(flowName, childContext);
|
|
2555
|
+
if (child.status === "paused") {
|
|
2556
|
+
return {
|
|
2557
|
+
success: false,
|
|
2558
|
+
error: `subflow '${flowName}' suspended at a pausing node \u2014 a nested approval/screen/wait pause from a subflow is not yet supported`
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
if (!child.success) {
|
|
2562
|
+
return { success: false, error: `subflow '${flowName}' failed: ${child.error ?? "unknown error"}` };
|
|
2563
|
+
}
|
|
2564
|
+
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2565
|
+
if (outVar) variables.set(outVar, child.output ?? null);
|
|
2566
|
+
return { success: true, output: { output: child.output ?? null } };
|
|
2567
|
+
}
|
|
2568
|
+
});
|
|
2569
|
+
ctx.logger.info("[Subflow Node] 1 built-in node executor registered");
|
|
2570
|
+
}
|
|
2571
|
+
|
|
1671
2572
|
// src/builtin/index.ts
|
|
1672
2573
|
function installBuiltinNodes(engine, ctx) {
|
|
1673
2574
|
registerLogicNodes(engine, ctx);
|
|
2575
|
+
registerLoopNode(engine, ctx);
|
|
2576
|
+
registerParallelNode(engine, ctx);
|
|
2577
|
+
registerTryCatchNode(engine, ctx);
|
|
1674
2578
|
registerCrudNodes(engine, ctx);
|
|
1675
2579
|
registerScreenNodes(engine, ctx);
|
|
1676
2580
|
registerHttpNodes(engine, ctx);
|
|
1677
2581
|
registerConnectorNodes(engine, ctx);
|
|
1678
2582
|
registerNotifyNode(engine, ctx);
|
|
2583
|
+
registerWaitNode(engine, ctx);
|
|
2584
|
+
registerSubflowNode(engine, ctx);
|
|
1679
2585
|
const types = engine.getRegisteredNodeTypes();
|
|
1680
2586
|
ctx.logger.info(
|
|
1681
2587
|
`[Automation] ${types.length} built-in node executors installed: ${types.join(", ")}`
|
|
@@ -1697,6 +2603,24 @@ var AutomationServicePlugin = class {
|
|
|
1697
2603
|
async init(ctx) {
|
|
1698
2604
|
this.engine = new AutomationEngine(ctx.logger);
|
|
1699
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
|
+
}
|
|
1700
2624
|
installBuiltinNodes(this.engine, ctx);
|
|
1701
2625
|
if (this.options.debug) {
|
|
1702
2626
|
ctx.hook("automation:beforeExecute", async (flowName) => {
|
|
@@ -1716,6 +2640,23 @@ var AutomationServicePlugin = class {
|
|
|
1716
2640
|
ctx.logger.info(
|
|
1717
2641
|
`[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
|
|
1718
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
|
+
}
|
|
1719
2660
|
try {
|
|
1720
2661
|
const ql = ctx.getService("objectql");
|
|
1721
2662
|
if (!ql) {
|
|
@@ -1755,6 +2696,9 @@ var AutomationServicePlugin = class {
|
|
|
1755
2696
|
0 && (module.exports = {
|
|
1756
2697
|
AutomationEngine,
|
|
1757
2698
|
AutomationServicePlugin,
|
|
2699
|
+
InMemorySuspendedRunStore,
|
|
2700
|
+
ObjectStoreSuspendedRunStore,
|
|
2701
|
+
SysAutomationRun,
|
|
1758
2702
|
installBuiltinNodes,
|
|
1759
2703
|
registerConnectorNodes,
|
|
1760
2704
|
registerCrudNodes,
|