@objectstack/service-automation 7.5.0 → 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 +966 -160
- 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 +969 -166
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
35
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
return { success: false, error: `No suspended run '${runId}'` };
|
|
585
|
+
if (this.resuming.has(runId)) {
|
|
586
|
+
return { success: false, error: `Run '${runId}' is already being resumed` };
|
|
470
587
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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: "
|
|
684
|
+
status: "failed",
|
|
535
685
|
startedAt: run.startedAt,
|
|
536
|
-
|
|
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:
|
|
696
|
+
return { success: false, error: errorMessage, durationMs };
|
|
545
697
|
}
|
|
546
|
-
|
|
547
|
-
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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/
|
|
1139
|
-
import { defineActionDescriptor as
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
1462
|
-
descriptor:
|
|
1463
|
-
type:
|
|
2151
|
+
type: HTTP_TYPE,
|
|
2152
|
+
descriptor: defineActionDescriptor8({
|
|
2153
|
+
type: HTTP_TYPE,
|
|
1464
2154
|
version: "1.0.0",
|
|
1465
|
-
name: "HTTP
|
|
1466
|
-
description: "Call an external HTTP endpoint.
|
|
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
|
-
//
|
|
1471
|
-
//
|
|
1472
|
-
needsOutbox:
|
|
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,
|
|
1477
|
-
const
|
|
1478
|
-
const
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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) {
|
|
@@ -1636,7 +2398,7 @@ function registerNotifyNode(engine, ctx) {
|
|
|
1636
2398
|
}
|
|
1637
2399
|
|
|
1638
2400
|
// src/builtin/wait-node.ts
|
|
1639
|
-
import { defineActionDescriptor as
|
|
2401
|
+
import { defineActionDescriptor as defineActionDescriptor11 } from "@objectstack/spec/automation";
|
|
1640
2402
|
function registerWaitNode(engine, ctx) {
|
|
1641
2403
|
const getJobService = () => {
|
|
1642
2404
|
try {
|
|
@@ -1647,7 +2409,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
1647
2409
|
};
|
|
1648
2410
|
engine.registerNodeExecutor({
|
|
1649
2411
|
type: "wait",
|
|
1650
|
-
descriptor:
|
|
2412
|
+
descriptor: defineActionDescriptor11({
|
|
1651
2413
|
type: "wait",
|
|
1652
2414
|
version: "1.0.0",
|
|
1653
2415
|
name: "Wait",
|
|
@@ -1719,12 +2481,12 @@ function parseIsoDuration(input) {
|
|
|
1719
2481
|
}
|
|
1720
2482
|
|
|
1721
2483
|
// src/builtin/subflow-node.ts
|
|
1722
|
-
import { defineActionDescriptor as
|
|
2484
|
+
import { defineActionDescriptor as defineActionDescriptor12 } from "@objectstack/spec/automation";
|
|
1723
2485
|
var MAX_SUBFLOW_DEPTH = 16;
|
|
1724
2486
|
function registerSubflowNode(engine, ctx) {
|
|
1725
2487
|
engine.registerNodeExecutor({
|
|
1726
2488
|
type: "subflow",
|
|
1727
|
-
descriptor:
|
|
2489
|
+
descriptor: defineActionDescriptor12({
|
|
1728
2490
|
type: "subflow",
|
|
1729
2491
|
version: "1.0.0",
|
|
1730
2492
|
name: "Subflow",
|
|
@@ -1774,6 +2536,9 @@ function registerSubflowNode(engine, ctx) {
|
|
|
1774
2536
|
// src/builtin/index.ts
|
|
1775
2537
|
function installBuiltinNodes(engine, ctx) {
|
|
1776
2538
|
registerLogicNodes(engine, ctx);
|
|
2539
|
+
registerLoopNode(engine, ctx);
|
|
2540
|
+
registerParallelNode(engine, ctx);
|
|
2541
|
+
registerTryCatchNode(engine, ctx);
|
|
1777
2542
|
registerCrudNodes(engine, ctx);
|
|
1778
2543
|
registerScreenNodes(engine, ctx);
|
|
1779
2544
|
registerHttpNodes(engine, ctx);
|
|
@@ -1802,6 +2567,24 @@ var AutomationServicePlugin = class {
|
|
|
1802
2567
|
async init(ctx) {
|
|
1803
2568
|
this.engine = new AutomationEngine(ctx.logger);
|
|
1804
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
|
+
}
|
|
1805
2588
|
installBuiltinNodes(this.engine, ctx);
|
|
1806
2589
|
if (this.options.debug) {
|
|
1807
2590
|
ctx.hook("automation:beforeExecute", async (flowName) => {
|
|
@@ -1821,6 +2604,23 @@ var AutomationServicePlugin = class {
|
|
|
1821
2604
|
ctx.logger.info(
|
|
1822
2605
|
`[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
|
|
1823
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
|
+
}
|
|
1824
2624
|
try {
|
|
1825
2625
|
const ql = ctx.getService("objectql");
|
|
1826
2626
|
if (!ql) {
|
|
@@ -1859,6 +2659,9 @@ var AutomationServicePlugin = class {
|
|
|
1859
2659
|
export {
|
|
1860
2660
|
AutomationEngine,
|
|
1861
2661
|
AutomationServicePlugin,
|
|
2662
|
+
InMemorySuspendedRunStore,
|
|
2663
|
+
ObjectStoreSuspendedRunStore,
|
|
2664
|
+
SysAutomationRun,
|
|
1862
2665
|
installBuiltinNodes,
|
|
1863
2666
|
registerConnectorNodes,
|
|
1864
2667
|
registerCrudNodes,
|