@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.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
|
@@ -460,6 +460,8 @@ var AutomationEngine = class {
|
|
|
460
460
|
}
|
|
461
461
|
const runId = this.nextRunId();
|
|
462
462
|
variables.set("$runId", runId);
|
|
463
|
+
variables.set("$flowName", flowName);
|
|
464
|
+
variables.set("$flowLabel", flow.label ?? flowName);
|
|
463
465
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
464
466
|
const steps = [];
|
|
465
467
|
try {
|
|
@@ -580,8 +582,24 @@ var AutomationEngine = class {
|
|
|
580
582
|
* restricted to the edge labelled `signal.branchLabel` (e.g. the approval
|
|
581
583
|
* decision). The continuation may itself suspend again, in which case this
|
|
582
584
|
* returns `{ status: 'paused', runId }` afresh.
|
|
585
|
+
*
|
|
586
|
+
* **Subflow chains (nested pause, linked-runs model).** A run paused at a
|
|
587
|
+
* `subflow` node (correlation `subflow:<childRunId>`) DELEGATES the signal
|
|
588
|
+
* down to the suspended child; a run that completes and carries
|
|
589
|
+
* `$parentRunId` in its context BUBBLES its output up by auto-resuming the
|
|
590
|
+
* parent. Both directions compose recursively, so arbitrarily nested
|
|
591
|
+
* subflow pauses resolve from either end (UI holds the parent run id;
|
|
592
|
+
* approval/wait infrastructure holds the child's).
|
|
583
593
|
*/
|
|
584
594
|
async resume(runId, signal) {
|
|
595
|
+
return this.resumeInternal(runId, signal, false);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* @param skipBubble - Set when the caller is the subflow DELEGATION path,
|
|
599
|
+
* which continues the parent itself after the child completes — the
|
|
600
|
+
* child's own up-bubble must stay off so the parent isn't resumed twice.
|
|
601
|
+
*/
|
|
602
|
+
async resumeInternal(runId, signal, skipBubble) {
|
|
585
603
|
if (this.resuming.has(runId)) {
|
|
586
604
|
return { success: false, error: `Run '${runId}' is already being resumed` };
|
|
587
605
|
}
|
|
@@ -608,6 +626,35 @@ var AutomationEngine = class {
|
|
|
608
626
|
if (!node) {
|
|
609
627
|
return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` };
|
|
610
628
|
}
|
|
629
|
+
if (typeof run.correlation === "string" && run.correlation.startsWith("subflow:")) {
|
|
630
|
+
const childRunId = run.correlation.slice("subflow:".length);
|
|
631
|
+
const childRun = this.suspendedRuns.get(childRunId) ?? (this.store ? await this.store.load(childRunId).catch(() => null) : null);
|
|
632
|
+
if (childRun) {
|
|
633
|
+
const childRes = await this.resumeInternal(childRunId, signal, true);
|
|
634
|
+
if (childRes.status === "paused") {
|
|
635
|
+
if (childRes.screen && childRes.screen !== run.screen) {
|
|
636
|
+
await this.persistSuspendedRun({ ...run, screen: childRes.screen });
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
success: true,
|
|
640
|
+
status: "paused",
|
|
641
|
+
runId,
|
|
642
|
+
durationMs: Date.now() - run.startTime,
|
|
643
|
+
screen: childRes.screen
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (!childRes.success) {
|
|
647
|
+
const error = `subflow run '${childRunId}' (${childRun.flowName}) failed: ${childRes.error ?? "unknown error"}`;
|
|
648
|
+
await this.failSuspendedRun(run, error);
|
|
649
|
+
return { success: false, error, durationMs: Date.now() - run.startTime };
|
|
650
|
+
}
|
|
651
|
+
signal = this.buildSubflowResumeSignal(childRun.context, childRes.output);
|
|
652
|
+
} else {
|
|
653
|
+
this.logger.warn(
|
|
654
|
+
`[automation] run '${runId}' is paused at subflow node '${run.nodeId}' but child run '${childRunId}' is gone \u2014 continuing without child output`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
611
658
|
await this.forgetSuspendedRun(runId);
|
|
612
659
|
const variables = new Map(Object.entries(run.variables));
|
|
613
660
|
if (signal?.output) {
|
|
@@ -623,7 +670,11 @@ var AutomationEngine = class {
|
|
|
623
670
|
const steps = run.steps;
|
|
624
671
|
const context = run.context;
|
|
625
672
|
try {
|
|
626
|
-
|
|
673
|
+
if (typeof run.correlation === "string" && run.correlation.startsWith("map:")) {
|
|
674
|
+
await this.executeNode(node, flow, variables, context, steps);
|
|
675
|
+
} else {
|
|
676
|
+
await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
|
|
677
|
+
}
|
|
627
678
|
const output = {};
|
|
628
679
|
if (flow.variables) {
|
|
629
680
|
for (const v of flow.variables) {
|
|
@@ -647,6 +698,9 @@ var AutomationEngine = class {
|
|
|
647
698
|
steps,
|
|
648
699
|
output
|
|
649
700
|
});
|
|
701
|
+
if (!skipBubble) {
|
|
702
|
+
await this.bubbleToParent(run, output);
|
|
703
|
+
}
|
|
650
704
|
return { success: true, output, durationMs };
|
|
651
705
|
} catch (err) {
|
|
652
706
|
if (isSuspendSignal(err)) {
|
|
@@ -693,12 +747,94 @@ var AutomationEngine = class {
|
|
|
693
747
|
steps,
|
|
694
748
|
error: errorMessage
|
|
695
749
|
});
|
|
750
|
+
if (!skipBubble) {
|
|
751
|
+
await this.failAncestors(run.context, errorMessage);
|
|
752
|
+
}
|
|
696
753
|
return { success: false, error: errorMessage, durationMs };
|
|
697
754
|
}
|
|
698
755
|
} finally {
|
|
699
756
|
this.resuming.delete(runId);
|
|
700
757
|
}
|
|
701
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Build the resume signal that maps a completed subflow child's output
|
|
761
|
+
* into its parent — mirroring the synchronous path exactly: the engine's
|
|
762
|
+
* standard `signal.output` merge lands it under `${subflowNodeId}.output`,
|
|
763
|
+
* and `signal.variables` writes the bare `config.outputVariable` when the
|
|
764
|
+
* child's context carries one (`$parentOutputVariable`).
|
|
765
|
+
*/
|
|
766
|
+
buildSubflowResumeSignal(childContext, childOutput) {
|
|
767
|
+
const outVar = childContext?.$parentOutputVariable;
|
|
768
|
+
return {
|
|
769
|
+
output: { output: childOutput ?? null },
|
|
770
|
+
...typeof outVar === "string" && outVar ? { variables: { [outVar]: childOutput ?? null } } : {}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Up-bubble for the subflow chain: when a completed run carries
|
|
775
|
+
* `$parentRunId`, resume that parent with this run's output. Recursion via
|
|
776
|
+
* the parent's own completion bubbles multi-level chains. Best-effort —
|
|
777
|
+
* a failed parent continuation is logged, never thrown back at the
|
|
778
|
+
* caller who resumed the child.
|
|
779
|
+
*/
|
|
780
|
+
async bubbleToParent(run, output) {
|
|
781
|
+
const ctx = run.context;
|
|
782
|
+
const parentRunId = ctx?.$parentRunId;
|
|
783
|
+
if (typeof parentRunId !== "string" || !parentRunId) return;
|
|
784
|
+
try {
|
|
785
|
+
const mapNode = ctx?.$parentMapNode;
|
|
786
|
+
const sig = typeof mapNode === "string" && mapNode ? { variables: { [`${mapNode}.$mapItemOutput`]: output ?? null, [`${mapNode}.$mapItemDone`]: true } } : this.buildSubflowResumeSignal(run.context, output);
|
|
787
|
+
const parentRes = await this.resumeInternal(parentRunId, sig, false);
|
|
788
|
+
if (!parentRes.success) {
|
|
789
|
+
this.logger.warn(
|
|
790
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' failed: ${parentRes.error}`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
} catch (err) {
|
|
794
|
+
this.logger.warn(
|
|
795
|
+
`[automation] subflow run '${run.runId}' completed but resuming parent '${parentRunId}' threw: ${err.message}`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Terminally fail a suspended run: consume its continuation and record a
|
|
801
|
+
* `failed` log so it stops surfacing as resumable. Used when a subflow
|
|
802
|
+
* descendant fails — the ancestor awaiting it can never be resumed.
|
|
803
|
+
*/
|
|
804
|
+
async failSuspendedRun(run, error) {
|
|
805
|
+
await this.forgetSuspendedRun(run.runId);
|
|
806
|
+
this.recordLog({
|
|
807
|
+
id: run.runId,
|
|
808
|
+
flowName: run.flowName,
|
|
809
|
+
flowVersion: run.flowVersion,
|
|
810
|
+
status: "failed",
|
|
811
|
+
startedAt: run.startedAt,
|
|
812
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
813
|
+
durationMs: Date.now() - run.startTime,
|
|
814
|
+
trigger: {
|
|
815
|
+
type: run.context?.event ?? "manual",
|
|
816
|
+
userId: run.context?.userId,
|
|
817
|
+
object: run.context?.object
|
|
818
|
+
},
|
|
819
|
+
steps: run.steps,
|
|
820
|
+
error
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Walk a failed run's `$parentRunId` chain and fail each suspended
|
|
825
|
+
* ancestor (see {@link failSuspendedRun}). Bounded so a corrupt context
|
|
826
|
+
* can't loop forever.
|
|
827
|
+
*/
|
|
828
|
+
async failAncestors(context, error) {
|
|
829
|
+
let parentId = context?.$parentRunId;
|
|
830
|
+
let hops = 0;
|
|
831
|
+
while (typeof parentId === "string" && parentId && hops++ < 32) {
|
|
832
|
+
const parent = this.suspendedRuns.get(parentId) ?? (this.store ? await this.store.load(parentId).catch(() => null) : null);
|
|
833
|
+
if (!parent) return;
|
|
834
|
+
await this.failSuspendedRun(parent, `subflow descendant failed: ${error}`);
|
|
835
|
+
parentId = parent.context?.$parentRunId;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
702
838
|
/**
|
|
703
839
|
* List the runs currently suspended awaiting {@link resume} (ADR-0019).
|
|
704
840
|
* Backs operability surfaces such as a "pending approvals" view.
|
|
@@ -2470,10 +2606,11 @@ function registerWaitNode(engine, ctx) {
|
|
|
2470
2606
|
const runId = variables.get("$runId");
|
|
2471
2607
|
if (eventType === "timer") {
|
|
2472
2608
|
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);
|
|
2609
|
+
const at = durationMs && durationMs > 0 ? new Date(Date.now() + durationMs).toISOString() : void 0;
|
|
2610
|
+
const output = at ? { waitUntil: at } : void 0;
|
|
2473
2611
|
const job = getJobService();
|
|
2474
|
-
if (job && runId != null &&
|
|
2612
|
+
if (job && runId != null && at) {
|
|
2475
2613
|
const jobName = `flow-wait:${String(runId)}:${node.id}`;
|
|
2476
|
-
const at = new Date(Date.now() + durationMs).toISOString();
|
|
2477
2614
|
try {
|
|
2478
2615
|
await job.schedule(jobName, { type: "once", at }, async () => {
|
|
2479
2616
|
try {
|
|
@@ -2485,7 +2622,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2485
2622
|
}
|
|
2486
2623
|
}
|
|
2487
2624
|
});
|
|
2488
|
-
return { success: true, suspend: true, correlation: jobName };
|
|
2625
|
+
return { success: true, suspend: true, correlation: jobName, output };
|
|
2489
2626
|
} catch (err) {
|
|
2490
2627
|
ctx.logger.warn(
|
|
2491
2628
|
`[wait] node '${node.id}': failed to schedule timer resume (${err?.message ?? err}); suspending without auto-resume (resume it via resume(runId))`
|
|
@@ -2496,7 +2633,7 @@ function registerWaitNode(engine, ctx) {
|
|
|
2496
2633
|
`[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
2634
|
);
|
|
2498
2635
|
}
|
|
2499
|
-
return { success: true, suspend: true, correlation: `timer:${node.id}
|
|
2636
|
+
return { success: true, suspend: true, correlation: `timer:${node.id}`, output };
|
|
2500
2637
|
}
|
|
2501
2638
|
const signal = String(wec.signalName ?? loose.signalName ?? loose.signal ?? `wait:${node.id}`);
|
|
2502
2639
|
return { success: true, suspend: true, correlation: signal };
|
|
@@ -2504,6 +2641,54 @@ function registerWaitNode(engine, ctx) {
|
|
|
2504
2641
|
});
|
|
2505
2642
|
ctx.logger.info("[Wait Node] 1 built-in node executor registered");
|
|
2506
2643
|
}
|
|
2644
|
+
async function rearmSuspendedWaitTimers(engine, store, job, logger) {
|
|
2645
|
+
let runs;
|
|
2646
|
+
try {
|
|
2647
|
+
runs = await store.list();
|
|
2648
|
+
} catch (err) {
|
|
2649
|
+
logger.warn(`[wait] timer re-arm: failed to list suspended runs: ${err?.message ?? err}`);
|
|
2650
|
+
return 0;
|
|
2651
|
+
}
|
|
2652
|
+
let rearmed = 0;
|
|
2653
|
+
for (const run of runs) {
|
|
2654
|
+
const wakeAt = run.variables?.[`${run.nodeId}.waitUntil`];
|
|
2655
|
+
if (typeof wakeAt !== "string" || !wakeAt) continue;
|
|
2656
|
+
const atMs = Date.parse(wakeAt);
|
|
2657
|
+
if (Number.isNaN(atMs)) continue;
|
|
2658
|
+
if (atMs <= Date.now()) {
|
|
2659
|
+
try {
|
|
2660
|
+
await engine.resume(run.runId);
|
|
2661
|
+
rearmed++;
|
|
2662
|
+
} catch (err) {
|
|
2663
|
+
logger.warn(`[wait] timer re-arm: resume of overdue run '${run.runId}' failed: ${err?.message ?? err}`);
|
|
2664
|
+
}
|
|
2665
|
+
continue;
|
|
2666
|
+
}
|
|
2667
|
+
if (!job) {
|
|
2668
|
+
logger.warn(
|
|
2669
|
+
`[wait] timer re-arm: run '${run.runId}' waits until ${wakeAt} but no job service is registered \u2014 resume it externally via resume(runId)`
|
|
2670
|
+
);
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
const jobName = `flow-wait:${run.runId}:${run.nodeId}`;
|
|
2674
|
+
try {
|
|
2675
|
+
await job.schedule(jobName, { type: "once", at: wakeAt }, async () => {
|
|
2676
|
+
try {
|
|
2677
|
+
await engine.resume(run.runId);
|
|
2678
|
+
} finally {
|
|
2679
|
+
try {
|
|
2680
|
+
await job.cancel?.(jobName);
|
|
2681
|
+
} catch {
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
rearmed++;
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
logger.warn(`[wait] timer re-arm: failed to re-schedule run '${run.runId}': ${err?.message ?? err}`);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
return rearmed;
|
|
2691
|
+
}
|
|
2507
2692
|
function parseIsoDuration(input) {
|
|
2508
2693
|
if (typeof input === "number" && Number.isFinite(input)) return input > 0 ? input : void 0;
|
|
2509
2694
|
if (typeof input !== "string") return void 0;
|
|
@@ -2535,7 +2720,10 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2535
2720
|
description: "Invoke another flow as a reusable step and capture its output.",
|
|
2536
2721
|
icon: "workflow",
|
|
2537
2722
|
category: "logic",
|
|
2538
|
-
source: "builtin"
|
|
2723
|
+
source: "builtin",
|
|
2724
|
+
// A child that suspends (approval/screen/wait) suspends this node too —
|
|
2725
|
+
// the parent run pauses here and resumes when the child completes.
|
|
2726
|
+
supportsPause: true
|
|
2539
2727
|
}),
|
|
2540
2728
|
async execute(node, variables, context) {
|
|
2541
2729
|
const cfg = node.config ?? {};
|
|
@@ -2552,22 +2740,33 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2552
2740
|
}
|
|
2553
2741
|
const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
|
|
2554
2742
|
const params = interpolate(rawInput, variables, context ?? {});
|
|
2743
|
+
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2744
|
+
const parentRunId = variables.get("$runId");
|
|
2555
2745
|
const childContext = {
|
|
2556
2746
|
...context ?? {},
|
|
2557
2747
|
$subflowDepth: depth + 1,
|
|
2558
|
-
params
|
|
2748
|
+
params,
|
|
2749
|
+
...parentRunId != null ? {
|
|
2750
|
+
$parentRunId: String(parentRunId),
|
|
2751
|
+
$parentNodeId: node.id,
|
|
2752
|
+
...outVar ? { $parentOutputVariable: outVar } : {}
|
|
2753
|
+
} : {}
|
|
2559
2754
|
};
|
|
2560
2755
|
const child = await engine.execute(flowName, childContext);
|
|
2561
2756
|
if (child.status === "paused") {
|
|
2757
|
+
if (!child.runId) {
|
|
2758
|
+
return { success: false, error: `subflow '${flowName}' paused without a run id \u2014 cannot link the runs` };
|
|
2759
|
+
}
|
|
2562
2760
|
return {
|
|
2563
|
-
success:
|
|
2564
|
-
|
|
2761
|
+
success: true,
|
|
2762
|
+
suspend: true,
|
|
2763
|
+
correlation: `subflow:${child.runId}`,
|
|
2764
|
+
screen: child.screen
|
|
2565
2765
|
};
|
|
2566
2766
|
}
|
|
2567
2767
|
if (!child.success) {
|
|
2568
2768
|
return { success: false, error: `subflow '${flowName}' failed: ${child.error ?? "unknown error"}` };
|
|
2569
2769
|
}
|
|
2570
|
-
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2571
2770
|
if (outVar) variables.set(outVar, child.output ?? null);
|
|
2572
2771
|
return { success: true, output: { output: child.output ?? null } };
|
|
2573
2772
|
}
|
|
@@ -2575,6 +2774,96 @@ function registerSubflowNode(engine, ctx) {
|
|
|
2575
2774
|
ctx.logger.info("[Subflow Node] 1 built-in node executor registered");
|
|
2576
2775
|
}
|
|
2577
2776
|
|
|
2777
|
+
// src/builtin/map-node.ts
|
|
2778
|
+
import { defineActionDescriptor as defineActionDescriptor13 } from "@objectstack/spec/automation";
|
|
2779
|
+
var MAX_MAP_ITEMS = 1e4;
|
|
2780
|
+
function registerMapNode(engine, ctx) {
|
|
2781
|
+
engine.registerNodeExecutor({
|
|
2782
|
+
type: "map",
|
|
2783
|
+
descriptor: defineActionDescriptor13({
|
|
2784
|
+
type: "map",
|
|
2785
|
+
version: "1.0.0",
|
|
2786
|
+
name: "Map",
|
|
2787
|
+
description: "Run a per-item subflow for each element of a collection, one at a time (each item may pause).",
|
|
2788
|
+
icon: "list-check",
|
|
2789
|
+
category: "logic",
|
|
2790
|
+
source: "builtin",
|
|
2791
|
+
// Each item's subflow may pause, so the map suspends and resumes per item.
|
|
2792
|
+
supportsPause: true,
|
|
2793
|
+
isAsync: true
|
|
2794
|
+
}),
|
|
2795
|
+
async execute(node, variables, context) {
|
|
2796
|
+
const cfg = node.config ?? {};
|
|
2797
|
+
const flowName = typeof cfg.flowName === "string" ? cfg.flowName : typeof cfg.flow === "string" ? cfg.flow : void 0;
|
|
2798
|
+
if (!flowName) {
|
|
2799
|
+
return { success: false, error: `map '${node.id}': config.flowName (the per-item subflow) is required` };
|
|
2800
|
+
}
|
|
2801
|
+
const iteratorVariable = typeof cfg.iteratorVariable === "string" && cfg.iteratorVariable ? cfg.iteratorVariable : "item";
|
|
2802
|
+
const indexVariable = typeof cfg.indexVariable === "string" && cfg.indexVariable ? cfg.indexVariable : void 0;
|
|
2803
|
+
const outVar = typeof cfg.outputVariable === "string" && cfg.outputVariable ? cfg.outputVariable : void 0;
|
|
2804
|
+
const rawCollection = cfg.collection;
|
|
2805
|
+
let collection;
|
|
2806
|
+
if (Array.isArray(rawCollection)) {
|
|
2807
|
+
collection = rawCollection;
|
|
2808
|
+
} else if (typeof rawCollection === "string") {
|
|
2809
|
+
collection = interpolate(rawCollection, variables, context ?? {});
|
|
2810
|
+
if (collection == null && variables.has(rawCollection)) collection = variables.get(rawCollection);
|
|
2811
|
+
}
|
|
2812
|
+
if (!Array.isArray(collection)) {
|
|
2813
|
+
return { success: false, error: `map '${node.id}': collection '${String(rawCollection)}' did not resolve to an array` };
|
|
2814
|
+
}
|
|
2815
|
+
if (collection.length > MAX_MAP_ITEMS) {
|
|
2816
|
+
return { success: false, error: `map '${node.id}': collection length ${collection.length} exceeds the ${MAX_MAP_ITEMS} cap` };
|
|
2817
|
+
}
|
|
2818
|
+
const stateKey = `${node.id}.$mapState`;
|
|
2819
|
+
const state = variables.get(stateKey) ?? {
|
|
2820
|
+
started: 0,
|
|
2821
|
+
results: []
|
|
2822
|
+
};
|
|
2823
|
+
if (variables.get(`${node.id}.$mapItemDone`) === true) {
|
|
2824
|
+
state.results.push(variables.get(`${node.id}.$mapItemOutput`) ?? null);
|
|
2825
|
+
variables.delete(`${node.id}.$mapItemDone`);
|
|
2826
|
+
variables.delete(`${node.id}.$mapItemOutput`);
|
|
2827
|
+
}
|
|
2828
|
+
const parentRunId = variables.get("$runId");
|
|
2829
|
+
while (state.started < collection.length) {
|
|
2830
|
+
const idx = state.started;
|
|
2831
|
+
const item = collection[idx];
|
|
2832
|
+
variables.set(iteratorVariable, item);
|
|
2833
|
+
if (indexVariable) variables.set(indexVariable, idx);
|
|
2834
|
+
const rawInput = cfg.input && typeof cfg.input === "object" ? cfg.input : {};
|
|
2835
|
+
const params = interpolate(rawInput, variables, context ?? {});
|
|
2836
|
+
const itemIsRecord = item != null && typeof item === "object" && typeof item.id === "string";
|
|
2837
|
+
const itemObject = typeof cfg.itemObject === "string" ? cfg.itemObject : context?.object;
|
|
2838
|
+
const childContext = {
|
|
2839
|
+
...context ?? {},
|
|
2840
|
+
params,
|
|
2841
|
+
...itemIsRecord ? { record: item, object: itemObject } : {},
|
|
2842
|
+
...parentRunId != null ? { $parentRunId: String(parentRunId), $parentMapNode: node.id } : {}
|
|
2843
|
+
};
|
|
2844
|
+
const child = await engine.execute(flowName, childContext);
|
|
2845
|
+
if (child.status === "paused") {
|
|
2846
|
+
if (!child.runId) {
|
|
2847
|
+
return { success: false, error: `map '${node.id}': item ${idx} paused without a run id \u2014 cannot link the runs` };
|
|
2848
|
+
}
|
|
2849
|
+
state.started = idx + 1;
|
|
2850
|
+
variables.set(stateKey, state);
|
|
2851
|
+
return { success: true, suspend: true, correlation: `map:${child.runId}` };
|
|
2852
|
+
}
|
|
2853
|
+
if (!child.success) {
|
|
2854
|
+
return { success: false, error: `map '${node.id}': item ${idx} (subflow '${flowName}') failed: ${child.error ?? "unknown error"}` };
|
|
2855
|
+
}
|
|
2856
|
+
state.started = idx + 1;
|
|
2857
|
+
state.results.push(child.output ?? null);
|
|
2858
|
+
}
|
|
2859
|
+
variables.set(stateKey, state);
|
|
2860
|
+
if (outVar) variables.set(outVar, state.results);
|
|
2861
|
+
return { success: true, output: { results: state.results, count: state.results.length } };
|
|
2862
|
+
}
|
|
2863
|
+
});
|
|
2864
|
+
ctx.logger.info("[Map Node] 1 built-in node executor registered");
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2578
2867
|
// src/builtin/index.ts
|
|
2579
2868
|
function installBuiltinNodes(engine, ctx) {
|
|
2580
2869
|
registerLogicNodes(engine, ctx);
|
|
@@ -2588,6 +2877,7 @@ function installBuiltinNodes(engine, ctx) {
|
|
|
2588
2877
|
registerNotifyNode(engine, ctx);
|
|
2589
2878
|
registerWaitNode(engine, ctx);
|
|
2590
2879
|
registerSubflowNode(engine, ctx);
|
|
2880
|
+
registerMapNode(engine, ctx);
|
|
2591
2881
|
const types = engine.getRegisteredNodeTypes();
|
|
2592
2882
|
ctx.logger.info(
|
|
2593
2883
|
`[Automation] ${types.length} built-in node executors installed: ${types.join(", ")}`
|
|
@@ -2636,9 +2926,8 @@ var AutomationServicePlugin = class {
|
|
|
2636
2926
|
ctx.logger.info("[Automation] Engine initialized");
|
|
2637
2927
|
}
|
|
2638
2928
|
async start(ctx) {
|
|
2639
|
-
console.warn("[Automation:start] entering start()");
|
|
2640
2929
|
if (!this.engine) {
|
|
2641
|
-
|
|
2930
|
+
ctx.logger.warn("[Automation] start() called before init() \u2014 engine missing, skipping");
|
|
2642
2931
|
return;
|
|
2643
2932
|
}
|
|
2644
2933
|
await ctx.trigger("automation:ready", this.engine);
|
|
@@ -2646,6 +2935,7 @@ var AutomationServicePlugin = class {
|
|
|
2646
2935
|
ctx.logger.info(
|
|
2647
2936
|
`[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(", ") || "(none)"}`
|
|
2648
2937
|
);
|
|
2938
|
+
let durableStore = null;
|
|
2649
2939
|
if ((this.options.suspendedRunStore ?? "auto") !== "memory") {
|
|
2650
2940
|
let dataEngine = null;
|
|
2651
2941
|
try {
|
|
@@ -2657,7 +2947,8 @@ var AutomationServicePlugin = class {
|
|
|
2657
2947
|
}
|
|
2658
2948
|
}
|
|
2659
2949
|
if (dataEngine && typeof dataEngine.find === "function" && typeof dataEngine.insert === "function") {
|
|
2660
|
-
|
|
2950
|
+
durableStore = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger);
|
|
2951
|
+
this.engine.setSuspendedRunStore(durableStore);
|
|
2661
2952
|
ctx.logger.info("[Automation] Suspended-run persistence enabled (sys_automation_run)");
|
|
2662
2953
|
} else {
|
|
2663
2954
|
ctx.logger.info("[Automation] No ObjectQL engine \u2014 suspended runs kept in-memory only");
|
|
@@ -2666,14 +2957,14 @@ var AutomationServicePlugin = class {
|
|
|
2666
2957
|
try {
|
|
2667
2958
|
const ql = ctx.getService("objectql");
|
|
2668
2959
|
if (!ql) {
|
|
2669
|
-
|
|
2960
|
+
ctx.logger.debug("[Automation] objectql service not found at start()");
|
|
2670
2961
|
} else if (!ql.registry) {
|
|
2671
|
-
|
|
2962
|
+
ctx.logger.debug("[Automation] objectql.registry is undefined at start()");
|
|
2672
2963
|
} else if (typeof ql.registry.listItems !== "function") {
|
|
2673
|
-
|
|
2964
|
+
ctx.logger.debug("[Automation] objectql.registry.listItems is not a function");
|
|
2674
2965
|
}
|
|
2675
2966
|
const flows = ql?.registry?.listItems?.("flow") ?? [];
|
|
2676
|
-
|
|
2967
|
+
ctx.logger.debug(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
|
|
2677
2968
|
let registered = 0;
|
|
2678
2969
|
for (const f of flows) {
|
|
2679
2970
|
const def = f;
|
|
@@ -2693,6 +2984,21 @@ var AutomationServicePlugin = class {
|
|
|
2693
2984
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2694
2985
|
ctx.logger.warn(`[Automation] flow pull from ObjectQL registry failed: ${msg}`);
|
|
2695
2986
|
}
|
|
2987
|
+
if (durableStore) {
|
|
2988
|
+
let job;
|
|
2989
|
+
try {
|
|
2990
|
+
job = ctx.getService("job");
|
|
2991
|
+
} catch {
|
|
2992
|
+
}
|
|
2993
|
+
try {
|
|
2994
|
+
const rearmed = await rearmSuspendedWaitTimers(this.engine, durableStore, job, ctx.logger);
|
|
2995
|
+
if (rearmed > 0) {
|
|
2996
|
+
ctx.logger.info(`[Automation] Re-armed ${rearmed} suspended wait timer(s) after restart`);
|
|
2997
|
+
}
|
|
2998
|
+
} catch (err) {
|
|
2999
|
+
ctx.logger.warn(`[Automation] wait-timer re-arm failed: ${err.message}`);
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
2696
3002
|
}
|
|
2697
3003
|
async destroy() {
|
|
2698
3004
|
this.engine = void 0;
|