callman-core 1.17.0 → 1.19.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/scenario-runner/adapters/index.d.ts +1 -0
- package/dist/scenario-runner/adapters/index.d.ts.map +1 -1
- package/dist/scenario-runner/adapters/index.js +3 -0
- package/dist/scenario-runner/adapters/index.js.map +1 -1
- package/dist/scenario-runner/adapters/types.d.ts +1 -0
- package/dist/scenario-runner/adapters/types.d.ts.map +1 -1
- package/dist/scenario-runner/adapters/websocket-executor.d.ts +4 -0
- package/dist/scenario-runner/adapters/websocket-executor.d.ts.map +1 -0
- package/dist/scenario-runner/adapters/websocket-executor.js +20 -0
- package/dist/scenario-runner/adapters/websocket-executor.js.map +1 -0
- package/dist/scenario-runner/condition.d.ts.map +1 -1
- package/dist/scenario-runner/condition.js +53 -1
- package/dist/scenario-runner/condition.js.map +1 -1
- package/dist/scenario-runner/graphTraversal.d.ts +10 -0
- package/dist/scenario-runner/graphTraversal.d.ts.map +1 -1
- package/dist/scenario-runner/graphTraversal.js +16 -2
- package/dist/scenario-runner/graphTraversal.js.map +1 -1
- package/dist/scenario-runner/index.d.ts +1 -0
- package/dist/scenario-runner/index.d.ts.map +1 -1
- package/dist/scenario-runner/index.js +1 -0
- package/dist/scenario-runner/index.js.map +1 -1
- package/dist/scenario-runner/nodePolicy.d.ts +2 -2
- package/dist/scenario-runner/nodePolicy.d.ts.map +1 -1
- package/dist/scenario-runner/nodePolicy.js +5 -0
- package/dist/scenario-runner/nodePolicy.js.map +1 -1
- package/dist/scenario-runner/runScenario.d.ts.map +1 -1
- package/dist/scenario-runner/runScenario.js +412 -7
- package/dist/scenario-runner/runScenario.js.map +1 -1
- package/dist/scenario-runner/types.d.ts +137 -3
- package/dist/scenario-runner/types.d.ts.map +1 -1
- package/dist/scenario-runner/websocket.d.ts +17 -0
- package/dist/scenario-runner/websocket.d.ts.map +1 -0
- package/dist/scenario-runner/websocket.js +74 -0
- package/dist/scenario-runner/websocket.js.map +1 -0
- package/dist-cjs/scenario-runner/adapters/index.js +5 -1
- package/dist-cjs/scenario-runner/adapters/index.js.map +1 -1
- package/dist-cjs/scenario-runner/adapters/websocket-executor.js +24 -0
- package/dist-cjs/scenario-runner/adapters/websocket-executor.js.map +1 -0
- package/dist-cjs/scenario-runner/condition.js +53 -1
- package/dist-cjs/scenario-runner/condition.js.map +1 -1
- package/dist-cjs/scenario-runner/graphTraversal.js +17 -3
- package/dist-cjs/scenario-runner/graphTraversal.js.map +1 -1
- package/dist-cjs/scenario-runner/index.js +1 -0
- package/dist-cjs/scenario-runner/index.js.map +1 -1
- package/dist-cjs/scenario-runner/nodePolicy.js +5 -0
- package/dist-cjs/scenario-runner/nodePolicy.js.map +1 -1
- package/dist-cjs/scenario-runner/runScenario.js +411 -6
- package/dist-cjs/scenario-runner/runScenario.js.map +1 -1
- package/dist-cjs/scenario-runner/websocket.js +79 -0
- package/dist-cjs/scenario-runner/websocket.js.map +1 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ const condition_js_1 = require("./condition.js");
|
|
|
5
5
|
const extraction_js_1 = require("./extraction.js");
|
|
6
6
|
const assertion_js_1 = require("./assertion.js");
|
|
7
7
|
const graphTraversal_js_1 = require("./graphTraversal.js");
|
|
8
|
+
const websocket_js_1 = require("./websocket.js");
|
|
8
9
|
const executionPolicyRunner_js_1 = require("./executionPolicyRunner.js");
|
|
9
10
|
const nodePolicy_js_1 = require("./nodePolicy.js");
|
|
10
11
|
const templateResolver_js_1 = require("./templateResolver.js");
|
|
@@ -36,9 +37,19 @@ const buildTitle = (node) => {
|
|
|
36
37
|
return `${node.type[0]?.toUpperCase() ?? ""}${node.type.slice(1)}`;
|
|
37
38
|
};
|
|
38
39
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
-
// `loop`/`loops` are the loop-iteration template roots
|
|
40
|
+
// `loop`/`loops` are the loop-iteration template roots and `item`/`each`/
|
|
41
|
+
// `eaches` the foreach-iteration ones (see the loop-frame and foreach
|
|
40
42
|
// machinery in runScenario) — node labels must not shadow them.
|
|
41
|
-
const RESERVED_WORKFLOW_ROOT_LABELS = new Set([
|
|
43
|
+
const RESERVED_WORKFLOW_ROOT_LABELS = new Set([
|
|
44
|
+
"workflow",
|
|
45
|
+
"__meta",
|
|
46
|
+
"loop",
|
|
47
|
+
"loops",
|
|
48
|
+
"item",
|
|
49
|
+
"each",
|
|
50
|
+
"eaches",
|
|
51
|
+
"ws",
|
|
52
|
+
]);
|
|
42
53
|
const cloneNodeExecutionMeta = (meta) => ({
|
|
43
54
|
...meta,
|
|
44
55
|
...(meta.error ? { error: { ...meta.error } } : {}),
|
|
@@ -416,6 +427,18 @@ const updateRecordFromEvent = (records, event) => {
|
|
|
416
427
|
});
|
|
417
428
|
return;
|
|
418
429
|
}
|
|
430
|
+
if (event.type === "step:iteration") {
|
|
431
|
+
// Iteration progress marker only — the terminal success/fail event owns
|
|
432
|
+
// the record. Without this early return the generic fallback below would
|
|
433
|
+
// stamp status:"failed" on the foreach step.
|
|
434
|
+
records.set(event.stepId, {
|
|
435
|
+
...current,
|
|
436
|
+
stepType: event.stepType,
|
|
437
|
+
title: event.title,
|
|
438
|
+
startedAt: current.startedAt ?? event.at,
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
419
442
|
if (event.type === "step:condition") {
|
|
420
443
|
records.set(event.stepId, {
|
|
421
444
|
...current,
|
|
@@ -511,6 +534,9 @@ const callHandler = async (handlers, event) => {
|
|
|
511
534
|
case "step:poll":
|
|
512
535
|
await handlers?.onPollTick?.(event);
|
|
513
536
|
return;
|
|
537
|
+
case "step:iteration":
|
|
538
|
+
await handlers?.onForeachIteration?.(event);
|
|
539
|
+
return;
|
|
514
540
|
case "step:success":
|
|
515
541
|
await handlers?.onNodeSuccess?.(event);
|
|
516
542
|
return;
|
|
@@ -824,10 +850,10 @@ const runScenario = async ({ scenario, environment = {}, globals = {}, runtimeAd
|
|
|
824
850
|
let executionPolicy = (0, nodePolicy_js_1.supportsScenarioNodeExecutionPolicy)(node)
|
|
825
851
|
? withNotificationFailurePolicyOverride(node, (0, nodePolicy_js_1.getScenarioNodeExecutionPolicy)(node))
|
|
826
852
|
: (0, nodePolicy_js_1.getLegacyScenarioNodeExecutionPolicy)();
|
|
827
|
-
if (node.type === "poll") {
|
|
828
|
-
// The poll loop REPLACES per-node retry (nested retry loops would
|
|
829
|
-
// unexplainable). onFailure — incl. error edges — still governs the
|
|
830
|
-
//
|
|
853
|
+
if (node.type === "poll" || node.type === "foreach") {
|
|
854
|
+
// The poll/item loop REPLACES per-node retry (nested retry loops would
|
|
855
|
+
// be unexplainable). onFailure — incl. error edges — still governs the
|
|
856
|
+
// final outcome.
|
|
831
857
|
executionPolicy = {
|
|
832
858
|
...executionPolicy,
|
|
833
859
|
retry: { enabled: false, maxAttempts: 0, delayMs: 0 },
|
|
@@ -1424,6 +1450,163 @@ const runScenario = async ({ scenario, environment = {}, globals = {}, runtimeAd
|
|
|
1424
1450
|
outputData = runtimeContext.kafkaEvent;
|
|
1425
1451
|
}
|
|
1426
1452
|
}
|
|
1453
|
+
if (node.type === "websocket") {
|
|
1454
|
+
const wsConfig = node.data.config;
|
|
1455
|
+
const resolvedUrl = (0, templateResolver_js_1.resolveScenarioTemplateString)(wsConfig.url ?? "", templateValues).trim();
|
|
1456
|
+
if (!resolvedUrl) {
|
|
1457
|
+
throw new ScenarioNodeFailureError("WebSocket URL is required.");
|
|
1458
|
+
}
|
|
1459
|
+
const configuredSteps = wsConfig.steps ?? [];
|
|
1460
|
+
if (configuredSteps.length > graphTraversal_js_1.MAX_WS_STEPS) {
|
|
1461
|
+
throw new ScenarioNodeFailureError(`WebSocket conversations are capped at ${graphTraversal_js_1.MAX_WS_STEPS} steps.`);
|
|
1462
|
+
}
|
|
1463
|
+
for (const [stepIndex, step] of configuredSteps.entries()) {
|
|
1464
|
+
if (step.kind === "expect" && (!step.rules || step.rules.length === 0)) {
|
|
1465
|
+
throw new ScenarioNodeFailureError(`WebSocket expect step ${stepIndex + 1} has no rules.`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
const resolvedHeaders = (wsConfig.headers ?? [])
|
|
1469
|
+
.filter((header) => header.enabled !== false && header.key.trim())
|
|
1470
|
+
.map((header) => ({
|
|
1471
|
+
key: (0, templateResolver_js_1.resolveScenarioTemplateString)(header.key, templateValues).trim(),
|
|
1472
|
+
value: (0, templateResolver_js_1.resolveScenarioTemplateString)(header.value ?? "", templateValues),
|
|
1473
|
+
}));
|
|
1474
|
+
const resolvedSubprotocols = (wsConfig.subprotocols ?? [])
|
|
1475
|
+
.map((protocol) => (0, templateResolver_js_1.resolveScenarioTemplateString)(protocol, templateValues).trim())
|
|
1476
|
+
.filter((protocol) => protocol.length > 0);
|
|
1477
|
+
const connectTimeoutMs = Math.min(graphTraversal_js_1.MAX_WS_CONNECT_TIMEOUT_MS, Math.max(100, Math.round(wsConfig.connectTimeoutMs ?? graphTraversal_js_1.DEFAULT_WS_CONNECT_TIMEOUT_MS)));
|
|
1478
|
+
const resolvedSteps = configuredSteps.map((step) => {
|
|
1479
|
+
if (step.kind === "send") {
|
|
1480
|
+
return {
|
|
1481
|
+
id: step.id,
|
|
1482
|
+
kind: "send",
|
|
1483
|
+
payload: (0, templateResolver_js_1.resolveScenarioJsonTemplateString)(step.payload ?? "", templateValues),
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
if (step.kind === "wait") {
|
|
1487
|
+
return {
|
|
1488
|
+
id: step.id,
|
|
1489
|
+
kind: "wait",
|
|
1490
|
+
durationMs: Math.min(graphTraversal_js_1.MAX_WS_WAIT_DURATION_MS, Math.max(0, Math.round(step.durationMs ?? 0))),
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
return {
|
|
1494
|
+
id: step.id,
|
|
1495
|
+
kind: "expect",
|
|
1496
|
+
timeoutMs: Math.min(graphTraversal_js_1.MAX_WS_EXPECT_TIMEOUT_MS, Math.max(100, Math.round(step.timeoutMs ?? graphTraversal_js_1.DEFAULT_WS_EXPECT_TIMEOUT_MS))),
|
|
1497
|
+
matchMode: step.matchMode === "any" ? "any" : "all",
|
|
1498
|
+
rules: step.rules,
|
|
1499
|
+
};
|
|
1500
|
+
});
|
|
1501
|
+
const wsExecution = await runtimeAdapters.websocketExecutor({
|
|
1502
|
+
runId,
|
|
1503
|
+
stepId,
|
|
1504
|
+
node,
|
|
1505
|
+
resolvedUrl,
|
|
1506
|
+
resolvedHeaders,
|
|
1507
|
+
subprotocols: resolvedSubprotocols,
|
|
1508
|
+
connectTimeoutMs,
|
|
1509
|
+
steps: resolvedSteps,
|
|
1510
|
+
closeCode: wsConfig.closeCode ?? graphTraversal_js_1.DEFAULT_WS_CLOSE_CODE,
|
|
1511
|
+
rejectUnauthorized: wsConfig.rejectUnauthorized !== false,
|
|
1512
|
+
environment: { ...runtimeContext.environment },
|
|
1513
|
+
globals: { ...runtimeContext.globals },
|
|
1514
|
+
signal,
|
|
1515
|
+
});
|
|
1516
|
+
throwIfStopped(signal);
|
|
1517
|
+
// Authoritative expect evaluation over the recorded frames — the
|
|
1518
|
+
// host's early-stop is advisory only (kafka consume precedent).
|
|
1519
|
+
const evaluationScope = {
|
|
1520
|
+
environment: runtimeContext.environment,
|
|
1521
|
+
globals: runtimeContext.globals,
|
|
1522
|
+
workflowContext: runtimeContext.workflowContext,
|
|
1523
|
+
nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
|
|
1524
|
+
responseRoot: runtimeContext.responseRoot,
|
|
1525
|
+
dbResult: runtimeContext.dbResult,
|
|
1526
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
1527
|
+
redisResult: runtimeContext.redisResult,
|
|
1528
|
+
};
|
|
1529
|
+
const outcomesByIndex = new Map(wsExecution.stepOutcomes.map((outcome) => [outcome.index, outcome]));
|
|
1530
|
+
let conversationBroken = !wsExecution.connected;
|
|
1531
|
+
const stepResults = resolvedSteps.map((step, index) => {
|
|
1532
|
+
const outcome = outcomesByIndex.get(index);
|
|
1533
|
+
const durationMs = outcome && outcome.startedAt && outcome.endedAt
|
|
1534
|
+
? Math.max(0, new Date(outcome.endedAt).getTime() - new Date(outcome.startedAt).getTime())
|
|
1535
|
+
: 0;
|
|
1536
|
+
if (!outcome) {
|
|
1537
|
+
return {
|
|
1538
|
+
index,
|
|
1539
|
+
kind: step.kind,
|
|
1540
|
+
status: "failed",
|
|
1541
|
+
durationMs: 0,
|
|
1542
|
+
error: conversationBroken
|
|
1543
|
+
? "Step not reached — the conversation ended early."
|
|
1544
|
+
: "Step was not executed by the host.",
|
|
1545
|
+
matchedMessageIndex: null,
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
if (step.kind !== "expect") {
|
|
1549
|
+
const failed = Boolean(outcome.error);
|
|
1550
|
+
if (failed)
|
|
1551
|
+
conversationBroken = true;
|
|
1552
|
+
return {
|
|
1553
|
+
index,
|
|
1554
|
+
kind: step.kind,
|
|
1555
|
+
status: failed ? "failed" : "passed",
|
|
1556
|
+
durationMs,
|
|
1557
|
+
error: outcome.error ?? null,
|
|
1558
|
+
matchedMessageIndex: null,
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
const candidates = wsExecution.messages.filter((message) => message.direction === "received" && message.stepIndex === index);
|
|
1562
|
+
let matchedMessageIndex = null;
|
|
1563
|
+
for (const candidate of candidates) {
|
|
1564
|
+
const evaluation = (0, websocket_js_1.evaluateWebsocketExpect)(step.rules ?? [], step.matchMode ?? "all", evaluationScope, candidate);
|
|
1565
|
+
if (evaluation.passed) {
|
|
1566
|
+
matchedMessageIndex = candidate.index;
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const failed = matchedMessageIndex === null;
|
|
1571
|
+
if (failed)
|
|
1572
|
+
conversationBroken = true;
|
|
1573
|
+
return {
|
|
1574
|
+
index,
|
|
1575
|
+
kind: step.kind,
|
|
1576
|
+
status: failed ? "failed" : "passed",
|
|
1577
|
+
durationMs,
|
|
1578
|
+
error: failed
|
|
1579
|
+
? (outcome.error ??
|
|
1580
|
+
`No message matched within ${step.timeoutMs}ms (${candidates.length} received).`)
|
|
1581
|
+
: null,
|
|
1582
|
+
matchedMessageIndex,
|
|
1583
|
+
};
|
|
1584
|
+
});
|
|
1585
|
+
const failedSteps = stepResults.filter((entry) => entry.status === "failed").length;
|
|
1586
|
+
const wsErrorMessage = !wsExecution.connected
|
|
1587
|
+
? (wsExecution.connectError ?? "WebSocket connection failed.")
|
|
1588
|
+
: failedSteps > 0
|
|
1589
|
+
? (() => {
|
|
1590
|
+
const firstFailed = stepResults.find((entry) => entry.status === "failed");
|
|
1591
|
+
return `WebSocket step ${(firstFailed?.index ?? 0) + 1} (${firstFailed?.kind}) failed: ${firstFailed?.error}`;
|
|
1592
|
+
})()
|
|
1593
|
+
: null;
|
|
1594
|
+
outputData = {
|
|
1595
|
+
url: resolvedUrl,
|
|
1596
|
+
connected: wsExecution.connected,
|
|
1597
|
+
closeCode: wsExecution.closeCode,
|
|
1598
|
+
closeReason: wsExecution.closeReason ?? null,
|
|
1599
|
+
durationMs: wsExecution.durationMs,
|
|
1600
|
+
messageCount: wsExecution.messages.length,
|
|
1601
|
+
messages: wsExecution.messages,
|
|
1602
|
+
steps: stepResults,
|
|
1603
|
+
failedSteps,
|
|
1604
|
+
errorMessage: wsErrorMessage,
|
|
1605
|
+
};
|
|
1606
|
+
if (wsErrorMessage) {
|
|
1607
|
+
throw new ScenarioNodeFailureError(wsErrorMessage);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1427
1610
|
if (node.type === "condition") {
|
|
1428
1611
|
const conditionIssues = (0, condition_js_1.validateConditionConfig)(node.data.config);
|
|
1429
1612
|
const blockingConditionIssue = conditionIssues.find((issue) => issue.severity === "error");
|
|
@@ -1536,6 +1719,228 @@ const runScenario = async ({ scenario, environment = {}, globals = {}, runtimeAd
|
|
|
1536
1719
|
shouldMarkPartialSuccess = degradedStepIds.size > degradedCountBefore;
|
|
1537
1720
|
outputData = groupedOutput;
|
|
1538
1721
|
}
|
|
1722
|
+
if (node.type === "foreach") {
|
|
1723
|
+
const foreachConfig = node.data.config;
|
|
1724
|
+
// ── Resolve the items array ──────────────────────────────────────
|
|
1725
|
+
let items;
|
|
1726
|
+
if (foreachConfig.sourceMode === "inline") {
|
|
1727
|
+
const rawInline = (foreachConfig.itemsInline ?? "").trim();
|
|
1728
|
+
if (!rawInline) {
|
|
1729
|
+
throw new ScenarioNodeFailureError("Foreach inline items are empty.");
|
|
1730
|
+
}
|
|
1731
|
+
const resolvedInline = (0, templateResolver_js_1.resolveScenarioJsonTemplateString)(rawInline, templateValues);
|
|
1732
|
+
let parsedInline;
|
|
1733
|
+
try {
|
|
1734
|
+
parsedInline = JSON.parse(resolvedInline);
|
|
1735
|
+
}
|
|
1736
|
+
catch {
|
|
1737
|
+
throw new ScenarioNodeFailureError("Foreach inline items are not valid JSON after template resolution.");
|
|
1738
|
+
}
|
|
1739
|
+
if (!Array.isArray(parsedInline)) {
|
|
1740
|
+
throw new ScenarioNodeFailureError("Foreach inline items must be a JSON array.");
|
|
1741
|
+
}
|
|
1742
|
+
items = parsedInline;
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
const itemsPath = (foreachConfig.itemsPath ?? "").trim();
|
|
1746
|
+
if (!itemsPath) {
|
|
1747
|
+
throw new ScenarioNodeFailureError("Foreach items path is required.");
|
|
1748
|
+
}
|
|
1749
|
+
const resolved = (0, condition_js_1.resolveConditionRuntimeValue)({
|
|
1750
|
+
environment: runtimeContext.environment,
|
|
1751
|
+
globals: runtimeContext.globals,
|
|
1752
|
+
workflowContext: runtimeContext.workflowContext,
|
|
1753
|
+
nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
|
|
1754
|
+
responseRoot: runtimeContext.responseRoot,
|
|
1755
|
+
dbResult: runtimeContext.dbResult,
|
|
1756
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
1757
|
+
redisResult: runtimeContext.redisResult,
|
|
1758
|
+
}, itemsPath);
|
|
1759
|
+
if (!resolved.exists || typeof resolved.value === "undefined") {
|
|
1760
|
+
throw new ScenarioNodeFailureError(`Foreach items path "${itemsPath}" resolved to nothing.`);
|
|
1761
|
+
}
|
|
1762
|
+
if (!Array.isArray(resolved.value)) {
|
|
1763
|
+
throw new ScenarioNodeFailureError(`Foreach items path "${itemsPath}" must resolve to an array (got ${typeof resolved.value}).`);
|
|
1764
|
+
}
|
|
1765
|
+
items = resolved.value;
|
|
1766
|
+
}
|
|
1767
|
+
const itemAlias = (foreachConfig.itemAlias ?? "").trim();
|
|
1768
|
+
if (itemAlias && RESERVED_WORKFLOW_ROOT_LABELS.has(itemAlias)) {
|
|
1769
|
+
throw new ScenarioNodeFailureError(`Foreach item alias "${itemAlias}" is a reserved name.`);
|
|
1770
|
+
}
|
|
1771
|
+
const maxIterations = Math.min(graphTraversal_js_1.MAX_FOREACH_ITERATIONS, Math.max(1, Math.round(foreachConfig.maxIterations ?? graphTraversal_js_1.DEFAULT_FOREACH_MAX_ITERATIONS)));
|
|
1772
|
+
const processLimit = Math.min(items.length, maxIterations);
|
|
1773
|
+
const iterationDelayMs = Math.max(0, Math.round(foreachConfig.delayMs ?? 0));
|
|
1774
|
+
const onItemFailure = foreachConfig.onItemFailure ?? "stop";
|
|
1775
|
+
const collectResults = foreachConfig.collectResults !== false;
|
|
1776
|
+
// Thinned emission keeps stored events bounded (~100 iteration markers).
|
|
1777
|
+
const iterationEmitStride = Math.max(1, Math.ceil(processLimit / 100));
|
|
1778
|
+
const internalGraph = (0, templateResolver_js_1.buildScenarioExecutionGraph)(foreachConfig.nodes, foreachConfig.edges);
|
|
1779
|
+
const internalScope = {
|
|
1780
|
+
graph: internalGraph,
|
|
1781
|
+
stepIdForNode: (childNodeId) => (0, graphTraversal_js_1.createSubScenarioStepId)(stepId, childNodeId),
|
|
1782
|
+
stepIdForEdge: (edgeId) => (0, graphTraversal_js_1.createSubScenarioStepId)(stepId, edgeId),
|
|
1783
|
+
};
|
|
1784
|
+
// ALL descendant step ids (nested containers included) — reset
|
|
1785
|
+
// targets between iterations.
|
|
1786
|
+
const internalStepIds = (0, graphTraversal_js_1.buildRuntimeStepDescriptors)(foreachConfig.nodes, stepId).map((descriptor) => descriptor.stepId);
|
|
1787
|
+
const childLabels = (0, graphTraversal_js_1.flattenScenarioNodesForContext)(foreachConfig.nodes)
|
|
1788
|
+
.map((childNode) => childNode.data.label.trim())
|
|
1789
|
+
.filter((childLabel) => childLabel.length > 0);
|
|
1790
|
+
const workflowRoots = runtimeContext.workflowContext;
|
|
1791
|
+
// Save the enclosing iteration roots so a NESTED foreach restores
|
|
1792
|
+
// its parent's {{item}}/{{each}} when it finishes mid-iteration.
|
|
1793
|
+
const hadItemRoot = Object.prototype.hasOwnProperty.call(workflowRoots, "item");
|
|
1794
|
+
const previousItemRoot = workflowRoots.item;
|
|
1795
|
+
const hadEachRoot = Object.prototype.hasOwnProperty.call(workflowRoots, "each");
|
|
1796
|
+
const previousEachRoot = workflowRoots.each;
|
|
1797
|
+
const hadAliasRoot = itemAlias
|
|
1798
|
+
? Object.prototype.hasOwnProperty.call(workflowRoots, itemAlias)
|
|
1799
|
+
: false;
|
|
1800
|
+
const previousAliasRoot = itemAlias ? workflowRoots[itemAlias] : undefined;
|
|
1801
|
+
const nodeLabelKey = nodeLabel;
|
|
1802
|
+
const previousEaches = workflowRoots.eaches;
|
|
1803
|
+
const degradedCountBefore = degradedStepIds.size;
|
|
1804
|
+
const results = [];
|
|
1805
|
+
let failedItems = 0;
|
|
1806
|
+
try {
|
|
1807
|
+
for (let index = 0; index < processLimit; index += 1) {
|
|
1808
|
+
throwIfStopped(signal);
|
|
1809
|
+
if (index > 0) {
|
|
1810
|
+
// Fresh pass: every internal step back to idle (including the
|
|
1811
|
+
// "skipped" stamped on unreached nodes last pass), degraded
|
|
1812
|
+
// marks cleared, records reset via the bulk step:reset event.
|
|
1813
|
+
for (const internalStepId of internalStepIds) {
|
|
1814
|
+
localStatuses.set(internalStepId, "idle");
|
|
1815
|
+
degradedStepIds.delete(internalStepId);
|
|
1816
|
+
}
|
|
1817
|
+
await emit({
|
|
1818
|
+
type: "step:reset",
|
|
1819
|
+
runId,
|
|
1820
|
+
scenarioId: scenario.id,
|
|
1821
|
+
stepIds: internalStepIds,
|
|
1822
|
+
at: new Date().toISOString(),
|
|
1823
|
+
reason: `Item ${index + 1}/${processLimit}: reset foreach body`,
|
|
1824
|
+
});
|
|
1825
|
+
if (iterationDelayMs > 0) {
|
|
1826
|
+
await waitForDuration(iterationDelayMs);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
// Fresh-pass isolation: a failed/short-circuited item must not
|
|
1830
|
+
// inherit the previous iteration's child outputs in its results
|
|
1831
|
+
// snapshot. The final iteration's children stay published after
|
|
1832
|
+
// the loop (last-iteration-wins for downstream reads).
|
|
1833
|
+
for (const childLabel of childLabels) {
|
|
1834
|
+
delete workflowRoots[childLabel];
|
|
1835
|
+
}
|
|
1836
|
+
const item = items[index];
|
|
1837
|
+
const frame = {
|
|
1838
|
+
index,
|
|
1839
|
+
iteration: index + 1,
|
|
1840
|
+
total: processLimit,
|
|
1841
|
+
remaining: processLimit - (index + 1),
|
|
1842
|
+
};
|
|
1843
|
+
workflowRoots.item = item;
|
|
1844
|
+
if (itemAlias) {
|
|
1845
|
+
workflowRoots[itemAlias] = item;
|
|
1846
|
+
}
|
|
1847
|
+
workflowRoots.each = frame;
|
|
1848
|
+
if (nodeLabelKey) {
|
|
1849
|
+
const eachesRoot = workflowRoots.eaches && typeof workflowRoots.eaches === "object"
|
|
1850
|
+
? workflowRoots.eaches
|
|
1851
|
+
: {};
|
|
1852
|
+
workflowRoots.eaches = { ...eachesRoot, [nodeLabelKey]: frame };
|
|
1853
|
+
}
|
|
1854
|
+
if (index === 0 ||
|
|
1855
|
+
index === processLimit - 1 ||
|
|
1856
|
+
(index + 1) % iterationEmitStride === 0) {
|
|
1857
|
+
const itemPreview = (() => {
|
|
1858
|
+
try {
|
|
1859
|
+
const serialized = JSON.stringify(item);
|
|
1860
|
+
if (typeof serialized !== "string")
|
|
1861
|
+
return String(item);
|
|
1862
|
+
return serialized.length > 2048 ? `${serialized.slice(0, 2048)}…` : serialized;
|
|
1863
|
+
}
|
|
1864
|
+
catch {
|
|
1865
|
+
return null;
|
|
1866
|
+
}
|
|
1867
|
+
})();
|
|
1868
|
+
await emit({
|
|
1869
|
+
type: "step:iteration",
|
|
1870
|
+
runId,
|
|
1871
|
+
scenarioId: scenario.id,
|
|
1872
|
+
stepId,
|
|
1873
|
+
stepType: node.type,
|
|
1874
|
+
title,
|
|
1875
|
+
at: new Date().toISOString(),
|
|
1876
|
+
index,
|
|
1877
|
+
iteration: index + 1,
|
|
1878
|
+
total: processLimit,
|
|
1879
|
+
itemPreview,
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
try {
|
|
1883
|
+
for (const startNodeId of internalGraph.startNodeIds) {
|
|
1884
|
+
throwIfStopped(signal);
|
|
1885
|
+
await executeNode(startNodeId, null, {}, internalScope);
|
|
1886
|
+
}
|
|
1887
|
+
await markScopeRemainingNodesSkipped(internalScope, `Internal node was not reached inside ${title} (item ${index + 1}).`);
|
|
1888
|
+
}
|
|
1889
|
+
catch (error) {
|
|
1890
|
+
if (error instanceof ScenarioStopError) {
|
|
1891
|
+
throw error;
|
|
1892
|
+
}
|
|
1893
|
+
failedItems += 1;
|
|
1894
|
+
if (onItemFailure === "stop") {
|
|
1895
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1896
|
+
throw new ScenarioNodeFailureError(`Foreach item ${index + 1}/${processLimit} failed: ${message}`);
|
|
1897
|
+
}
|
|
1898
|
+
// continue: unreached internal nodes stay consistent for the
|
|
1899
|
+
// report, the run degrades to partial success below.
|
|
1900
|
+
await markScopeRemainingNodesSkipped(internalScope, `Internal node was not reached inside ${title} (item ${index + 1} failed).`);
|
|
1901
|
+
}
|
|
1902
|
+
if (collectResults) {
|
|
1903
|
+
const iterationOutput = {};
|
|
1904
|
+
for (const childLabel of childLabels) {
|
|
1905
|
+
if (Object.prototype.hasOwnProperty.call(workflowRoots, childLabel)) {
|
|
1906
|
+
iterationOutput[childLabel] = workflowRoots[childLabel];
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
results.push(iterationOutput);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
finally {
|
|
1914
|
+
// Restore the enclosing iteration roots (or clear ours).
|
|
1915
|
+
if (hadItemRoot)
|
|
1916
|
+
workflowRoots.item = previousItemRoot;
|
|
1917
|
+
else
|
|
1918
|
+
delete workflowRoots.item;
|
|
1919
|
+
if (hadEachRoot)
|
|
1920
|
+
workflowRoots.each = previousEachRoot;
|
|
1921
|
+
else
|
|
1922
|
+
delete workflowRoots.each;
|
|
1923
|
+
if (itemAlias) {
|
|
1924
|
+
if (hadAliasRoot)
|
|
1925
|
+
workflowRoots[itemAlias] = previousAliasRoot;
|
|
1926
|
+
else
|
|
1927
|
+
delete workflowRoots[itemAlias];
|
|
1928
|
+
}
|
|
1929
|
+
if (typeof previousEaches === "undefined")
|
|
1930
|
+
delete workflowRoots.eaches;
|
|
1931
|
+
else
|
|
1932
|
+
workflowRoots.eaches = previousEaches;
|
|
1933
|
+
}
|
|
1934
|
+
shouldMarkPartialSuccess =
|
|
1935
|
+
degradedStepIds.size > degradedCountBefore || failedItems > 0;
|
|
1936
|
+
outputData = {
|
|
1937
|
+
...(collectResults ? { results } : {}),
|
|
1938
|
+
itemCount: items.length,
|
|
1939
|
+
processedCount: processLimit,
|
|
1940
|
+
truncated: items.length > processLimit,
|
|
1941
|
+
failedItems,
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1539
1944
|
if (node.type === "end") {
|
|
1540
1945
|
if (node.data.config.loop && node.data.config.loopTargetNodeId) {
|
|
1541
1946
|
const loopTargetNode = scope.graph.nodeMap.get(node.data.config.loopTargetNodeId);
|