callman-core 1.17.0 → 1.18.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.
@@ -36,9 +36,18 @@ const buildTitle = (node) => {
36
36
  return `${node.type[0]?.toUpperCase() ?? ""}${node.type.slice(1)}`;
37
37
  };
38
38
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39
- // `loop`/`loops` are the loop-iteration template roots (see the loop-frame
39
+ // `loop`/`loops` are the loop-iteration template roots and `item`/`each`/
40
+ // `eaches` the foreach-iteration ones (see the loop-frame and foreach
40
41
  // machinery in runScenario) — node labels must not shadow them.
41
- const RESERVED_WORKFLOW_ROOT_LABELS = new Set(["workflow", "__meta", "loop", "loops"]);
42
+ const RESERVED_WORKFLOW_ROOT_LABELS = new Set([
43
+ "workflow",
44
+ "__meta",
45
+ "loop",
46
+ "loops",
47
+ "item",
48
+ "each",
49
+ "eaches",
50
+ ]);
42
51
  const cloneNodeExecutionMeta = (meta) => ({
43
52
  ...meta,
44
53
  ...(meta.error ? { error: { ...meta.error } } : {}),
@@ -416,6 +425,18 @@ const updateRecordFromEvent = (records, event) => {
416
425
  });
417
426
  return;
418
427
  }
428
+ if (event.type === "step:iteration") {
429
+ // Iteration progress marker only — the terminal success/fail event owns
430
+ // the record. Without this early return the generic fallback below would
431
+ // stamp status:"failed" on the foreach step.
432
+ records.set(event.stepId, {
433
+ ...current,
434
+ stepType: event.stepType,
435
+ title: event.title,
436
+ startedAt: current.startedAt ?? event.at,
437
+ });
438
+ return;
439
+ }
419
440
  if (event.type === "step:condition") {
420
441
  records.set(event.stepId, {
421
442
  ...current,
@@ -511,6 +532,9 @@ const callHandler = async (handlers, event) => {
511
532
  case "step:poll":
512
533
  await handlers?.onPollTick?.(event);
513
534
  return;
535
+ case "step:iteration":
536
+ await handlers?.onForeachIteration?.(event);
537
+ return;
514
538
  case "step:success":
515
539
  await handlers?.onNodeSuccess?.(event);
516
540
  return;
@@ -824,10 +848,10 @@ const runScenario = async ({ scenario, environment = {}, globals = {}, runtimeAd
824
848
  let executionPolicy = (0, nodePolicy_js_1.supportsScenarioNodeExecutionPolicy)(node)
825
849
  ? withNotificationFailurePolicyOverride(node, (0, nodePolicy_js_1.getScenarioNodeExecutionPolicy)(node))
826
850
  : (0, nodePolicy_js_1.getLegacyScenarioNodeExecutionPolicy)();
827
- if (node.type === "poll") {
828
- // The poll loop REPLACES per-node retry (nested retry loops would be
829
- // unexplainable). onFailure — incl. error edges — still governs the
830
- // timeout outcome.
851
+ if (node.type === "poll" || node.type === "foreach") {
852
+ // The poll/item loop REPLACES per-node retry (nested retry loops would
853
+ // be unexplainable). onFailure — incl. error edges — still governs the
854
+ // final outcome.
831
855
  executionPolicy = {
832
856
  ...executionPolicy,
833
857
  retry: { enabled: false, maxAttempts: 0, delayMs: 0 },
@@ -1536,6 +1560,228 @@ const runScenario = async ({ scenario, environment = {}, globals = {}, runtimeAd
1536
1560
  shouldMarkPartialSuccess = degradedStepIds.size > degradedCountBefore;
1537
1561
  outputData = groupedOutput;
1538
1562
  }
1563
+ if (node.type === "foreach") {
1564
+ const foreachConfig = node.data.config;
1565
+ // ── Resolve the items array ──────────────────────────────────────
1566
+ let items;
1567
+ if (foreachConfig.sourceMode === "inline") {
1568
+ const rawInline = (foreachConfig.itemsInline ?? "").trim();
1569
+ if (!rawInline) {
1570
+ throw new ScenarioNodeFailureError("Foreach inline items are empty.");
1571
+ }
1572
+ const resolvedInline = (0, templateResolver_js_1.resolveScenarioJsonTemplateString)(rawInline, templateValues);
1573
+ let parsedInline;
1574
+ try {
1575
+ parsedInline = JSON.parse(resolvedInline);
1576
+ }
1577
+ catch {
1578
+ throw new ScenarioNodeFailureError("Foreach inline items are not valid JSON after template resolution.");
1579
+ }
1580
+ if (!Array.isArray(parsedInline)) {
1581
+ throw new ScenarioNodeFailureError("Foreach inline items must be a JSON array.");
1582
+ }
1583
+ items = parsedInline;
1584
+ }
1585
+ else {
1586
+ const itemsPath = (foreachConfig.itemsPath ?? "").trim();
1587
+ if (!itemsPath) {
1588
+ throw new ScenarioNodeFailureError("Foreach items path is required.");
1589
+ }
1590
+ const resolved = (0, condition_js_1.resolveConditionRuntimeValue)({
1591
+ environment: runtimeContext.environment,
1592
+ globals: runtimeContext.globals,
1593
+ workflowContext: runtimeContext.workflowContext,
1594
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
1595
+ responseRoot: runtimeContext.responseRoot,
1596
+ dbResult: runtimeContext.dbResult,
1597
+ kafkaEvent: runtimeContext.kafkaEvent,
1598
+ redisResult: runtimeContext.redisResult,
1599
+ }, itemsPath);
1600
+ if (!resolved.exists || typeof resolved.value === "undefined") {
1601
+ throw new ScenarioNodeFailureError(`Foreach items path "${itemsPath}" resolved to nothing.`);
1602
+ }
1603
+ if (!Array.isArray(resolved.value)) {
1604
+ throw new ScenarioNodeFailureError(`Foreach items path "${itemsPath}" must resolve to an array (got ${typeof resolved.value}).`);
1605
+ }
1606
+ items = resolved.value;
1607
+ }
1608
+ const itemAlias = (foreachConfig.itemAlias ?? "").trim();
1609
+ if (itemAlias && RESERVED_WORKFLOW_ROOT_LABELS.has(itemAlias)) {
1610
+ throw new ScenarioNodeFailureError(`Foreach item alias "${itemAlias}" is a reserved name.`);
1611
+ }
1612
+ const maxIterations = Math.min(graphTraversal_js_1.MAX_FOREACH_ITERATIONS, Math.max(1, Math.round(foreachConfig.maxIterations ?? graphTraversal_js_1.DEFAULT_FOREACH_MAX_ITERATIONS)));
1613
+ const processLimit = Math.min(items.length, maxIterations);
1614
+ const iterationDelayMs = Math.max(0, Math.round(foreachConfig.delayMs ?? 0));
1615
+ const onItemFailure = foreachConfig.onItemFailure ?? "stop";
1616
+ const collectResults = foreachConfig.collectResults !== false;
1617
+ // Thinned emission keeps stored events bounded (~100 iteration markers).
1618
+ const iterationEmitStride = Math.max(1, Math.ceil(processLimit / 100));
1619
+ const internalGraph = (0, templateResolver_js_1.buildScenarioExecutionGraph)(foreachConfig.nodes, foreachConfig.edges);
1620
+ const internalScope = {
1621
+ graph: internalGraph,
1622
+ stepIdForNode: (childNodeId) => (0, graphTraversal_js_1.createSubScenarioStepId)(stepId, childNodeId),
1623
+ stepIdForEdge: (edgeId) => (0, graphTraversal_js_1.createSubScenarioStepId)(stepId, edgeId),
1624
+ };
1625
+ // ALL descendant step ids (nested containers included) — reset
1626
+ // targets between iterations.
1627
+ const internalStepIds = (0, graphTraversal_js_1.buildRuntimeStepDescriptors)(foreachConfig.nodes, stepId).map((descriptor) => descriptor.stepId);
1628
+ const childLabels = (0, graphTraversal_js_1.flattenScenarioNodesForContext)(foreachConfig.nodes)
1629
+ .map((childNode) => childNode.data.label.trim())
1630
+ .filter((childLabel) => childLabel.length > 0);
1631
+ const workflowRoots = runtimeContext.workflowContext;
1632
+ // Save the enclosing iteration roots so a NESTED foreach restores
1633
+ // its parent's {{item}}/{{each}} when it finishes mid-iteration.
1634
+ const hadItemRoot = Object.prototype.hasOwnProperty.call(workflowRoots, "item");
1635
+ const previousItemRoot = workflowRoots.item;
1636
+ const hadEachRoot = Object.prototype.hasOwnProperty.call(workflowRoots, "each");
1637
+ const previousEachRoot = workflowRoots.each;
1638
+ const hadAliasRoot = itemAlias
1639
+ ? Object.prototype.hasOwnProperty.call(workflowRoots, itemAlias)
1640
+ : false;
1641
+ const previousAliasRoot = itemAlias ? workflowRoots[itemAlias] : undefined;
1642
+ const nodeLabelKey = nodeLabel;
1643
+ const previousEaches = workflowRoots.eaches;
1644
+ const degradedCountBefore = degradedStepIds.size;
1645
+ const results = [];
1646
+ let failedItems = 0;
1647
+ try {
1648
+ for (let index = 0; index < processLimit; index += 1) {
1649
+ throwIfStopped(signal);
1650
+ if (index > 0) {
1651
+ // Fresh pass: every internal step back to idle (including the
1652
+ // "skipped" stamped on unreached nodes last pass), degraded
1653
+ // marks cleared, records reset via the bulk step:reset event.
1654
+ for (const internalStepId of internalStepIds) {
1655
+ localStatuses.set(internalStepId, "idle");
1656
+ degradedStepIds.delete(internalStepId);
1657
+ }
1658
+ await emit({
1659
+ type: "step:reset",
1660
+ runId,
1661
+ scenarioId: scenario.id,
1662
+ stepIds: internalStepIds,
1663
+ at: new Date().toISOString(),
1664
+ reason: `Item ${index + 1}/${processLimit}: reset foreach body`,
1665
+ });
1666
+ if (iterationDelayMs > 0) {
1667
+ await waitForDuration(iterationDelayMs);
1668
+ }
1669
+ }
1670
+ // Fresh-pass isolation: a failed/short-circuited item must not
1671
+ // inherit the previous iteration's child outputs in its results
1672
+ // snapshot. The final iteration's children stay published after
1673
+ // the loop (last-iteration-wins for downstream reads).
1674
+ for (const childLabel of childLabels) {
1675
+ delete workflowRoots[childLabel];
1676
+ }
1677
+ const item = items[index];
1678
+ const frame = {
1679
+ index,
1680
+ iteration: index + 1,
1681
+ total: processLimit,
1682
+ remaining: processLimit - (index + 1),
1683
+ };
1684
+ workflowRoots.item = item;
1685
+ if (itemAlias) {
1686
+ workflowRoots[itemAlias] = item;
1687
+ }
1688
+ workflowRoots.each = frame;
1689
+ if (nodeLabelKey) {
1690
+ const eachesRoot = workflowRoots.eaches && typeof workflowRoots.eaches === "object"
1691
+ ? workflowRoots.eaches
1692
+ : {};
1693
+ workflowRoots.eaches = { ...eachesRoot, [nodeLabelKey]: frame };
1694
+ }
1695
+ if (index === 0 ||
1696
+ index === processLimit - 1 ||
1697
+ (index + 1) % iterationEmitStride === 0) {
1698
+ const itemPreview = (() => {
1699
+ try {
1700
+ const serialized = JSON.stringify(item);
1701
+ if (typeof serialized !== "string")
1702
+ return String(item);
1703
+ return serialized.length > 2048 ? `${serialized.slice(0, 2048)}…` : serialized;
1704
+ }
1705
+ catch {
1706
+ return null;
1707
+ }
1708
+ })();
1709
+ await emit({
1710
+ type: "step:iteration",
1711
+ runId,
1712
+ scenarioId: scenario.id,
1713
+ stepId,
1714
+ stepType: node.type,
1715
+ title,
1716
+ at: new Date().toISOString(),
1717
+ index,
1718
+ iteration: index + 1,
1719
+ total: processLimit,
1720
+ itemPreview,
1721
+ });
1722
+ }
1723
+ try {
1724
+ for (const startNodeId of internalGraph.startNodeIds) {
1725
+ throwIfStopped(signal);
1726
+ await executeNode(startNodeId, null, {}, internalScope);
1727
+ }
1728
+ await markScopeRemainingNodesSkipped(internalScope, `Internal node was not reached inside ${title} (item ${index + 1}).`);
1729
+ }
1730
+ catch (error) {
1731
+ if (error instanceof ScenarioStopError) {
1732
+ throw error;
1733
+ }
1734
+ failedItems += 1;
1735
+ if (onItemFailure === "stop") {
1736
+ const message = error instanceof Error ? error.message : String(error);
1737
+ throw new ScenarioNodeFailureError(`Foreach item ${index + 1}/${processLimit} failed: ${message}`);
1738
+ }
1739
+ // continue: unreached internal nodes stay consistent for the
1740
+ // report, the run degrades to partial success below.
1741
+ await markScopeRemainingNodesSkipped(internalScope, `Internal node was not reached inside ${title} (item ${index + 1} failed).`);
1742
+ }
1743
+ if (collectResults) {
1744
+ const iterationOutput = {};
1745
+ for (const childLabel of childLabels) {
1746
+ if (Object.prototype.hasOwnProperty.call(workflowRoots, childLabel)) {
1747
+ iterationOutput[childLabel] = workflowRoots[childLabel];
1748
+ }
1749
+ }
1750
+ results.push(iterationOutput);
1751
+ }
1752
+ }
1753
+ }
1754
+ finally {
1755
+ // Restore the enclosing iteration roots (or clear ours).
1756
+ if (hadItemRoot)
1757
+ workflowRoots.item = previousItemRoot;
1758
+ else
1759
+ delete workflowRoots.item;
1760
+ if (hadEachRoot)
1761
+ workflowRoots.each = previousEachRoot;
1762
+ else
1763
+ delete workflowRoots.each;
1764
+ if (itemAlias) {
1765
+ if (hadAliasRoot)
1766
+ workflowRoots[itemAlias] = previousAliasRoot;
1767
+ else
1768
+ delete workflowRoots[itemAlias];
1769
+ }
1770
+ if (typeof previousEaches === "undefined")
1771
+ delete workflowRoots.eaches;
1772
+ else
1773
+ workflowRoots.eaches = previousEaches;
1774
+ }
1775
+ shouldMarkPartialSuccess =
1776
+ degradedStepIds.size > degradedCountBefore || failedItems > 0;
1777
+ outputData = {
1778
+ ...(collectResults ? { results } : {}),
1779
+ itemCount: items.length,
1780
+ processedCount: processLimit,
1781
+ truncated: items.length > processLimit,
1782
+ failedItems,
1783
+ };
1784
+ }
1539
1785
  if (node.type === "end") {
1540
1786
  if (node.data.config.loop && node.data.config.loopTargetNodeId) {
1541
1787
  const loopTargetNode = scope.graph.nodeMap.get(node.data.config.loopTargetNodeId);