@objectstack/service-automation 8.0.1 → 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.d.cts
CHANGED
|
@@ -438,8 +438,50 @@ declare class AutomationEngine implements IAutomationService {
|
|
|
438
438
|
* restricted to the edge labelled `signal.branchLabel` (e.g. the approval
|
|
439
439
|
* decision). The continuation may itself suspend again, in which case this
|
|
440
440
|
* returns `{ status: 'paused', runId }` afresh.
|
|
441
|
+
*
|
|
442
|
+
* **Subflow chains (nested pause, linked-runs model).** A run paused at a
|
|
443
|
+
* `subflow` node (correlation `subflow:<childRunId>`) DELEGATES the signal
|
|
444
|
+
* down to the suspended child; a run that completes and carries
|
|
445
|
+
* `$parentRunId` in its context BUBBLES its output up by auto-resuming the
|
|
446
|
+
* parent. Both directions compose recursively, so arbitrarily nested
|
|
447
|
+
* subflow pauses resolve from either end (UI holds the parent run id;
|
|
448
|
+
* approval/wait infrastructure holds the child's).
|
|
441
449
|
*/
|
|
442
450
|
resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult>;
|
|
451
|
+
/**
|
|
452
|
+
* @param skipBubble - Set when the caller is the subflow DELEGATION path,
|
|
453
|
+
* which continues the parent itself after the child completes — the
|
|
454
|
+
* child's own up-bubble must stay off so the parent isn't resumed twice.
|
|
455
|
+
*/
|
|
456
|
+
private resumeInternal;
|
|
457
|
+
/**
|
|
458
|
+
* Build the resume signal that maps a completed subflow child's output
|
|
459
|
+
* into its parent — mirroring the synchronous path exactly: the engine's
|
|
460
|
+
* standard `signal.output` merge lands it under `${subflowNodeId}.output`,
|
|
461
|
+
* and `signal.variables` writes the bare `config.outputVariable` when the
|
|
462
|
+
* child's context carries one (`$parentOutputVariable`).
|
|
463
|
+
*/
|
|
464
|
+
private buildSubflowResumeSignal;
|
|
465
|
+
/**
|
|
466
|
+
* Up-bubble for the subflow chain: when a completed run carries
|
|
467
|
+
* `$parentRunId`, resume that parent with this run's output. Recursion via
|
|
468
|
+
* the parent's own completion bubbles multi-level chains. Best-effort —
|
|
469
|
+
* a failed parent continuation is logged, never thrown back at the
|
|
470
|
+
* caller who resumed the child.
|
|
471
|
+
*/
|
|
472
|
+
private bubbleToParent;
|
|
473
|
+
/**
|
|
474
|
+
* Terminally fail a suspended run: consume its continuation and record a
|
|
475
|
+
* `failed` log so it stops surfacing as resumable. Used when a subflow
|
|
476
|
+
* descendant fails — the ancestor awaiting it can never be resumed.
|
|
477
|
+
*/
|
|
478
|
+
private failSuspendedRun;
|
|
479
|
+
/**
|
|
480
|
+
* Walk a failed run's `$parentRunId` chain and fail each suspended
|
|
481
|
+
* ancestor (see {@link failSuspendedRun}). Bounded so a corrupt context
|
|
482
|
+
* can't loop forever.
|
|
483
|
+
*/
|
|
484
|
+
private failAncestors;
|
|
443
485
|
/**
|
|
444
486
|
* List the runs currently suspended awaiting {@link resume} (ADR-0019).
|
|
445
487
|
* Backs operability surfaces such as a "pending approvals" view.
|
|
@@ -1146,10 +1188,9 @@ declare const SysAutomationRun: Omit<{
|
|
|
1146
1188
|
} | undefined;
|
|
1147
1189
|
chart?: {
|
|
1148
1190
|
chartType: "bar" | "line" | "pie" | "area" | "scatter";
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
groupByField?: string | undefined;
|
|
1191
|
+
dataset: string;
|
|
1192
|
+
values: string[];
|
|
1193
|
+
dimensions?: string[] | undefined;
|
|
1153
1194
|
} | undefined;
|
|
1154
1195
|
description?: string | undefined;
|
|
1155
1196
|
sharing?: {
|
package/dist/index.d.ts
CHANGED
|
@@ -438,8 +438,50 @@ declare class AutomationEngine implements IAutomationService {
|
|
|
438
438
|
* restricted to the edge labelled `signal.branchLabel` (e.g. the approval
|
|
439
439
|
* decision). The continuation may itself suspend again, in which case this
|
|
440
440
|
* returns `{ status: 'paused', runId }` afresh.
|
|
441
|
+
*
|
|
442
|
+
* **Subflow chains (nested pause, linked-runs model).** A run paused at a
|
|
443
|
+
* `subflow` node (correlation `subflow:<childRunId>`) DELEGATES the signal
|
|
444
|
+
* down to the suspended child; a run that completes and carries
|
|
445
|
+
* `$parentRunId` in its context BUBBLES its output up by auto-resuming the
|
|
446
|
+
* parent. Both directions compose recursively, so arbitrarily nested
|
|
447
|
+
* subflow pauses resolve from either end (UI holds the parent run id;
|
|
448
|
+
* approval/wait infrastructure holds the child's).
|
|
441
449
|
*/
|
|
442
450
|
resume(runId: string, signal?: ResumeSignal): Promise<AutomationResult>;
|
|
451
|
+
/**
|
|
452
|
+
* @param skipBubble - Set when the caller is the subflow DELEGATION path,
|
|
453
|
+
* which continues the parent itself after the child completes — the
|
|
454
|
+
* child's own up-bubble must stay off so the parent isn't resumed twice.
|
|
455
|
+
*/
|
|
456
|
+
private resumeInternal;
|
|
457
|
+
/**
|
|
458
|
+
* Build the resume signal that maps a completed subflow child's output
|
|
459
|
+
* into its parent — mirroring the synchronous path exactly: the engine's
|
|
460
|
+
* standard `signal.output` merge lands it under `${subflowNodeId}.output`,
|
|
461
|
+
* and `signal.variables` writes the bare `config.outputVariable` when the
|
|
462
|
+
* child's context carries one (`$parentOutputVariable`).
|
|
463
|
+
*/
|
|
464
|
+
private buildSubflowResumeSignal;
|
|
465
|
+
/**
|
|
466
|
+
* Up-bubble for the subflow chain: when a completed run carries
|
|
467
|
+
* `$parentRunId`, resume that parent with this run's output. Recursion via
|
|
468
|
+
* the parent's own completion bubbles multi-level chains. Best-effort —
|
|
469
|
+
* a failed parent continuation is logged, never thrown back at the
|
|
470
|
+
* caller who resumed the child.
|
|
471
|
+
*/
|
|
472
|
+
private bubbleToParent;
|
|
473
|
+
/**
|
|
474
|
+
* Terminally fail a suspended run: consume its continuation and record a
|
|
475
|
+
* `failed` log so it stops surfacing as resumable. Used when a subflow
|
|
476
|
+
* descendant fails — the ancestor awaiting it can never be resumed.
|
|
477
|
+
*/
|
|
478
|
+
private failSuspendedRun;
|
|
479
|
+
/**
|
|
480
|
+
* Walk a failed run's `$parentRunId` chain and fail each suspended
|
|
481
|
+
* ancestor (see {@link failSuspendedRun}). Bounded so a corrupt context
|
|
482
|
+
* can't loop forever.
|
|
483
|
+
*/
|
|
484
|
+
private failAncestors;
|
|
443
485
|
/**
|
|
444
486
|
* List the runs currently suspended awaiting {@link resume} (ADR-0019).
|
|
445
487
|
* Backs operability surfaces such as a "pending approvals" view.
|
|
@@ -1146,10 +1188,9 @@ declare const SysAutomationRun: Omit<{
|
|
|
1146
1188
|
} | undefined;
|
|
1147
1189
|
chart?: {
|
|
1148
1190
|
chartType: "bar" | "line" | "pie" | "area" | "scatter";
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
groupByField?: string | undefined;
|
|
1191
|
+
dataset: string;
|
|
1192
|
+
values: string[];
|
|
1193
|
+
dimensions?: string[] | undefined;
|
|
1153
1194
|
} | undefined;
|
|
1154
1195
|
description?: string | undefined;
|
|
1155
1196
|
sharing?: {
|
package/dist/index.js
CHANGED
|
@@ -580,8 +580,24 @@ var AutomationEngine = class {
|
|
|
580
580
|
* restricted to the edge labelled `signal.branchLabel` (e.g. the approval
|
|
581
581
|
* decision). The continuation may itself suspend again, in which case this
|
|
582
582
|
* returns `{ status: 'paused', runId }` afresh.
|
|
583
|
+
*
|
|
584
|
+
* **Subflow chains (nested pause, linked-runs model).** A run paused at a
|
|
585
|
+
* `subflow` node (correlation `subflow:<childRunId>`) DELEGATES the signal
|
|
586
|
+
* down to the suspended child; a run that completes and carries
|
|
587
|
+
* `$parentRunId` in its context BUBBLES its output up by auto-resuming the
|
|
588
|
+
* parent. Both directions compose recursively, so arbitrarily nested
|
|
589
|
+
* subflow pauses resolve from either end (UI holds the parent run id;
|
|
590
|
+
* approval/wait infrastructure holds the child's).
|
|
583
591
|
*/
|
|
584
592
|
async resume(runId, signal) {
|
|
593
|
+
return this.resumeInternal(runId, signal, false);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* @param skipBubble - Set when the caller is the subflow DELEGATION path,
|
|
597
|
+
* which continues the parent itself after the child completes — the
|
|
598
|
+
* child's own up-bubble must stay off so the parent isn't resumed twice.
|
|
599
|
+
*/
|
|
600
|
+
async resumeInternal(runId, signal, skipBubble) {
|
|
585
601
|
if (this.resuming.has(runId)) {
|
|
586
602
|
return { success: false, error: `Run '${runId}' is already being resumed` };
|
|
587
603
|
}
|
|
@@ -608,6 +624,35 @@ var AutomationEngine = class {
|
|
|
608
624
|
if (!node) {
|
|
609
625
|
return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
|
|
610
626
|
}
|
|
627
|
+
if (typeof run.correlation === "string" && run.correlation.startsWith("subflow:")) {
|
|
628
|
+
const childRunId = run.correlation.slice("subflow:".length);
|
|
629
|
+
const childRun = this.suspendedRuns.get(childRunId) ?? (this.store ? await this.store.load(childRunId).catch(() => null) : null);
|
|
630
|
+
if (childRun) {
|
|
631
|
+
const childRes = await this.resumeInternal(childRunId, signal, true);
|
|
632
|
+
if (childRes.status === "paused") {
|
|
633
|
+
if (childRes.screen && childRes.screen !== run.screen) {
|
|
634
|
+
await this.persistSuspendedRun({ ...run, screen: childRes.screen });
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
success: true,
|
|
638
|
+
status: "paused",
|
|
639
|
+
runId,
|
|
640
|
+
durationMs: Date.now() - run.startTime,
|
|
641
|
+
screen: childRes.screen
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (!childRes.success) {
|
|
645
|
+
const error = `subflow run '${childRunId}' (${childRun.flowName}) failed: ${childRes.error ?? "unknown error"}`;
|
|
646
|
+
await this.failSuspendedRun(run, error);
|
|
647
|
+
return { success: false, error, durationMs: Date.now() - run.startTime };
|
|
648
|
+
}
|
|
649
|
+
signal = this.buildSubflowResumeSignal(childRun.context, childRes.output);
|
|
650
|
+
} else {
|
|
651
|
+
this.logger.warn(
|
|
652
|
+
`[automation] run '${runId}' is paused at subflow node '${run.nodeId}' but child run '${childRunId}' is gone \u2014 continuing without child output`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
611
656
|
await this.forgetSuspendedRun(runId);
|
|
612
657
|
const variables = new Map(Object.entries(run.variables));
|
|
613
658
|
if (signal?.output) {
|
|
@@ -647,6 +692,9 @@ var AutomationEngine = class {
|
|
|
647
692
|
steps,
|
|
648
693
|
output
|
|
649
694
|
});
|
|
695
|
+
if (!skipBubble) {
|
|
696
|
+
await this.bubbleToParent(run, output);
|
|
697
|
+
}
|
|
650
698
|
return { success: true, output, durationMs };
|
|
651
699
|
} catch (err) {
|
|
652
700
|
if (isSuspendSignal(err)) {
|
|
@@ -693,12 +741,92 @@ var AutomationEngine = class {
|
|
|
693
741
|
steps,
|
|
694
742
|
error: errorMessage
|
|
695
743
|
});
|
|
744
|
+
if (!skipBubble) {
|
|
745
|
+
await this.failAncestors(run.context, errorMessage);
|
|
746
|
+
}
|
|
696
747
|
return { success: false, error: errorMessage, durationMs };
|
|
697
748
|
}
|
|
698
749
|
} finally {
|
|
699
750
|
this.resuming.delete(runId);
|
|
700
751
|
}
|
|
701
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* Build the resume signal that maps a completed subflow child's output
|
|
755
|
+
* into its parent — mirroring the synchronous path exactly: the engine's
|
|
756
|
+
* standard `signal.output` merge lands it under `${subflowNodeId}.output`,
|
|
757
|
+
* and `signal.variables` writes the bare `config.outputVariable` when the
|
|
758
|
+
* child's context carries one (`$parentOutputVariable`).
|
|
759
|
+
*/
|
|
760
|
+
buildSubflowResumeSignal(childContext, childOutput) {
|
|
761
|
+
const outVar = childContext?.$parentOutputVariable;
|
|
762
|
+
return {
|
|
763
|
+
output: { output: childOutput ?? null },
|
|
764
|
+
...typeof outVar === "string" && outVar ? { variables: { [outVar]: childOutput ?? null } } : {}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Up-bubble for the subflow chain: when a completed run carries
|
|
769
|
+
* `$parentRunId`, resume that parent with this run's output. Recursion via
|
|
770
|
+
* the parent's own completion bubbles multi-level chains. Best-effort —
|
|
771
|
+
* a failed parent continuation is logged, never thrown back at the
|
|
772
|
+
* caller who resumed the child.
|
|
773
|
+
*/
|
|
774
|
+
async bubbleToParent(run, output) {
|
|
775
|
+
const parentRunId = run.context?.$parentRunId;
|
|
776
|
+
if (typeof parentRunId !== "string" || !parentRunId) return;
|
|
777
|
+
try {
|
|
778
|
+
const sig = this.buildSubflowResumeSignal(run.context, output);
|
|
779
|
+
const parentRes = await this.resumeInternal(parentRunId, sig, false);
|
|
780
|
+
if (!parentRes.success) {
|
|
781
|
+
this.logger.warn(
|
|
782
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' failed: ${parentRes.error}`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
} catch (err) {
|
|
786
|
+
this.logger.warn(
|
|
787
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' threw: ${err.message}`
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Terminally fail a suspended run: consume its continuation and record a
|
|
793
|
+
* `failed` log so it stops surfacing as resumable. Used when a subflow
|
|
794
|
+
* descendant fails — the ancestor awaiting it can never be resumed.
|
|
795
|
+
*/
|
|
796
|
+
async failSuspendedRun(run, error) {
|
|
797
|
+
await this.forgetSuspendedRun(run.runId);
|
|
798
|
+
this.recordLog({
|
|
799
|
+
id: run.runId,
|
|
800
|
+
flowName: run.flowName,
|
|
801
|
+
flowVersion: run.flowVersion,
|
|
802
|
+
status: "failed",
|
|
803
|
+
startedAt: run.startedAt,
|
|
804
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
805
|
+
durationMs: Date.now() - run.startTime,
|
|
806
|
+
trigger: {
|
|
807
|
+
type: run.context?.event ?? "manual",
|
|
808
|
+
userId: run.context?.userId,
|
|
809
|
+
object: run.context?.object
|
|
810
|
+
},
|
|
811
|
+
steps: run.steps,
|
|
812
|
+
error
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Walk a failed run's `$parentRunId` chain and fail each suspended
|
|
817
|
+
* ancestor (see {@link failSuspendedRun}). Bounded so a corrupt context
|
|
818
|
+
* can't loop forever.
|
|
819
|
+
*/
|
|
820
|
+
async failAncestors(context, error) {
|
|
821
|
+
let parentId = context?.$parentRunId;
|
|
822
|
+
let hops = 0;
|
|
823
|
+
while (typeof parentId === "string" && parentId && hops++ < 32) {
|
|
824
|
+
const parent = this.suspendedRuns.get(parentId) ?? (this.store ? await this.store.load(parentId).catch(() => null) : null);
|
|
825
|
+
if (!parent) return;
|
|
826
|
+
await this.failSuspendedRun(parent, `subflow descendant failed: ${error}`);
|
|
827
|
+
parentId = parent.context?.$parentRunId;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
702
830
|
/**
|
|
703
831
|
* List the runs currently suspended awaiting {@link resume} (ADR-0019).
|
|
704
832
|
* Backs operability surfaces such as a "pending approvals" view.
|
|
@@ -2470,10 +2598,11 @@ function registerWaitNode(engine, ctx) {
|
|
|
2470
2598
|
const runId = variables.get("$runId");
|
|
2471
2599
|
if (eventType === "timer") {
|
|
2472
2600
|
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);
|
|
2601
|
+
const at = durationMs && durationMs > 0 ? new Date(Date.now() + durationMs).toISOString() : void 0;
|
|
2602
|
+
const output = at ? { waitUntil: at } : void 0;
|
|
2473
2603
|
const job = getJobService();
|
|
2474
|
-
if (job && runId != null &&
|
|
2604
|
+
if (job && runId != null && at) {
|
|
2475
2605
|
const jobName = `flow-wait:${String(runId)}:${node.id}`;
|
|
2476
|
-
const at = new Date(Date.now() + durationMs).toISOString();
|
|
2477
2606
|
try {
|
|
2478
2607
|
await job.schedule(jobName, { type: "once", at }, async () => {
|
|
2479
2608
|
try {
|
|
@@ -2485,7 +2614,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2485
2614
|
}
|
|
2486
2615
|
}
|
|
2487
2616
|
});
|
|
2488
|
-
return { success: true, suspend: true, correlation: jobName };
|
|
2617
|
+
return { success: true, suspend: true, correlation: jobName, output };
|
|
2489
2618
|
} catch (err) {
|
|
2490
2619
|
ctx.logger.warn(
|
|
2491
2620
|
`[wait] node '${node.id}': failed to schedule timer resume (${err?.message ?? err}); suspending without auto-resume (resume it via resume(runId))`
|
|
@@ -2496,7 +2625,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2496
2625
|
`[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)`
|
|
2497
2626
|
);
|
|
2498
2627
|
}
|
|
2499
|
-
return { success: true, suspend: true, correlation: `timer:${node.id}
|
|
2628
|
+
return { success: true, suspend: true, correlation: `timer:${node.id}`, output };
|
|
2500
2629
|
}
|
|
2501
2630
|
const signal = String(wec.signalName ?? loose.signalName ?? loose.signal ?? `wait:${node.id}`);
|
|
2502
2631
|
return { success: true, suspend: true, correlation: signal };
|
|
@@ -2504,6 +2633,54 @@ function registerWaitNode(engine, ctx) {
|
|
|
2504
2633
|
});
|
|
2505
2634
|
ctx.logger.info("[Wait Node] 1 built-in node executor registered");
|
|
2506
2635
|
}
|
|
2636
|
+
async function rearmSuspendedWaitTimers(engine, store, job, logger) {
|
|
2637
|
+
let runs;
|
|
2638
|
+
try {
|
|
2639
|
+
runs = await store.list();
|
|
2640
|
+
} catch (err) {
|
|
2641
|
+
logger.warn(`[wait] timer re-arm: failed to list suspended runs: ${err?.message ?? err}`);
|
|
2642
|
+
return 0;
|
|
2643
|
+
}
|
|
2644
|
+
let rearmed = 0;
|
|
2645
|
+
for (const run of runs) {
|
|
2646
|
+
const wakeAt = run.variables?.[`${run.nodeId}.waitUntil`];
|
|
2647
|
+
if (typeof wakeAt !== "string" || !wakeAt) continue;
|
|
2648
|
+
const atMs = Date.parse(wakeAt);
|
|
2649
|
+
if (Number.isNaN(atMs)) continue;
|
|
2650
|
+
if (atMs <= Date.now()) {
|
|
2651
|
+
try {
|
|
2652
|
+
await engine.resume(run.runId);
|
|
2653
|
+
rearmed++;
|
|
2654
|
+
} catch (err) {
|
|
2655
|
+
logger.warn(`[wait] timer re-arm: resume of overdue run '${run.runId}' failed: ${err?.message ?? err}`);
|
|
2656
|
+
}
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
if (!job) {
|
|
2660
|
+
logger.warn(
|
|
2661
|
+
`[wait] timer re-arm: run '${run.runId}' waits until ${wakeAt} but no job service is registered \u2014 resume it externally via resume(runId)`
|
|
2662
|
+
);
|
|
2663
|
+
continue;
|
|
2664
|
+
}
|
|
2665
|
+
const jobName = `flow-wait:${run.runId}:${run.nodeId}`;
|
|
2666
|
+
try {
|
|
2667
|
+
await job.schedule(jobName, { type: "once", at: wakeAt }, async () => {
|
|
2668
|
+
try {
|
|
2669
|
+
await engine.resume(run.runId);
|
|
2670
|
+
} finally {
|
|
2671
|
+
try {
|
|
2672
|
+
await job.cancel?.(jobName);
|
|
2673
|
+
} catch {
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
rearmed++;
|
|
2678
|
+
} catch (err) {
|
|
2679
|
+
logger.warn(`[wait] timer re-arm: failed to re-schedule run '${run.runId}': ${err?.message ?? err}`);
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
return rearmed;
|
|
2683
|
+
}
|
|
2507
2684
|
function parseIsoDuration(input) {
|
|
2508
2685
|
if (typeof input === "number" && Number.isFinite(input)) return input > 0 ? input : void 0;
|
|
2509
2686
|
if (typeof input !== "string") return void 0;
|
|
@@ -2535,7 +2712,10 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2535
2712
|
description: "Invoke another flow as a reusable step and capture its output.",
|
|
2536
2713
|
icon: "workflow",
|
|
2537
2714
|
category: "logic",
|
|
2538
|
-
source: "builtin"
|
|
2715
|
+
source: "builtin",
|
|
2716
|
+
// A child that suspends (approval/screen/wait) suspends this node too —
|
|
2717
|
+
// the parent run pauses here and resumes when the child completes.
|
|
2718
|
+
supportsPause: true
|
|
2539
2719
|
}),
|
|
2540
2720
|
async execute(node, variables, context) {
|
|
2541
2721
|
const cfg = node.config ?? {};
|
|
@@ -2552,22 +2732,33 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2552
2732
|
}
|
|
2553
2733
|
const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
|
|
2554
2734
|
const params = interpolate(rawInput, variables, context ?? {});
|
|
2735
|
+
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2736
|
+
const parentRunId = variables.get("$runId");
|
|
2555
2737
|
const childContext = {
|
|
2556
2738
|
...context ?? {},
|
|
2557
2739
|
$subflowDepth: depth + 1,
|
|
2558
|
-
params
|
|
2740
|
+
params,
|
|
2741
|
+
...parentRunId != null ? {
|
|
2742
|
+
$parentRunId: String(parentRunId),
|
|
2743
|
+
$parentNodeId: node.id,
|
|
2744
|
+
...outVar ? { $parentOutputVariable: outVar } : {}
|
|
2745
|
+
} : {}
|
|
2559
2746
|
};
|
|
2560
2747
|
const child = await engine.execute(flowName, childContext);
|
|
2561
2748
|
if (child.status === "paused") {
|
|
2749
|
+
if (!child.runId) {
|
|
2750
|
+
return { success: false, error: `subflow '${flowName}' paused without a run id \u2014 cannot link the runs` };
|
|
2751
|
+
}
|
|
2562
2752
|
return {
|
|
2563
|
-
success:
|
|
2564
|
-
|
|
2753
|
+
success: true,
|
|
2754
|
+
suspend: true,
|
|
2755
|
+
correlation: `subflow:${child.runId}`,
|
|
2756
|
+
screen: child.screen
|
|
2565
2757
|
};
|
|
2566
2758
|
}
|
|
2567
2759
|
if (!child.success) {
|
|
2568
2760
|
return { success: false, error: `subflow '${flowName}' failed: ${child.error ?? "unknown error"}` };
|
|
2569
2761
|
}
|
|
2570
|
-
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2571
2762
|
if (outVar) variables.set(outVar, child.output ?? null);
|
|
2572
2763
|
return { success: true, output: { output: child.output ?? null } };
|
|
2573
2764
|
}
|
|
@@ -2636,9 +2827,8 @@ var AutomationServicePlugin = class {
|
|
|
2636
2827
|
ctx.logger.info("[Automation] Engine initialized");
|
|
2637
2828
|
}
|
|
2638
2829
|
async start(ctx) {
|
|
2639
|
-
console.warn("[Automation:start] entering start()");
|
|
2640
2830
|
if (!this.engine) {
|
|
2641
|
-
|
|
2831
|
+
ctx.logger.warn("[Automation] start() called before init() \u2014 engine missing, skipping");
|
|
2642
2832
|
return;
|
|
2643
2833
|
}
|
|
2644
2834
|
await ctx.trigger("automation:ready", this.engine);
|
|
@@ -2646,6 +2836,7 @@ var AutomationServicePlugin = class {
|
|
|
2646
2836
|
ctx.logger.info(
|
|
2647
2837
|
`[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
|
|
2648
2838
|
);
|
|
2839
|
+
let durableStore = null;
|
|
2649
2840
|
if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
|
|
2650
2841
|
let dataEngine = null;
|
|
2651
2842
|
try {
|
|
@@ -2657,7 +2848,8 @@ var AutomationServicePlugin = class {
|
|
|
2657
2848
|
}
|
|
2658
2849
|
}
|
|
2659
2850
|
if (dataEngine && typeof dataEngine.find === "function" && typeof dataEngine.insert === "function") {
|
|
2660
|
-
|
|
2851
|
+
durableStore = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger);
|
|
2852
|
+
this.engine.setSuspendedRunStore(durableStore);
|
|
2661
2853
|
ctx.logger.info("[Automation] Suspended-run persistence enabled (sys_automation_run)");
|
|
2662
2854
|
} else {
|
|
2663
2855
|
ctx.logger.info("[Automation] No ObjectQL engine \u2014 suspended runs kept in-memory only");
|
|
@@ -2666,14 +2858,14 @@ var AutomationServicePlugin = class {
|
|
|
2666
2858
|
try {
|
|
2667
2859
|
const ql = ctx.getService("objectql");
|
|
2668
2860
|
if (!ql) {
|
|
2669
|
-
|
|
2861
|
+
ctx.logger.debug("[Automation] objectql service not found at start()");
|
|
2670
2862
|
} else if (!ql.registry) {
|
|
2671
|
-
|
|
2863
|
+
ctx.logger.debug("[Automation] objectql.registry is undefined at start()");
|
|
2672
2864
|
} else if (typeof ql.registry.listItems !== "function") {
|
|
2673
|
-
|
|
2865
|
+
ctx.logger.debug("[Automation] objectql.registry.listItems is not a function");
|
|
2674
2866
|
}
|
|
2675
2867
|
const flows = ql?.registry?.listItems?.("flow") ?? [];
|
|
2676
|
-
|
|
2868
|
+
ctx.logger.debug(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
|
|
2677
2869
|
let registered = 0;
|
|
2678
2870
|
for (const f of flows) {
|
|
2679
2871
|
const def = f;
|
|
@@ -2693,6 +2885,21 @@ var AutomationServicePlugin = class {
|
|
|
2693
2885
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2694
2886
|
ctx.logger.warn(`[Automation] flow pull from ObjectQL registry failed: ${msg}`);
|
|
2695
2887
|
}
|
|
2888
|
+
if (durableStore) {
|
|
2889
|
+
let job;
|
|
2890
|
+
try {
|
|
2891
|
+
job = ctx.getService("job");
|
|
2892
|
+
} catch {
|
|
2893
|
+
}
|
|
2894
|
+
try {
|
|
2895
|
+
const rearmed = await rearmSuspendedWaitTimers(this.engine, durableStore, job, ctx.logger);
|
|
2896
|
+
if (rearmed > 0) {
|
|
2897
|
+
ctx.logger.info(`[Automation] Re-armed ${rearmed} suspended wait timer(s) after restart`);
|
|
2898
|
+
}
|
|
2899
|
+
} catch (err) {
|
|
2900
|
+
ctx.logger.warn(`[Automation] wait-timer re-arm failed: ${err.message}`);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2696
2903
|
}
|
|
2697
2904
|
async destroy() {
|
|
2698
2905
|
this.engine = void 0;
|