@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 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 && durationMs && durationMs > 0) {
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: false,
2600
- error: `subflow '${flowName}' suspended at a pausing node \u2014 a nested approval/screen/wait pause from a subflow is not yet supported`
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
- console.warn("[Automation:start] engine missing, bailing");
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
- this.engine.setSuspendedRunStore(new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger));
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
- console.warn("[Automation] objectql service not found at start()");
2897
+ ctx.logger.debug("[Automation] objectql service not found at start()");
2706
2898
  } else if (!ql.registry) {
2707
- console.warn("[Automation] objectql.registry is undefined at start()");
2899
+ ctx.logger.debug("[Automation] objectql.registry is undefined at start()");
2708
2900
  } else if (typeof ql.registry.listItems !== "function") {
2709
- console.warn("[Automation] objectql.registry.listItems is not a function");
2901
+ ctx.logger.debug("[Automation] objectql.registry.listItems is not a function");
2710
2902
  }
2711
2903
  const flows = ql?.registry?.listItems?.("flow") ?? [];
2712
- console.warn(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
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;