@objectstack/service-automation 8.0.0 → 9.0.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 +223 -16
- 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 +223 -16
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -616,8 +616,24 @@ var AutomationEngine = class {
|
|
|
616
616
|
* restricted to the edge labelled `signal.branchLabel` (e.g. the approval
|
|
617
617
|
* decision). The continuation may itself suspend again, in which case this
|
|
618
618
|
* returns `{ status: 'paused', runId }` afresh.
|
|
619
|
+
*
|
|
620
|
+
* **Subflow chains (nested pause, linked-runs model).** A run paused at a
|
|
621
|
+
* `subflow` node (correlation `subflow:<childRunId>`) DELEGATES the signal
|
|
622
|
+
* down to the suspended child; a run that completes and carries
|
|
623
|
+
* `$parentRunId` in its context BUBBLES its output up by auto-resuming the
|
|
624
|
+
* parent. Both directions compose recursively, so arbitrarily nested
|
|
625
|
+
* subflow pauses resolve from either end (UI holds the parent run id;
|
|
626
|
+
* approval/wait infrastructure holds the child's).
|
|
619
627
|
*/
|
|
620
628
|
async resume(runId, signal) {
|
|
629
|
+
return this.resumeInternal(runId, signal, false);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* @param skipBubble - Set when the caller is the subflow DELEGATION path,
|
|
633
|
+
* which continues the parent itself after the child completes — the
|
|
634
|
+
* child's own up-bubble must stay off so the parent isn't resumed twice.
|
|
635
|
+
*/
|
|
636
|
+
async resumeInternal(runId, signal, skipBubble) {
|
|
621
637
|
if (this.resuming.has(runId)) {
|
|
622
638
|
return { success: false, error: `Run '${runId}' is already being resumed` };
|
|
623
639
|
}
|
|
@@ -644,6 +660,35 @@ var AutomationEngine = class {
|
|
|
644
660
|
if (!node) {
|
|
645
661
|
return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
|
|
646
662
|
}
|
|
663
|
+
if (typeof run.correlation === "string" && run.correlation.startsWith("subflow:")) {
|
|
664
|
+
const childRunId = run.correlation.slice("subflow:".length);
|
|
665
|
+
const childRun = this.suspendedRuns.get(childRunId) ?? (this.store ? await this.store.load(childRunId).catch(() => null) : null);
|
|
666
|
+
if (childRun) {
|
|
667
|
+
const childRes = await this.resumeInternal(childRunId, signal, true);
|
|
668
|
+
if (childRes.status === "paused") {
|
|
669
|
+
if (childRes.screen && childRes.screen !== run.screen) {
|
|
670
|
+
await this.persistSuspendedRun({ ...run, screen: childRes.screen });
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
success: true,
|
|
674
|
+
status: "paused",
|
|
675
|
+
runId,
|
|
676
|
+
durationMs: Date.now() - run.startTime,
|
|
677
|
+
screen: childRes.screen
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
if (!childRes.success) {
|
|
681
|
+
const error = `subflow run '${childRunId}' (${childRun.flowName}) failed: ${childRes.error ?? "unknown error"}`;
|
|
682
|
+
await this.failSuspendedRun(run, error);
|
|
683
|
+
return { success: false, error, durationMs: Date.now() - run.startTime };
|
|
684
|
+
}
|
|
685
|
+
signal = this.buildSubflowResumeSignal(childRun.context, childRes.output);
|
|
686
|
+
} else {
|
|
687
|
+
this.logger.warn(
|
|
688
|
+
`[automation] run '${runId}' is paused at subflow node '${run.nodeId}' but child run '${childRunId}' is gone \u2014 continuing without child output`
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
647
692
|
await this.forgetSuspendedRun(runId);
|
|
648
693
|
const variables = new Map(Object.entries(run.variables));
|
|
649
694
|
if (signal?.output) {
|
|
@@ -683,6 +728,9 @@ var AutomationEngine = class {
|
|
|
683
728
|
steps,
|
|
684
729
|
output
|
|
685
730
|
});
|
|
731
|
+
if (!skipBubble) {
|
|
732
|
+
await this.bubbleToParent(run, output);
|
|
733
|
+
}
|
|
686
734
|
return { success: true, output, durationMs };
|
|
687
735
|
} catch (err) {
|
|
688
736
|
if (isSuspendSignal(err)) {
|
|
@@ -729,12 +777,92 @@ var AutomationEngine = class {
|
|
|
729
777
|
steps,
|
|
730
778
|
error: errorMessage
|
|
731
779
|
});
|
|
780
|
+
if (!skipBubble) {
|
|
781
|
+
await this.failAncestors(run.context, errorMessage);
|
|
782
|
+
}
|
|
732
783
|
return { success: false, error: errorMessage, durationMs };
|
|
733
784
|
}
|
|
734
785
|
} finally {
|
|
735
786
|
this.resuming.delete(runId);
|
|
736
787
|
}
|
|
737
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* Build the resume signal that maps a completed subflow child's output
|
|
791
|
+
* into its parent — mirroring the synchronous path exactly: the engine's
|
|
792
|
+
* standard `signal.output` merge lands it under `${subflowNodeId}.output`,
|
|
793
|
+
* and `signal.variables` writes the bare `config.outputVariable` when the
|
|
794
|
+
* child's context carries one (`$parentOutputVariable`).
|
|
795
|
+
*/
|
|
796
|
+
buildSubflowResumeSignal(childContext, childOutput) {
|
|
797
|
+
const outVar = childContext?.$parentOutputVariable;
|
|
798
|
+
return {
|
|
799
|
+
output: { output: childOutput ?? null },
|
|
800
|
+
...typeof outVar === "string" && outVar ? { variables: { [outVar]: childOutput ?? null } } : {}
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Up-bubble for the subflow chain: when a completed run carries
|
|
805
|
+
* `$parentRunId`, resume that parent with this run's output. Recursion via
|
|
806
|
+
* the parent's own completion bubbles multi-level chains. Best-effort —
|
|
807
|
+
* a failed parent continuation is logged, never thrown back at the
|
|
808
|
+
* caller who resumed the child.
|
|
809
|
+
*/
|
|
810
|
+
async bubbleToParent(run, output) {
|
|
811
|
+
const parentRunId = run.context?.$parentRunId;
|
|
812
|
+
if (typeof parentRunId !== "string" || !parentRunId) return;
|
|
813
|
+
try {
|
|
814
|
+
const sig = this.buildSubflowResumeSignal(run.context, output);
|
|
815
|
+
const parentRes = await this.resumeInternal(parentRunId, sig, false);
|
|
816
|
+
if (!parentRes.success) {
|
|
817
|
+
this.logger.warn(
|
|
818
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' failed: ${parentRes.error}`
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} catch (err) {
|
|
822
|
+
this.logger.warn(
|
|
823
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' threw: ${err.message}`
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Terminally fail a suspended run: consume its continuation and record a
|
|
829
|
+
* `failed` log so it stops surfacing as resumable. Used when a subflow
|
|
830
|
+
* descendant fails — the ancestor awaiting it can never be resumed.
|
|
831
|
+
*/
|
|
832
|
+
async failSuspendedRun(run, error) {
|
|
833
|
+
await this.forgetSuspendedRun(run.runId);
|
|
834
|
+
this.recordLog({
|
|
835
|
+
id: run.runId,
|
|
836
|
+
flowName: run.flowName,
|
|
837
|
+
flowVersion: run.flowVersion,
|
|
838
|
+
status: "failed",
|
|
839
|
+
startedAt: run.startedAt,
|
|
840
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
841
|
+
durationMs: Date.now() - run.startTime,
|
|
842
|
+
trigger: {
|
|
843
|
+
type: run.context?.event ?? "manual",
|
|
844
|
+
userId: run.context?.userId,
|
|
845
|
+
object: run.context?.object
|
|
846
|
+
},
|
|
847
|
+
steps: run.steps,
|
|
848
|
+
error
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Walk a failed run's `$parentRunId` chain and fail each suspended
|
|
853
|
+
* ancestor (see {@link failSuspendedRun}). Bounded so a corrupt context
|
|
854
|
+
* can't loop forever.
|
|
855
|
+
*/
|
|
856
|
+
async failAncestors(context, error) {
|
|
857
|
+
let parentId = context?.$parentRunId;
|
|
858
|
+
let hops = 0;
|
|
859
|
+
while (typeof parentId === "string" && parentId && hops++ < 32) {
|
|
860
|
+
const parent = this.suspendedRuns.get(parentId) ?? (this.store ? await this.store.load(parentId).catch(() => null) : null);
|
|
861
|
+
if (!parent) return;
|
|
862
|
+
await this.failSuspendedRun(parent, `subflow descendant failed: ${error}`);
|
|
863
|
+
parentId = parent.context?.$parentRunId;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
738
866
|
/**
|
|
739
867
|
* List the runs currently suspended awaiting {@link resume} (ADR-0019).
|
|
740
868
|
* Backs operability surfaces such as a "pending approvals" view.
|
|
@@ -2506,10 +2634,11 @@ function registerWaitNode(engine, ctx) {
|
|
|
2506
2634
|
const runId = variables.get("$runId");
|
|
2507
2635
|
if (eventType === "timer") {
|
|
2508
2636
|
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);
|
|
2637
|
+
const at = durationMs && durationMs > 0 ? new Date(Date.now() + durationMs).toISOString() : void 0;
|
|
2638
|
+
const output = at ? { waitUntil: at } : void 0;
|
|
2509
2639
|
const job = getJobService();
|
|
2510
|
-
if (job && runId != null &&
|
|
2640
|
+
if (job && runId != null && at) {
|
|
2511
2641
|
const jobName = `flow-wait:${String(runId)}:${node.id}`;
|
|
2512
|
-
const at = new Date(Date.now() + durationMs).toISOString();
|
|
2513
2642
|
try {
|
|
2514
2643
|
await job.schedule(jobName, { type: "once", at }, async () => {
|
|
2515
2644
|
try {
|
|
@@ -2521,7 +2650,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2521
2650
|
}
|
|
2522
2651
|
}
|
|
2523
2652
|
});
|
|
2524
|
-
return { success: true, suspend: true, correlation: jobName };
|
|
2653
|
+
return { success: true, suspend: true, correlation: jobName, output };
|
|
2525
2654
|
} catch (err) {
|
|
2526
2655
|
ctx.logger.warn(
|
|
2527
2656
|
`[wait] node '${node.id}': failed to schedule timer resume (${err?.message ?? err}); suspending without auto-resume (resume it via resume(runId))`
|
|
@@ -2532,7 +2661,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2532
2661
|
`[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
2662
|
);
|
|
2534
2663
|
}
|
|
2535
|
-
return { success: true, suspend: true, correlation: `timer:${node.id}
|
|
2664
|
+
return { success: true, suspend: true, correlation: `timer:${node.id}`, output };
|
|
2536
2665
|
}
|
|
2537
2666
|
const signal = String(wec.signalName ?? loose.signalName ?? loose.signal ?? `wait:${node.id}`);
|
|
2538
2667
|
return { success: true, suspend: true, correlation: signal };
|
|
@@ -2540,6 +2669,54 @@ function registerWaitNode(engine, ctx) {
|
|
|
2540
2669
|
});
|
|
2541
2670
|
ctx.logger.info("[Wait Node] 1 built-in node executor registered");
|
|
2542
2671
|
}
|
|
2672
|
+
async function rearmSuspendedWaitTimers(engine, store, job, logger) {
|
|
2673
|
+
let runs;
|
|
2674
|
+
try {
|
|
2675
|
+
runs = await store.list();
|
|
2676
|
+
} catch (err) {
|
|
2677
|
+
logger.warn(`[wait] timer re-arm: failed to list suspended runs: ${err?.message ?? err}`);
|
|
2678
|
+
return 0;
|
|
2679
|
+
}
|
|
2680
|
+
let rearmed = 0;
|
|
2681
|
+
for (const run of runs) {
|
|
2682
|
+
const wakeAt = run.variables?.[`${run.nodeId}.waitUntil`];
|
|
2683
|
+
if (typeof wakeAt !== "string" || !wakeAt) continue;
|
|
2684
|
+
const atMs = Date.parse(wakeAt);
|
|
2685
|
+
if (Number.isNaN(atMs)) continue;
|
|
2686
|
+
if (atMs <= Date.now()) {
|
|
2687
|
+
try {
|
|
2688
|
+
await engine.resume(run.runId);
|
|
2689
|
+
rearmed++;
|
|
2690
|
+
} catch (err) {
|
|
2691
|
+
logger.warn(`[wait] timer re-arm: resume of overdue run '${run.runId}' failed: ${err?.message ?? err}`);
|
|
2692
|
+
}
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2695
|
+
if (!job) {
|
|
2696
|
+
logger.warn(
|
|
2697
|
+
`[wait] timer re-arm: run '${run.runId}' waits until ${wakeAt} but no job service is registered \u2014 resume it externally via resume(runId)`
|
|
2698
|
+
);
|
|
2699
|
+
continue;
|
|
2700
|
+
}
|
|
2701
|
+
const jobName = `flow-wait:${run.runId}:${run.nodeId}`;
|
|
2702
|
+
try {
|
|
2703
|
+
await job.schedule(jobName, { type: "once", at: wakeAt }, async () => {
|
|
2704
|
+
try {
|
|
2705
|
+
await engine.resume(run.runId);
|
|
2706
|
+
} finally {
|
|
2707
|
+
try {
|
|
2708
|
+
await job.cancel?.(jobName);
|
|
2709
|
+
} catch {
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
rearmed++;
|
|
2714
|
+
} catch (err) {
|
|
2715
|
+
logger.warn(`[wait] timer re-arm: failed to re-schedule run '${run.runId}': ${err?.message ?? err}`);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
return rearmed;
|
|
2719
|
+
}
|
|
2543
2720
|
function parseIsoDuration(input) {
|
|
2544
2721
|
if (typeof input === "number" && Number.isFinite(input)) return input > 0 ? input : void 0;
|
|
2545
2722
|
if (typeof input !== "string") return void 0;
|
|
@@ -2571,7 +2748,10 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2571
2748
|
description: "Invoke another flow as a reusable step and capture its output.",
|
|
2572
2749
|
icon: "workflow",
|
|
2573
2750
|
category: "logic",
|
|
2574
|
-
source: "builtin"
|
|
2751
|
+
source: "builtin",
|
|
2752
|
+
// A child that suspends (approval/screen/wait) suspends this node too —
|
|
2753
|
+
// the parent run pauses here and resumes when the child completes.
|
|
2754
|
+
supportsPause: true
|
|
2575
2755
|
}),
|
|
2576
2756
|
async execute(node, variables, context) {
|
|
2577
2757
|
const cfg = node.config ?? {};
|
|
@@ -2588,22 +2768,33 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2588
2768
|
}
|
|
2589
2769
|
const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
|
|
2590
2770
|
const params = interpolate(rawInput, variables, context ?? {});
|
|
2771
|
+
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2772
|
+
const parentRunId = variables.get("$runId");
|
|
2591
2773
|
const childContext = {
|
|
2592
2774
|
...context ?? {},
|
|
2593
2775
|
$subflowDepth: depth + 1,
|
|
2594
|
-
params
|
|
2776
|
+
params,
|
|
2777
|
+
...parentRunId != null ? {
|
|
2778
|
+
$parentRunId: String(parentRunId),
|
|
2779
|
+
$parentNodeId: node.id,
|
|
2780
|
+
...outVar ? { $parentOutputVariable: outVar } : {}
|
|
2781
|
+
} : {}
|
|
2595
2782
|
};
|
|
2596
2783
|
const child = await engine.execute(flowName, childContext);
|
|
2597
2784
|
if (child.status === "paused") {
|
|
2785
|
+
if (!child.runId) {
|
|
2786
|
+
return { success: false, error: `subflow '${flowName}' paused without a run id \u2014 cannot link the runs` };
|
|
2787
|
+
}
|
|
2598
2788
|
return {
|
|
2599
|
-
success:
|
|
2600
|
-
|
|
2789
|
+
success: true,
|
|
2790
|
+
suspend: true,
|
|
2791
|
+
correlation: `subflow:${child.runId}`,
|
|
2792
|
+
screen: child.screen
|
|
2601
2793
|
};
|
|
2602
2794
|
}
|
|
2603
2795
|
if (!child.success) {
|
|
2604
2796
|
return { success: false, error: `subflow '${flowName}' failed: ${child.error ?? "unknown error"}` };
|
|
2605
2797
|
}
|
|
2606
|
-
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2607
2798
|
if (outVar) variables.set(outVar, child.output ?? null);
|
|
2608
2799
|
return { success: true, output: { output: child.output ?? null } };
|
|
2609
2800
|
}
|
|
@@ -2672,9 +2863,8 @@ var AutomationServicePlugin = class {
|
|
|
2672
2863
|
ctx.logger.info("[Automation] Engine initialized");
|
|
2673
2864
|
}
|
|
2674
2865
|
async start(ctx) {
|
|
2675
|
-
console.warn("[Automation:start] entering start()");
|
|
2676
2866
|
if (!this.engine) {
|
|
2677
|
-
|
|
2867
|
+
ctx.logger.warn("[Automation] start() called before init() \u2014 engine missing, skipping");
|
|
2678
2868
|
return;
|
|
2679
2869
|
}
|
|
2680
2870
|
await ctx.trigger("automation:ready", this.engine);
|
|
@@ -2682,6 +2872,7 @@ var AutomationServicePlugin = class {
|
|
|
2682
2872
|
ctx.logger.info(
|
|
2683
2873
|
`[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
|
|
2684
2874
|
);
|
|
2875
|
+
let durableStore = null;
|
|
2685
2876
|
if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
|
|
2686
2877
|
let dataEngine = null;
|
|
2687
2878
|
try {
|
|
@@ -2693,7 +2884,8 @@ var AutomationServicePlugin = class {
|
|
|
2693
2884
|
}
|
|
2694
2885
|
}
|
|
2695
2886
|
if (dataEngine && typeof dataEngine.find === "function" && typeof dataEngine.insert === "function") {
|
|
2696
|
-
|
|
2887
|
+
durableStore = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger);
|
|
2888
|
+
this.engine.setSuspendedRunStore(durableStore);
|
|
2697
2889
|
ctx.logger.info("[Automation] Suspended-run persistence enabled (sys_automation_run)");
|
|
2698
2890
|
} else {
|
|
2699
2891
|
ctx.logger.info("[Automation] No ObjectQL engine \u2014 suspended runs kept in-memory only");
|
|
@@ -2702,14 +2894,14 @@ var AutomationServicePlugin = class {
|
|
|
2702
2894
|
try {
|
|
2703
2895
|
const ql = ctx.getService("objectql");
|
|
2704
2896
|
if (!ql) {
|
|
2705
|
-
|
|
2897
|
+
ctx.logger.debug("[Automation] objectql service not found at start()");
|
|
2706
2898
|
} else if (!ql.registry) {
|
|
2707
|
-
|
|
2899
|
+
ctx.logger.debug("[Automation] objectql.registry is undefined at start()");
|
|
2708
2900
|
} else if (typeof ql.registry.listItems !== "function") {
|
|
2709
|
-
|
|
2901
|
+
ctx.logger.debug("[Automation] objectql.registry.listItems is not a function");
|
|
2710
2902
|
}
|
|
2711
2903
|
const flows = ql?.registry?.listItems?.("flow") ?? [];
|
|
2712
|
-
|
|
2904
|
+
ctx.logger.debug(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
|
|
2713
2905
|
let registered = 0;
|
|
2714
2906
|
for (const f of flows) {
|
|
2715
2907
|
const def = f;
|
|
@@ -2729,6 +2921,21 @@ var AutomationServicePlugin = class {
|
|
|
2729
2921
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2730
2922
|
ctx.logger.warn(`[Automation] flow pull from ObjectQL registry failed: ${msg}`);
|
|
2731
2923
|
}
|
|
2924
|
+
if (durableStore) {
|
|
2925
|
+
let job;
|
|
2926
|
+
try {
|
|
2927
|
+
job = ctx.getService("job");
|
|
2928
|
+
} catch {
|
|
2929
|
+
}
|
|
2930
|
+
try {
|
|
2931
|
+
const rearmed = await rearmSuspendedWaitTimers(this.engine, durableStore, job, ctx.logger);
|
|
2932
|
+
if (rearmed > 0) {
|
|
2933
|
+
ctx.logger.info(`[Automation] Re-armed ${rearmed} suspended wait timer(s) after restart`);
|
|
2934
|
+
}
|
|
2935
|
+
} catch (err) {
|
|
2936
|
+
ctx.logger.warn(`[Automation] wait-timer re-arm failed: ${err.message}`);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2732
2939
|
}
|
|
2733
2940
|
async destroy() {
|
|
2734
2941
|
this.engine = void 0;
|