@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.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
- xAxisField: string;
1150
- yAxisFields: string[];
1151
- aggregation?: "min" | "max" | "count" | "sum" | "avg" | undefined;
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
- xAxisField: string;
1150
- yAxisFields: string[];
1151
- aggregation?: "min" | "max" | "count" | "sum" | "avg" | undefined;
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
- await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel);
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 && durationMs && durationMs > 0) {
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: false,
2564
- error: `subflow '${flowName}' suspended at a pausing node \u2014 a nested approval/screen/wait pause from a subflow is not yet supported`
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
- console.warn("[Automation:start] engine missing, bailing");
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
- this.engine.setSuspendedRunStore(new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger));
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
- console.warn("[Automation] objectql service not found at start()");
2960
+ ctx.logger.debug("[Automation] objectql service not found at start()");
2670
2961
  } else if (!ql.registry) {
2671
- console.warn("[Automation] objectql.registry is undefined at start()");
2962
+ ctx.logger.debug("[Automation] objectql.registry is undefined at start()");
2672
2963
  } else if (typeof ql.registry.listItems !== "function") {
2673
- console.warn("[Automation] objectql.registry.listItems is not a function");
2964
+ ctx.logger.debug("[Automation] objectql.registry.listItems is not a function");
2674
2965
  }
2675
2966
  const flows = ql?.registry?.listItems?.("flow") ?? [];
2676
- console.warn(`[Automation] flow pull: registry returned ${flows.length} flow(s)`);
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;