@objectstack/service-automation 8.0.1 → 9.0.1
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 +323 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -4
- package/dist/index.d.ts +45 -4
- package/dist/index.js +323 -17
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -496,6 +496,8 @@ var AutomationEngine = class {
|
|
|
496
496
|
}
|
|
497
497
|
const runId = this.nextRunId();
|
|
498
498
|
variables.set("$runId", runId);
|
|
499
|
+
variables.set("$flowName", flowName);
|
|
500
|
+
variables.set("$flowLabel", flow.label ?? flowName);
|
|
499
501
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
500
502
|
const steps = [];
|
|
501
503
|
try {
|
|
@@ -616,8 +618,24 @@ var AutomationEngine = class {
|
|
|
616
618
|
* restricted to the edge labelled `signal.branchLabel` (e.g. the approval
|
|
617
619
|
* decision). The continuation may itself suspend again, in which case this
|
|
618
620
|
* returns `{ status: 'paused', runId }` afresh.
|
|
621
|
+
*
|
|
622
|
+
* **Subflow chains (nested pause, linked-runs model).** A run paused at a
|
|
623
|
+
* `subflow` node (correlation `subflow:<childRunId>`) DELEGATES the signal
|
|
624
|
+
* down to the suspended child; a run that completes and carries
|
|
625
|
+
* `$parentRunId` in its context BUBBLES its output up by auto-resuming the
|
|
626
|
+
* parent. Both directions compose recursively, so arbitrarily nested
|
|
627
|
+
* subflow pauses resolve from either end (UI holds the parent run id;
|
|
628
|
+
* approval/wait infrastructure holds the child's).
|
|
619
629
|
*/
|
|
620
630
|
async resume(runId, signal) {
|
|
631
|
+
return this.resumeInternal(runId, signal, false);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* @param skipBubble - Set when the caller is the subflow DELEGATION path,
|
|
635
|
+
* which continues the parent itself after the child completes — the
|
|
636
|
+
* child's own up-bubble must stay off so the parent isn't resumed twice.
|
|
637
|
+
*/
|
|
638
|
+
async resumeInternal(runId, signal, skipBubble) {
|
|
621
639
|
if (this.resuming.has(runId)) {
|
|
622
640
|
return { success: false, error: `Run '${runId}' is already being resumed` };
|
|
623
641
|
}
|
|
@@ -644,6 +662,35 @@ var AutomationEngine = class {
|
|
|
644
662
|
if (!node) {
|
|
645
663
|
return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
|
|
646
664
|
}
|
|
665
|
+
if (typeof run.correlation === "string" && run.correlation.startsWith("subflow:")) {
|
|
666
|
+
const childRunId = run.correlation.slice("subflow:".length);
|
|
667
|
+
const childRun = this.suspendedRuns.get(childRunId) ?? (this.store ? await this.store.load(childRunId).catch(() => null) : null);
|
|
668
|
+
if (childRun) {
|
|
669
|
+
const childRes = await this.resumeInternal(childRunId, signal, true);
|
|
670
|
+
if (childRes.status === "paused") {
|
|
671
|
+
if (childRes.screen && childRes.screen !== run.screen) {
|
|
672
|
+
await this.persistSuspendedRun({ ...run, screen: childRes.screen });
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
success: true,
|
|
676
|
+
status: "paused",
|
|
677
|
+
runId,
|
|
678
|
+
durationMs: Date.now() - run.startTime,
|
|
679
|
+
screen: childRes.screen
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (!childRes.success) {
|
|
683
|
+
const error = `subflow run '${childRunId}' (${childRun.flowName}) failed: ${childRes.error ?? "unknown error"}`;
|
|
684
|
+
await this.failSuspendedRun(run, error);
|
|
685
|
+
return { success: false, error, durationMs: Date.now() - run.startTime };
|
|
686
|
+
}
|
|
687
|
+
signal = this.buildSubflowResumeSignal(childRun.context, childRes.output);
|
|
688
|
+
} else {
|
|
689
|
+
this.logger.warn(
|
|
690
|
+
`[automation] run '${runId}' is paused at subflow node '${run.nodeId}' but child run '${childRunId}' is gone \u2014 continuing without child output`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
647
694
|
await this.forgetSuspendedRun(runId);
|
|
648
695
|
const variables = new Map(Object.entries(run.variables));
|
|
649
696
|
if (signal?.output) {
|
|
@@ -659,7 +706,11 @@ var AutomationEngine = class {
|
|
|
659
706
|
const steps = run.steps;
|
|
660
707
|
const context = run.context;
|
|
661
708
|
try {
|
|
662
|
-
|
|
709
|
+
if (typeof run.correlation === "string" && run.correlation.startsWith("map:")) {
|
|
710
|
+
await this.executeNode(node, flow, variables, context, steps);
|
|
711
|
+
} else {
|
|
712
|
+
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
|
|
713
|
+
}
|
|
663
714
|
const output = {};
|
|
664
715
|
if (flow.variables) {
|
|
665
716
|
for (const v of flow.variables) {
|
|
@@ -683,6 +734,9 @@ var AutomationEngine = class {
|
|
|
683
734
|
steps,
|
|
684
735
|
output
|
|
685
736
|
});
|
|
737
|
+
if (!skipBubble) {
|
|
738
|
+
await this.bubbleToParent(run, output);
|
|
739
|
+
}
|
|
686
740
|
return { success: true, output, durationMs };
|
|
687
741
|
} catch (err) {
|
|
688
742
|
if (isSuspendSignal(err)) {
|
|
@@ -729,12 +783,94 @@ var AutomationEngine = class {
|
|
|
729
783
|
steps,
|
|
730
784
|
error: errorMessage
|
|
731
785
|
});
|
|
786
|
+
if (!skipBubble) {
|
|
787
|
+
await this.failAncestors(run.context, errorMessage);
|
|
788
|
+
}
|
|
732
789
|
return { success: false, error: errorMessage, durationMs };
|
|
733
790
|
}
|
|
734
791
|
} finally {
|
|
735
792
|
this.resuming.delete(runId);
|
|
736
793
|
}
|
|
737
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* Build the resume signal that maps a completed subflow child's output
|
|
797
|
+
* into its parent — mirroring the synchronous path exactly: the engine's
|
|
798
|
+
* standard `signal.output` merge lands it under `${subflowNodeId}.output`,
|
|
799
|
+
* and `signal.variables` writes the bare `config.outputVariable` when the
|
|
800
|
+
* child's context carries one (`$parentOutputVariable`).
|
|
801
|
+
*/
|
|
802
|
+
buildSubflowResumeSignal(childContext, childOutput) {
|
|
803
|
+
const outVar = childContext?.$parentOutputVariable;
|
|
804
|
+
return {
|
|
805
|
+
output: { output: childOutput ?? null },
|
|
806
|
+
...typeof outVar === "string" && outVar ? { variables: { [outVar]: childOutput ?? null } } : {}
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Up-bubble for the subflow chain: when a completed run carries
|
|
811
|
+
* `$parentRunId`, resume that parent with this run's output. Recursion via
|
|
812
|
+
* the parent's own completion bubbles multi-level chains. Best-effort —
|
|
813
|
+
* a failed parent continuation is logged, never thrown back at the
|
|
814
|
+
* caller who resumed the child.
|
|
815
|
+
*/
|
|
816
|
+
async bubbleToParent(run, output) {
|
|
817
|
+
const ctx = run.context;
|
|
818
|
+
const parentRunId = ctx?.$parentRunId;
|
|
819
|
+
if (typeof parentRunId !== "string" || !parentRunId) return;
|
|
820
|
+
try {
|
|
821
|
+
const mapNode = ctx?.$parentMapNode;
|
|
822
|
+
const sig = typeof mapNode === "string" && mapNode ? { variables: { [`${mapNode}.$mapItemOutput`]: output ?? null, [`${mapNode}.$mapItemDone`]: true } } : this.buildSubflowResumeSignal(run.context, output);
|
|
823
|
+
const parentRes = await this.resumeInternal(parentRunId, sig, false);
|
|
824
|
+
if (!parentRes.success) {
|
|
825
|
+
this.logger.warn(
|
|
826
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' failed: ${parentRes.error}`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
} catch (err) {
|
|
830
|
+
this.logger.warn(
|
|
831
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' threw: ${err.message}`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Terminally fail a suspended run: consume its continuation and record a
|
|
837
|
+
* `failed` log so it stops surfacing as resumable. Used when a subflow
|
|
838
|
+
* descendant fails — the ancestor awaiting it can never be resumed.
|
|
839
|
+
*/
|
|
840
|
+
async failSuspendedRun(run, error) {
|
|
841
|
+
await this.forgetSuspendedRun(run.runId);
|
|
842
|
+
this.recordLog({
|
|
843
|
+
id: run.runId,
|
|
844
|
+
flowName: run.flowName,
|
|
845
|
+
flowVersion: run.flowVersion,
|
|
846
|
+
status: "failed",
|
|
847
|
+
startedAt: run.startedAt,
|
|
848
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
849
|
+
durationMs: Date.now() - run.startTime,
|
|
850
|
+
trigger: {
|
|
851
|
+
type: run.context?.event ?? "manual",
|
|
852
|
+
userId: run.context?.userId,
|
|
853
|
+
object: run.context?.object
|
|
854
|
+
},
|
|
855
|
+
steps: run.steps,
|
|
856
|
+
error
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Walk a failed run's `$parentRunId` chain and fail each suspended
|
|
861
|
+
* ancestor (see {@link failSuspendedRun}). Bounded so a corrupt context
|
|
862
|
+
* can't loop forever.
|
|
863
|
+
*/
|
|
864
|
+
async failAncestors(context, error) {
|
|
865
|
+
let parentId = context?.$parentRunId;
|
|
866
|
+
let hops = 0;
|
|
867
|
+
while (typeof parentId === "string" && parentId && hops++ < 32) {
|
|
868
|
+
const parent = this.suspendedRuns.get(parentId) ?? (this.store ? await this.store.load(parentId).catch(() => null) : null);
|
|
869
|
+
if (!parent) return;
|
|
870
|
+
await this.failSuspendedRun(parent, `subflow descendant failed: ${error}`);
|
|
871
|
+
parentId = parent.context?.$parentRunId;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
738
874
|
/**
|
|
739
875
|
* List the runs currently suspended awaiting {@link resume} (ADR-0019).
|
|
740
876
|
* Backs operability surfaces such as a "pending approvals" view.
|
|
@@ -2506,10 +2642,11 @@ function registerWaitNode(engine, ctx) {
|
|
|
2506
2642
|
const runId = variables.get("$runId");
|
|
2507
2643
|
if (eventType === "timer") {
|
|
2508
2644
|
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);
|
|
2645
|
+
const at = durationMs && durationMs > 0 ? new Date(Date.now() + durationMs).toISOString() : void 0;
|
|
2646
|
+
const output = at ? { waitUntil: at } : void 0;
|
|
2509
2647
|
const job = getJobService();
|
|
2510
|
-
if (job && runId != null &&
|
|
2648
|
+
if (job && runId != null && at) {
|
|
2511
2649
|
const jobName = `flow-wait:${String(runId)}:${node.id}`;
|
|
2512
|
-
const at = new Date(Date.now() + durationMs).toISOString();
|
|
2513
2650
|
try {
|
|
2514
2651
|
await job.schedule(jobName, { type: "once", at }, async () => {
|
|
2515
2652
|
try {
|
|
@@ -2521,7 +2658,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2521
2658
|
}
|
|
2522
2659
|
}
|
|
2523
2660
|
});
|
|
2524
|
-
return { success: true, suspend: true, correlation: jobName };
|
|
2661
|
+
return { success: true, suspend: true, correlation: jobName, output };
|
|
2525
2662
|
} catch (err) {
|
|
2526
2663
|
ctx.logger.warn(
|
|
2527
2664
|
`[wait] node '${node.id}': failed to schedule timer resume (${err?.message ?? err}); suspending without auto-resume (resume it via resume(runId))`
|
|
@@ -2532,7 +2669,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2532
2669
|
`[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)`
|
|
2533
2670
|
);
|
|
2534
2671
|
}
|
|
2535
|
-
return { success: true, suspend: true, correlation: `timer:${node.id}
|
|
2672
|
+
return { success: true, suspend: true, correlation: `timer:${node.id}`, output };
|
|
2536
2673
|
}
|
|
2537
2674
|
const signal = String(wec.signalName ?? loose.signalName ?? loose.signal ?? `wait:${node.id}`);
|
|
2538
2675
|
return { success: true, suspend: true, correlation: signal };
|
|
@@ -2540,6 +2677,54 @@ function registerWaitNode(engine, ctx) {
|
|
|
2540
2677
|
});
|
|
2541
2678
|
ctx.logger.info("[Wait Node] 1 built-in node executor registered");
|
|
2542
2679
|
}
|
|
2680
|
+
async function rearmSuspendedWaitTimers(engine, store, job, logger) {
|
|
2681
|
+
let runs;
|
|
2682
|
+
try {
|
|
2683
|
+
runs = await store.list();
|
|
2684
|
+
} catch (err) {
|
|
2685
|
+
logger.warn(`[wait] timer re-arm: failed to list suspended runs: ${err?.message ?? err}`);
|
|
2686
|
+
return 0;
|
|
2687
|
+
}
|
|
2688
|
+
let rearmed = 0;
|
|
2689
|
+
for (const run of runs) {
|
|
2690
|
+
const wakeAt = run.variables?.[`${run.nodeId}.waitUntil`];
|
|
2691
|
+
if (typeof wakeAt !== "string" || !wakeAt) continue;
|
|
2692
|
+
const atMs = Date.parse(wakeAt);
|
|
2693
|
+
if (Number.isNaN(atMs)) continue;
|
|
2694
|
+
if (atMs <= Date.now()) {
|
|
2695
|
+
try {
|
|
2696
|
+
await engine.resume(run.runId);
|
|
2697
|
+
rearmed++;
|
|
2698
|
+
} catch (err) {
|
|
2699
|
+
logger.warn(`[wait] timer re-arm: resume of overdue run '${run.runId}' failed: ${err?.message ?? err}`);
|
|
2700
|
+
}
|
|
2701
|
+
continue;
|
|
2702
|
+
}
|
|
2703
|
+
if (!job) {
|
|
2704
|
+
logger.warn(
|
|
2705
|
+
`[wait] timer re-arm: run '${run.runId}' waits until ${wakeAt} but no job service is registered \u2014 resume it externally via resume(runId)`
|
|
2706
|
+
);
|
|
2707
|
+
continue;
|
|
2708
|
+
}
|
|
2709
|
+
const jobName = `flow-wait:${run.runId}:${run.nodeId}`;
|
|
2710
|
+
try {
|
|
2711
|
+
await job.schedule(jobName, { type: "once", at: wakeAt }, async () => {
|
|
2712
|
+
try {
|
|
2713
|
+
await engine.resume(run.runId);
|
|
2714
|
+
} finally {
|
|
2715
|
+
try {
|
|
2716
|
+
await job.cancel?.(jobName);
|
|
2717
|
+
} catch {
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
});
|
|
2721
|
+
rearmed++;
|
|
2722
|
+
} catch (err) {
|
|
2723
|
+
logger.warn(`[wait] timer re-arm: failed to re-schedule run '${run.runId}': ${err?.message ?? err}`);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
return rearmed;
|
|
2727
|
+
}
|
|
2543
2728
|
function parseIsoDuration(input) {
|
|
2544
2729
|
if (typeof input === "number" && Number.isFinite(input)) return input > 0 ? input : void 0;
|
|
2545
2730
|
if (typeof input !== "string") return void 0;
|
|
@@ -2571,7 +2756,10 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2571
2756
|
description: "Invoke another flow as a reusable step and capture its output.",
|
|
2572
2757
|
icon: "workflow",
|
|
2573
2758
|
category: "logic",
|
|
2574
|
-
source: "builtin"
|
|
2759
|
+
source: "builtin",
|
|
2760
|
+
// A child that suspends (approval/screen/wait) suspends this node too —
|
|
2761
|
+
// the parent run pauses here and resumes when the child completes.
|
|
2762
|
+
supportsPause: true
|
|
2575
2763
|
}),
|
|
2576
2764
|
async execute(node, variables, context) {
|
|
2577
2765
|
const cfg = node.config ?? {};
|
|
@@ -2588,22 +2776,33 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2588
2776
|
}
|
|
2589
2777
|
const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
|
|
2590
2778
|
const params = interpolate(rawInput, variables, context ?? {});
|
|
2779
|
+
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2780
|
+
const parentRunId = variables.get("$runId");
|
|
2591
2781
|
const childContext = {
|
|
2592
2782
|
...context ?? {},
|
|
2593
2783
|
$subflowDepth: depth + 1,
|
|
2594
|
-
params
|
|
2784
|
+
params,
|
|
2785
|
+
...parentRunId != null ? {
|
|
2786
|
+
$parentRunId: String(parentRunId),
|
|
2787
|
+
$parentNodeId: node.id,
|
|
2788
|
+
...outVar ? { $parentOutputVariable: outVar } : {}
|
|
2789
|
+
} : {}
|
|
2595
2790
|
};
|
|
2596
2791
|
const child = await engine.execute(flowName, childContext);
|
|
2597
2792
|
if (child.status === "paused") {
|
|
2793
|
+
if (!child.runId) {
|
|
2794
|
+
return { success: false, error: `subflow '${flowName}' paused without a run id \u2014 cannot link the runs` };
|
|
2795
|
+
}
|
|
2598
2796
|
return {
|
|
2599
|
-
success:
|
|
2600
|
-
|
|
2797
|
+
success: true,
|
|
2798
|
+
suspend: true,
|
|
2799
|
+
correlation: `subflow:${child.runId}`,
|
|
2800
|
+
screen: child.screen
|
|
2601
2801
|
};
|
|
2602
2802
|
}
|
|
2603
2803
|
if (!child.success) {
|
|
2604
2804
|
return { success: false, error: `subflow '${flowName}' failed: ${child.error ?? "unknown error"}` };
|
|
2605
2805
|
}
|
|
2606
|
-
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2607
2806
|
if (outVar) variables.set(outVar, child.output ?? null);
|
|
2608
2807
|
return { success: true, output: { output: child.output ?? null } };
|
|
2609
2808
|
}
|
|
@@ -2611,6 +2810,96 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2611
2810
|
ctx.logger.info("[Subflow Node] 1 built-in node executor registered");
|
|
2612
2811
|
}
|
|
2613
2812
|
|
|
2813
|
+
// src/builtin/map-node.ts
|
|
2814
|
+
var import_automation13 = require("@objectstack/spec/automation");
|
|
2815
|
+
var MAX_MAP_ITEMS = 1e4;
|
|
2816
|
+
function registerMapNode(engine, ctx) {
|
|
2817
|
+
engine.registerNodeExecutor({
|
|
2818
|
+
type: "map",
|
|
2819
|
+
descriptor: (0, import_automation13.defineActionDescriptor)({
|
|
2820
|
+
type: "map",
|
|
2821
|
+
version: "1.0.0",
|
|
2822
|
+
name: "Map",
|
|
2823
|
+
description: "Run a per-item subflow for each element of a collection, one at a time (each item may pause).",
|
|
2824
|
+
icon: "list-check",
|
|
2825
|
+
category: "logic",
|
|
2826
|
+
source: "builtin",
|
|
2827
|
+
// Each item's subflow may pause, so the map suspends and resumes per item.
|
|
2828
|
+
supportsPause: true,
|
|
2829
|
+
isAsync: true
|
|
2830
|
+
}),
|
|
2831
|
+
async execute(node, variables, context) {
|
|
2832
|
+
const cfg = node.config ?? {};
|
|
2833
|
+
const flowName = typeof cfg.flowName === "string" ? cfg.flowName : typeof cfg.flow === "string" ? cfg.flow : void 0;
|
|
2834
|
+
if (!flowName) {
|
|
2835
|
+
return { success: false, error: `map '${node.id}': config.flowName (the per-item subflow) is required` };
|
|
2836
|
+
}
|
|
2837
|
+
const iteratorVariable = typeof cfg.iteratorVariable === "string" && cfg.iteratorVariable ? cfg.iteratorVariable : "item";
|
|
2838
|
+
const indexVariable = typeof cfg.indexVariable === "string" && cfg.indexVariable ? cfg.indexVariable : void 0;
|
|
2839
|
+
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2840
|
+
const rawCollection = cfg.collection;
|
|
2841
|
+
let collection;
|
|
2842
|
+
if (Array.isArray(rawCollection)) {
|
|
2843
|
+
collection = rawCollection;
|
|
2844
|
+
} else if (typeof rawCollection === "string") {
|
|
2845
|
+
collection = interpolate(rawCollection, variables, context ?? {});
|
|
2846
|
+
if (collection == null && variables.has(rawCollection)) collection = variables.get(rawCollection);
|
|
2847
|
+
}
|
|
2848
|
+
if (!Array.isArray(collection)) {
|
|
2849
|
+
return { success: false, error: `map '${node.id}': collection '${String(rawCollection)}' did not resolve to an array` };
|
|
2850
|
+
}
|
|
2851
|
+
if (collection.length > MAX_MAP_ITEMS) {
|
|
2852
|
+
return { success: false, error: `map '${node.id}': collection length ${collection.length} exceeds the ${MAX_MAP_ITEMS} cap` };
|
|
2853
|
+
}
|
|
2854
|
+
const stateKey = `${node.id}.$mapState`;
|
|
2855
|
+
const state = variables.get(stateKey) ?? {
|
|
2856
|
+
started: 0,
|
|
2857
|
+
results: []
|
|
2858
|
+
};
|
|
2859
|
+
if (variables.get(`${node.id}.$mapItemDone`) === true) {
|
|
2860
|
+
state.results.push(variables.get(`${node.id}.$mapItemOutput`) ?? null);
|
|
2861
|
+
variables.delete(`${node.id}.$mapItemDone`);
|
|
2862
|
+
variables.delete(`${node.id}.$mapItemOutput`);
|
|
2863
|
+
}
|
|
2864
|
+
const parentRunId = variables.get("$runId");
|
|
2865
|
+
while (state.started < collection.length) {
|
|
2866
|
+
const idx = state.started;
|
|
2867
|
+
const item = collection[idx];
|
|
2868
|
+
variables.set(iteratorVariable, item);
|
|
2869
|
+
if (indexVariable) variables.set(indexVariable, idx);
|
|
2870
|
+
const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
|
|
2871
|
+
const params = interpolate(rawInput, variables, context ?? {});
|
|
2872
|
+
const itemIsRecord = item != null && typeof item === "object" && typeof item.id === "string";
|
|
2873
|
+
const itemObject = typeof cfg.itemObject === "string" ? cfg.itemObject : context?.object;
|
|
2874
|
+
const childContext = {
|
|
2875
|
+
...context ?? {},
|
|
2876
|
+
params,
|
|
2877
|
+
...itemIsRecord ? { record: item, object: itemObject } : {},
|
|
2878
|
+
...parentRunId != null ? { $parentRunId: String(parentRunId), $parentMapNode: node.id } : {}
|
|
2879
|
+
};
|
|
2880
|
+
const child = await engine.execute(flowName, childContext);
|
|
2881
|
+
if (child.status === "paused") {
|
|
2882
|
+
if (!child.runId) {
|
|
2883
|
+
return { success: false, error: `map '${node.id}': item ${idx} paused without a run id \u2014 cannot link the runs` };
|
|
2884
|
+
}
|
|
2885
|
+
state.started = idx + 1;
|
|
2886
|
+
variables.set(stateKey, state);
|
|
2887
|
+
return { success: true, suspend: true, correlation: `map:${child.runId}` };
|
|
2888
|
+
}
|
|
2889
|
+
if (!child.success) {
|
|
2890
|
+
return { success: false, error: `map '${node.id}': item ${idx} (subflow '${flowName}') failed: ${child.error ?? "unknown error"}` };
|
|
2891
|
+
}
|
|
2892
|
+
state.started = idx + 1;
|
|
2893
|
+
state.results.push(child.output ?? null);
|
|
2894
|
+
}
|
|
2895
|
+
variables.set(stateKey, state);
|
|
2896
|
+
if (outVar) variables.set(outVar, state.results);
|
|
2897
|
+
return { success: true, output: { results: state.results, count: state.results.length } };
|
|
2898
|
+
}
|
|
2899
|
+
});
|
|
2900
|
+
ctx.logger.info("[Map Node] 1 built-in node executor registered");
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2614
2903
|
// src/builtin/index.ts
|
|
2615
2904
|
function installBuiltinNodes(engine, ctx) {
|
|
2616
2905
|
registerLogicNodes(engine, ctx);
|
|
@@ -2624,6 +2913,7 @@ function installBuiltinNodes(engine, ctx) {
|
|
|
2624
2913
|
registerNotifyNode(engine, ctx);
|
|
2625
2914
|
registerWaitNode(engine, ctx);
|
|
2626
2915
|
registerSubflowNode(engine, ctx);
|
|
2916
|
+
registerMapNode(engine, ctx);
|
|
2627
2917
|
const types = engine.getRegisteredNodeTypes();
|
|
2628
2918
|
ctx.logger.info(
|
|
2629
2919
|
`[Automation] ${types.length} built-in node executors installed: ${types.join(", ")}`
|
|
@@ -2672,9 +2962,8 @@ var AutomationServicePlugin = class {
|
|
|
2672
2962
|
ctx.logger.info("[Automation] Engine initialized");
|
|
2673
2963
|
}
|
|
2674
2964
|
async start(ctx) {
|
|
2675
|
-
console.warn("[Automation:start] entering start()");
|
|
2676
2965
|
if (!this.engine) {
|
|
2677
|
-
|
|
2966
|
+
ctx.logger.warn("[Automation] start() called before init() \u2014 engine missing, skipping");
|
|
2678
2967
|
return;
|
|
2679
2968
|
}
|
|
2680
2969
|
await ctx.trigger("automation:ready", this.engine);
|
|
@@ -2682,6 +2971,7 @@ var AutomationServicePlugin = class {
|
|
|
2682
2971
|
ctx.logger.info(
|
|
2683
2972
|
`[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
|
|
2684
2973
|
);
|
|
2974
|
+
let durableStore = null;
|
|
2685
2975
|
if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
|
|
2686
2976
|
let dataEngine = null;
|
|
2687
2977
|
try {
|
|
@@ -2693,7 +2983,8 @@ var AutomationServicePlugin = class {
|
|
|
2693
2983
|
}
|
|
2694
2984
|
}
|
|
2695
2985
|
if (dataEngine && typeof dataEngine.find === "function" && typeof dataEngine.insert === "function") {
|
|
2696
|
-
|
|
2986
|
+
durableStore = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger);
|
|
2987
|
+
this.engine.setSuspendedRunStore(durableStore);
|
|
2697
2988
|
ctx.logger.info("[Automation] Suspended-run persistence enabled (sys_automation_run)");
|
|
2698
2989
|
} else {
|
|
2699
2990
|
ctx.logger.info("[Automation] No ObjectQL engine \u2014 suspended runs kept in-memory only");
|
|
@@ -2702,14 +2993,14 @@ var AutomationServicePlugin = class {
|
|
|
2702
2993
|
try {
|
|
2703
2994
|
const ql = ctx.getService("objectql");
|
|
2704
2995
|
if (!ql) {
|
|
2705
|
-
|
|
2996
|
+
ctx.logger.debug("[Automation] objectql service not found at start()");
|
|
2706
2997
|
} else if (!ql.registry) {
|
|
2707
|
-
|
|
2998
|
+
ctx.logger.debug("[Automation] objectql.registry is undefined at start()");
|
|
2708
2999
|
} else if (typeof ql.registry.listItems !== "function") {
|
|
2709
|
-
|
|
3000
|
+
ctx.logger.debug("[Automation] objectql.registry.listItems is not a function");
|
|
2710
3001
|
}
|
|
2711
3002
|
const flows = ql?.registry?.listItems?.("flow") ?? [];
|
|
2712
|
-
|
|
3003
|
+
ctx.logger.debug(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
|
|
2713
3004
|
let registered = 0;
|
|
2714
3005
|
for (const f of flows) {
|
|
2715
3006
|
const def = f;
|
|
@@ -2729,6 +3020,21 @@ var AutomationServicePlugin = class {
|
|
|
2729
3020
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2730
3021
|
ctx.logger.warn(`[Automation] flow pull from ObjectQL registry failed: ${msg}`);
|
|
2731
3022
|
}
|
|
3023
|
+
if (durableStore) {
|
|
3024
|
+
let job;
|
|
3025
|
+
try {
|
|
3026
|
+
job = ctx.getService("job");
|
|
3027
|
+
} catch {
|
|
3028
|
+
}
|
|
3029
|
+
try {
|
|
3030
|
+
const rearmed = await rearmSuspendedWaitTimers(this.engine, durableStore, job, ctx.logger);
|
|
3031
|
+
if (rearmed > 0) {
|
|
3032
|
+
ctx.logger.info(`[Automation] Re-armed ${rearmed} suspended wait timer(s) after restart`);
|
|
3033
|
+
}
|
|
3034
|
+
} catch (err) {
|
|
3035
|
+
ctx.logger.warn(`[Automation] wait-timer re-arm failed: ${err.message}`);
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
2732
3038
|
}
|
|
2733
3039
|
async destroy() {
|
|
2734
3040
|
this.engine = void 0;
|