callman-core 1.0.4 → 1.0.6

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.
Files changed (39) hide show
  1. package/dist/scenario-runner/condition.d.ts.map +1 -1
  2. package/dist/scenario-runner/condition.js +180 -1
  3. package/dist/scenario-runner/condition.js.map +1 -1
  4. package/dist/scenario-runner/conditionTypes.d.ts +1 -0
  5. package/dist/scenario-runner/conditionTypes.d.ts.map +1 -1
  6. package/dist/scenario-runner/index.d.ts +1 -0
  7. package/dist/scenario-runner/index.d.ts.map +1 -1
  8. package/dist/scenario-runner/index.js +1 -0
  9. package/dist/scenario-runner/index.js.map +1 -1
  10. package/dist/scenario-runner/nodePolicy.d.ts +2 -2
  11. package/dist/scenario-runner/nodePolicy.d.ts.map +1 -1
  12. package/dist/scenario-runner/nodePolicy.js +8 -0
  13. package/dist/scenario-runner/nodePolicy.js.map +1 -1
  14. package/dist/scenario-runner/notification/index.d.ts +7 -0
  15. package/dist/scenario-runner/notification/index.d.ts.map +1 -0
  16. package/dist/scenario-runner/notification/index.js +14 -0
  17. package/dist/scenario-runner/notification/index.js.map +1 -0
  18. package/dist/scenario-runner/notification/providers/provider.interface.d.ts +20 -0
  19. package/dist/scenario-runner/notification/providers/provider.interface.d.ts.map +1 -0
  20. package/dist/scenario-runner/notification/providers/provider.interface.js +2 -0
  21. package/dist/scenario-runner/notification/providers/provider.interface.js.map +1 -0
  22. package/dist/scenario-runner/notification/providers/slack.provider.d.ts +3 -0
  23. package/dist/scenario-runner/notification/providers/slack.provider.d.ts.map +1 -0
  24. package/dist/scenario-runner/notification/providers/slack.provider.js +85 -0
  25. package/dist/scenario-runner/notification/providers/slack.provider.js.map +1 -0
  26. package/dist/scenario-runner/notification/template.d.ts +2 -0
  27. package/dist/scenario-runner/notification/template.d.ts.map +1 -0
  28. package/dist/scenario-runner/notification/template.js +9 -0
  29. package/dist/scenario-runner/notification/template.js.map +1 -0
  30. package/dist/scenario-runner/runScenario.d.ts.map +1 -1
  31. package/dist/scenario-runner/runScenario.js +395 -15
  32. package/dist/scenario-runner/runScenario.js.map +1 -1
  33. package/dist/scenario-runner/templateResolver.d.ts +3 -2
  34. package/dist/scenario-runner/templateResolver.d.ts.map +1 -1
  35. package/dist/scenario-runner/templateResolver.js +19 -1
  36. package/dist/scenario-runner/templateResolver.js.map +1 -1
  37. package/dist/scenario-runner/types.d.ts +92 -3
  38. package/dist/scenario-runner/types.d.ts.map +1 -1
  39. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { executeWithScenarioNodePolicy } from "./executionPolicyRunner.js";
4
4
  import { getLegacyScenarioNodeExecutionPolicy, getScenarioNodeExecutionPolicy, supportsScenarioNodeExecutionPolicy, } from "./nodePolicy.js";
5
5
  import { buildScenarioExecutionGraph, buildScenarioTemplateValues, collectExclusiveBranchNodeIds, evaluateScenarioExpression, resolveScenarioJsonTemplateString, resolveScenarioTemplateString, } from "./templateResolver.js";
6
6
  import { executeWaitNode, ScenarioWaitNodeCancelledError, ScenarioWaitNodeValidationError, } from "./waitNodeExecutor.js";
7
+ import { buildNotificationDeliveryRequest, renderNotificationTemplate, } from "./notification/index.js";
7
8
  class ScenarioStopError extends Error {
8
9
  constructor() {
9
10
  super("Scenario execution stopped");
@@ -31,6 +32,57 @@ const buildTitle = (node) => {
31
32
  };
32
33
  const isKafkaNode = (node) => node?.type === "kafka";
33
34
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
35
+ const RESERVED_WORKFLOW_ROOT_LABELS = new Set(["workflow", "__meta"]);
36
+ const cloneNodeExecutionMeta = (meta) => ({
37
+ ...meta,
38
+ ...(meta.error ? { error: { ...meta.error } } : {}),
39
+ });
40
+ const cloneWorkflowExecutionMeta = (meta) => ({
41
+ ...meta,
42
+ });
43
+ const cloneNodeMetaByLabel = (value) => Object.fromEntries(Object.entries(value).map(([label, meta]) => [label, cloneNodeExecutionMeta(meta)]));
44
+ const attachNodeMetaToOutput = (output, meta) => {
45
+ const normalizedMeta = cloneNodeExecutionMeta(meta);
46
+ if (Array.isArray(output)) {
47
+ const nextValue = [...output];
48
+ nextValue.__meta = normalizedMeta;
49
+ return nextValue;
50
+ }
51
+ if (isRecord(output)) {
52
+ return {
53
+ ...output,
54
+ __meta: normalizedMeta,
55
+ };
56
+ }
57
+ if (output === null || typeof output === "undefined") {
58
+ return {
59
+ __meta: normalizedMeta,
60
+ };
61
+ }
62
+ return output;
63
+ };
64
+ const createInitialWorkflowMeta = ({ startedAt, totalNodes, }) => ({
65
+ status: "success",
66
+ success: true,
67
+ failed: false,
68
+ totalNodes,
69
+ successfulNodes: 0,
70
+ failedNodes: 0,
71
+ skippedNodes: 0,
72
+ totalRetries: 0,
73
+ startedAt,
74
+ endedAt: startedAt,
75
+ durationMs: 0,
76
+ currentNode: null,
77
+ lastNode: null,
78
+ failedNode: null,
79
+ });
80
+ const buildWorkflowRoot = ({ scenario, meta, }) => ({
81
+ id: scenario.id,
82
+ name: scenario.name,
83
+ ...(typeof scenario.version === "number" ? { version: scenario.version } : {}),
84
+ __meta: cloneWorkflowExecutionMeta(meta),
85
+ });
34
86
  const createInputSnapshot = (context) => ({
35
87
  env: context.environment,
36
88
  global: context.globals,
@@ -48,6 +100,8 @@ const createContextSnapshot = (context) => ({
48
100
  environment: { ...context.environment },
49
101
  globals: { ...context.globals },
50
102
  workflowContext: { ...context.workflowContext },
103
+ nodeMetaByLabel: cloneNodeMetaByLabel(context.nodeMetaByLabel),
104
+ workflowMeta: cloneWorkflowExecutionMeta(context.workflowMeta),
51
105
  responseRoot: context.responseRoot ? { ...context.responseRoot } : null,
52
106
  lastResponse: context.lastResponse,
53
107
  dbResult: context.dbResult,
@@ -126,11 +180,104 @@ const throwIfStopped = (signal) => {
126
180
  throw new ScenarioStopError();
127
181
  }
128
182
  };
183
+ const isWorkflowFailureStatus = (status) => status === "failed" || status === "failed_continued";
184
+ const isWorkflowSuccessStatus = (status) => status === "success" || status === "partial_success";
185
+ const buildNodeExecutionMeta = ({ status, attempts, startedAt, endedAt, durationMs, continueAfterFailure = false, errorMessage, }) => ({
186
+ status,
187
+ success: status === "success",
188
+ failed: status === "failed",
189
+ skipped: status === "skipped",
190
+ attempts,
191
+ startedAt,
192
+ endedAt,
193
+ durationMs,
194
+ ...(continueAfterFailure ? { continueAfterFailure: true } : {}),
195
+ ...(errorMessage
196
+ ? {
197
+ error: {
198
+ message: errorMessage,
199
+ type: "ScenarioNodeFailureError",
200
+ },
201
+ }
202
+ : {}),
203
+ });
204
+ const updateWorkflowMetaState = ({ context, records, localStatuses, startedAt, currentNode, lastNode, failedNode, endedAt = new Date().toISOString(), }) => {
205
+ const statuses = Array.from(localStatuses.values());
206
+ const successfulNodes = statuses.filter(isWorkflowSuccessStatus).length;
207
+ const failedNodes = statuses.filter(isWorkflowFailureStatus).length;
208
+ const skippedNodes = statuses.filter((status) => status === "skipped").length;
209
+ const totalRetries = Array.from(records.values()).reduce((sum, record) => sum + Math.max(0, record.retryCount), 0);
210
+ const hasPartialNodes = statuses.some((status) => status === "failed_continued" || status === "partial_success");
211
+ const nextStatus = failedNodes > 0 && !hasPartialNodes
212
+ ? "failed"
213
+ : failedNodes > 0 || hasPartialNodes
214
+ ? "partial"
215
+ : "success";
216
+ const nextMeta = {
217
+ status: nextStatus,
218
+ success: nextStatus === "success",
219
+ failed: nextStatus === "failed",
220
+ totalNodes: context.workflowMeta.totalNodes,
221
+ successfulNodes,
222
+ failedNodes,
223
+ skippedNodes,
224
+ totalRetries,
225
+ startedAt,
226
+ endedAt,
227
+ durationMs: Math.max(0, new Date(endedAt).getTime() - new Date(startedAt).getTime()),
228
+ currentNode: currentNode ?? context.workflowMeta.currentNode ?? null,
229
+ lastNode: lastNode ?? context.workflowMeta.lastNode ?? null,
230
+ failedNode: failedNode ?? context.workflowMeta.failedNode ?? null,
231
+ };
232
+ context.workflowMeta = nextMeta;
233
+ context.workflowContext.workflow = {
234
+ ...(isRecord(context.workflowContext.workflow)
235
+ ? context.workflowContext.workflow
236
+ : {}),
237
+ __meta: cloneWorkflowExecutionMeta(nextMeta),
238
+ };
239
+ return nextMeta;
240
+ };
241
+ const withNotificationFailurePolicyOverride = (node, policy) => {
242
+ if (node.type !== "notification") {
243
+ return policy;
244
+ }
245
+ if (typeof node.data.config.continueOnFailure !== "boolean") {
246
+ return policy;
247
+ }
248
+ return {
249
+ ...policy,
250
+ onFailure: node.data.config.continueOnFailure ? "continue" : "stop",
251
+ };
252
+ };
253
+ const normalizeNotificationSeverity = (value) => {
254
+ switch (value) {
255
+ case "success":
256
+ case "warning":
257
+ case "error":
258
+ return value;
259
+ case "info":
260
+ default:
261
+ return "info";
262
+ }
263
+ };
264
+ const persistNodeExecutionMeta = ({ context, label, output, meta, }) => {
265
+ const trimmedLabel = label.trim();
266
+ if (!trimmedLabel) {
267
+ return;
268
+ }
269
+ context.nodeMetaByLabel[trimmedLabel] = cloneNodeExecutionMeta(meta);
270
+ if (RESERVED_WORKFLOW_ROOT_LABELS.has(trimmedLabel)) {
271
+ return;
272
+ }
273
+ context.workflowContext[trimmedLabel] = attachNodeMetaToOutput(output, meta);
274
+ };
129
275
  const createStepExecutionRecord = ({ stepId, stepType, title, }) => ({
130
276
  stepId,
131
277
  stepType,
132
278
  title,
133
279
  status: "idle",
280
+ meta: null,
134
281
  attempts: 0,
135
282
  currentAttempt: null,
136
283
  maxAttempts: 1,
@@ -201,6 +348,7 @@ const updateRecordFromEvent = (records, event) => {
201
348
  stepType: event.stepType,
202
349
  title: event.title,
203
350
  status: "running",
351
+ meta: null,
204
352
  attempts: event.attempt ?? 1,
205
353
  currentAttempt: event.attempt ?? 1,
206
354
  maxAttempts: event.maxAttempts ?? 1,
@@ -223,6 +371,7 @@ const updateRecordFromEvent = (records, event) => {
223
371
  stepType: event.stepType,
224
372
  title: event.title,
225
373
  status: "retrying",
374
+ meta: current.meta,
226
375
  attempts: event.attempt,
227
376
  currentAttempt: event.attempt,
228
377
  maxAttempts: event.maxAttempts,
@@ -257,6 +406,7 @@ const updateRecordFromEvent = (records, event) => {
257
406
  stepType: event.stepType,
258
407
  title: event.title,
259
408
  status: "skipped",
409
+ meta: event.nodeMeta ?? current.meta,
260
410
  inputData: event.inputData ?? current.inputData,
261
411
  outputData: event.outputData ?? current.outputData,
262
412
  incomingEdgeId: event.incomingEdgeId ?? current.incomingEdgeId,
@@ -272,6 +422,7 @@ const updateRecordFromEvent = (records, event) => {
272
422
  stepType: event.stepType,
273
423
  title: event.title,
274
424
  status: event.status ?? (isSuccess ? "success" : "failed"),
425
+ meta: event.nodeMeta ?? current.meta,
275
426
  attempts: event.attempts ?? current.attempts,
276
427
  currentAttempt: event.currentAttempt ?? current.currentAttempt,
277
428
  maxAttempts: event.maxAttempts ?? current.maxAttempts,
@@ -353,10 +504,21 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
353
504
  const localStatuses = new Map(runtimeStepIds.map((stepId) => [stepId, "idle"]));
354
505
  const records = new Map();
355
506
  const events = [];
507
+ const initialWorkflowMeta = createInitialWorkflowMeta({
508
+ startedAt,
509
+ totalNodes: runtimeStepIds.length,
510
+ });
356
511
  const runtimeContext = {
357
512
  environment: { ...environment },
358
513
  globals: { ...globals },
359
- workflowContext: {},
514
+ workflowContext: {
515
+ workflow: buildWorkflowRoot({
516
+ scenario,
517
+ meta: initialWorkflowMeta,
518
+ }),
519
+ },
520
+ nodeMetaByLabel: {},
521
+ workflowMeta: initialWorkflowMeta,
360
522
  responseRoot: null,
361
523
  lastResponse: null,
362
524
  dbResult: null,
@@ -419,6 +581,20 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
419
581
  continue;
420
582
  }
421
583
  localStatuses.set(stepId, "skipped");
584
+ const skippedAt = new Date().toISOString();
585
+ const nodeMeta = buildNodeExecutionMeta({
586
+ status: "skipped",
587
+ attempts: 0,
588
+ startedAt: skippedAt,
589
+ endedAt: skippedAt,
590
+ durationMs: 0,
591
+ });
592
+ persistNodeExecutionMeta({
593
+ context: runtimeContext,
594
+ label: skippedNode.data.label.trim(),
595
+ output: null,
596
+ meta: nodeMeta,
597
+ });
422
598
  await emit({
423
599
  type: "step:skip",
424
600
  runId,
@@ -426,10 +602,19 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
426
602
  stepId,
427
603
  stepType: skippedNode.type,
428
604
  title: buildTitle(skippedNode),
429
- at: new Date().toISOString(),
605
+ at: skippedAt,
430
606
  reason,
607
+ nodeMeta,
431
608
  inputData: createInputSnapshot(runtimeContext),
432
609
  });
610
+ updateWorkflowMetaState({
611
+ context: runtimeContext,
612
+ records,
613
+ localStatuses,
614
+ startedAt,
615
+ currentNode: null,
616
+ lastNode: buildTitle(skippedNode),
617
+ });
433
618
  }
434
619
  };
435
620
  const markRemainingNodesSkipped = async (reason) => {
@@ -484,8 +669,9 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
484
669
  return;
485
670
  }
486
671
  const title = buildTitle(node);
672
+ const nodeLabel = node.data.label.trim();
487
673
  const executionPolicy = supportsScenarioNodeExecutionPolicy(node)
488
- ? getScenarioNodeExecutionPolicy(node)
674
+ ? withNotificationFailurePolicyOverride(node, getScenarioNodeExecutionPolicy(node))
489
675
  : getLegacyScenarioNodeExecutionPolicy();
490
676
  const executionStartedAtMs = Date.now();
491
677
  let inputData = createInputSnapshot(runtimeContext);
@@ -510,6 +696,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
510
696
  let conditionMode = null;
511
697
  let conditionDebug = null;
512
698
  let shouldMarkPartialSuccess = false;
699
+ let workflowOutput = undefined;
513
700
  const resetAttemptArtifacts = () => {
514
701
  statusCode = null;
515
702
  response = null;
@@ -532,6 +719,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
532
719
  conditionMode = null;
533
720
  conditionDebug = null;
534
721
  shouldMarkPartialSuccess = false;
722
+ workflowOutput = undefined;
535
723
  };
536
724
  const executeNodeAttempt = async (attempt, maxAttempts) => {
537
725
  throwIfStopped(signal);
@@ -555,10 +743,18 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
555
743
  inputData,
556
744
  incomingEdgeId: incomingEdgeId ?? null,
557
745
  });
746
+ updateWorkflowMetaState({
747
+ context: runtimeContext,
748
+ records,
749
+ localStatuses,
750
+ startedAt,
751
+ currentNode: title,
752
+ });
558
753
  const templateValues = buildScenarioTemplateValues({
559
754
  environment: runtimeContext.environment,
560
755
  globals: runtimeContext.globals,
561
756
  workflowContext: runtimeContext.workflowContext,
757
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
562
758
  responseRoot: runtimeContext.responseRoot,
563
759
  dbResult: runtimeContext.dbResult,
564
760
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -574,6 +770,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
574
770
  environment: runtimeContext.environment,
575
771
  globals: runtimeContext.globals,
576
772
  workflowContext: runtimeContext.workflowContext,
773
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
577
774
  responseRoot: runtimeContext.responseRoot,
578
775
  dbResult: runtimeContext.dbResult,
579
776
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -690,6 +887,48 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
690
887
  throw new ScenarioNodeFailureError(redisResult.failureMessage);
691
888
  }
692
889
  }
890
+ if (node.type === "notification") {
891
+ const resolvedMessage = renderNotificationTemplate(node.data.config.messageTemplate, templateValues).trim();
892
+ const resolvedChannelOverride = node.data.config.channelOverride?.trim()
893
+ ? renderNotificationTemplate(node.data.config.channelOverride, templateValues).trim()
894
+ : null;
895
+ const workflowRoot = isRecord(runtimeContext.workflowContext.workflow)
896
+ ? runtimeContext.workflowContext.workflow
897
+ : {};
898
+ const deliveryRequest = buildNotificationDeliveryRequest({
899
+ provider: node.data.config.provider,
900
+ scenario: {
901
+ id: scenario.id,
902
+ name: scenario.name,
903
+ ...(typeof scenario.version === "number"
904
+ ? { version: scenario.version }
905
+ : {}),
906
+ },
907
+ nodeLabel: title,
908
+ severity: normalizeNotificationSeverity(node.data.config.severity),
909
+ message: resolvedMessage || "N/A",
910
+ channelOverride: resolvedChannelOverride,
911
+ triggerLabel: "Scenario Run",
912
+ workflowMeta: runtimeContext.workflowMeta,
913
+ useRichBlocks: node.data.config.advanced?.useRichBlocks !== false,
914
+ });
915
+ const notificationResult = await runtimeAdapters.notificationExecutor({
916
+ runId,
917
+ stepId,
918
+ node,
919
+ context: createContextSnapshot(runtimeContext),
920
+ deliveryRequest,
921
+ signal,
922
+ });
923
+ outputData = notificationResult.outputData;
924
+ if (!notificationResult.delivered || notificationResult.failureMessage) {
925
+ throw new ScenarioNodeFailureError(notificationResult.failureMessage ?? "Notification delivery failed.");
926
+ }
927
+ runtimeContext.workflowContext.workflow = {
928
+ ...workflowRoot,
929
+ __meta: cloneWorkflowExecutionMeta(runtimeContext.workflowMeta),
930
+ };
931
+ }
693
932
  if (node.type === "kafka") {
694
933
  const resolvedTopic = resolveScenarioTemplateString(node.data.config.topic, templateValues);
695
934
  const rules = node.data.config.rules?.filter((rule) => rule.trim()) ?? [];
@@ -727,6 +966,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
727
966
  environment: runtimeContext.environment,
728
967
  globals: runtimeContext.globals,
729
968
  workflowContext: runtimeContext.workflowContext,
969
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
730
970
  responseRoot: runtimeContext.responseRoot,
731
971
  dbResult: runtimeContext.dbResult,
732
972
  kafkaEvent: kafkaEventValue,
@@ -753,6 +993,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
753
993
  environment: runtimeContext.environment,
754
994
  globals: runtimeContext.globals,
755
995
  workflowContext: runtimeContext.workflowContext,
996
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
756
997
  responseRoot: runtimeContext.responseRoot,
757
998
  dbResult: runtimeContext.dbResult,
758
999
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -831,13 +1072,14 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
831
1072
  }
832
1073
  }
833
1074
  throwIfStopped(signal);
834
- const nodeLabel = node.data.label.trim();
835
- const workflowOutput = node.type === "script"
836
- ? scriptHasReturnValue
837
- ? scriptReturnValue
838
- : undefined
839
- : outputData;
1075
+ workflowOutput =
1076
+ node.type === "script"
1077
+ ? scriptHasReturnValue
1078
+ ? scriptReturnValue
1079
+ : undefined
1080
+ : outputData;
840
1081
  if (nodeLabel &&
1082
+ !RESERVED_WORKFLOW_ROOT_LABELS.has(nodeLabel) &&
841
1083
  typeof workflowOutput !== "undefined" &&
842
1084
  (node.type === "script" || workflowOutput !== null)) {
843
1085
  runtimeContext.workflowContext[nodeLabel] = workflowOutput;
@@ -880,11 +1122,43 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
880
1122
  inputData,
881
1123
  incomingEdgeId: incomingEdgeId ?? null,
882
1124
  });
1125
+ updateWorkflowMetaState({
1126
+ context: runtimeContext,
1127
+ records,
1128
+ localStatuses,
1129
+ startedAt,
1130
+ currentNode: title,
1131
+ });
883
1132
  },
884
1133
  });
885
1134
  const durationMs = Math.round(Date.now() - executionStartedAtMs);
886
1135
  if (policyResult.outcome === "failed") {
887
1136
  localStatuses.set(stepId, "failed");
1137
+ const failedAt = new Date().toISOString();
1138
+ const nodeMeta = buildNodeExecutionMeta({
1139
+ status: "failed",
1140
+ attempts: policyResult.attempts,
1141
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1142
+ endedAt: failedAt,
1143
+ durationMs,
1144
+ errorMessage: policyResult.finalErrorMessage ?? "Scenario node failed",
1145
+ });
1146
+ persistNodeExecutionMeta({
1147
+ context: runtimeContext,
1148
+ label: nodeLabel,
1149
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1150
+ meta: nodeMeta,
1151
+ });
1152
+ updateWorkflowMetaState({
1153
+ context: runtimeContext,
1154
+ records,
1155
+ localStatuses,
1156
+ startedAt,
1157
+ currentNode: null,
1158
+ lastNode: title,
1159
+ failedNode: title,
1160
+ endedAt: failedAt,
1161
+ });
888
1162
  await emit({
889
1163
  type: "step:fail",
890
1164
  runId,
@@ -893,7 +1167,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
893
1167
  stepType: node.type,
894
1168
  title,
895
1169
  status: "failed",
896
- at: new Date().toISOString(),
1170
+ at: failedAt,
897
1171
  attempts: policyResult.attempts,
898
1172
  currentAttempt: policyResult.attempts,
899
1173
  maxAttempts: policyResult.maxAttempts,
@@ -903,6 +1177,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
903
1177
  failurePolicy: executionPolicy.onFailure,
904
1178
  continuedAfterFailure: false,
905
1179
  errors: policyResult.errors,
1180
+ nodeMeta,
906
1181
  errorMessage: policyResult.finalErrorMessage ?? "Scenario node failed",
907
1182
  durationMs,
908
1183
  statusCode,
@@ -931,6 +1206,33 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
931
1206
  if (policyResult.outcome === "continued") {
932
1207
  degradedStepIds.add(stepId);
933
1208
  localStatuses.set(stepId, "failed_continued");
1209
+ const continuedAt = new Date().toISOString();
1210
+ const nodeMeta = buildNodeExecutionMeta({
1211
+ status: "failed",
1212
+ attempts: policyResult.attempts,
1213
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1214
+ endedAt: continuedAt,
1215
+ durationMs,
1216
+ continueAfterFailure: true,
1217
+ errorMessage: policyResult.finalErrorMessage ??
1218
+ "Scenario node failed but execution continued.",
1219
+ });
1220
+ persistNodeExecutionMeta({
1221
+ context: runtimeContext,
1222
+ label: nodeLabel,
1223
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1224
+ meta: nodeMeta,
1225
+ });
1226
+ updateWorkflowMetaState({
1227
+ context: runtimeContext,
1228
+ records,
1229
+ localStatuses,
1230
+ startedAt,
1231
+ currentNode: null,
1232
+ lastNode: title,
1233
+ failedNode: title,
1234
+ endedAt: continuedAt,
1235
+ });
934
1236
  await emit({
935
1237
  type: "step:fail",
936
1238
  runId,
@@ -939,7 +1241,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
939
1241
  stepType: node.type,
940
1242
  title,
941
1243
  status: "failed_continued",
942
- at: new Date().toISOString(),
1244
+ at: continuedAt,
943
1245
  attempts: policyResult.attempts,
944
1246
  currentAttempt: policyResult.attempts,
945
1247
  maxAttempts: policyResult.maxAttempts,
@@ -949,6 +1251,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
949
1251
  failurePolicy: executionPolicy.onFailure,
950
1252
  continuedAfterFailure: true,
951
1253
  errors: policyResult.errors,
1254
+ nodeMeta,
952
1255
  errorMessage: policyResult.finalErrorMessage ??
953
1256
  "Scenario node failed but execution continued.",
954
1257
  durationMs,
@@ -983,6 +1286,29 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
983
1286
  degradedStepIds.delete(stepId);
984
1287
  }
985
1288
  localStatuses.set(stepId, successStatus);
1289
+ const succeededAt = new Date().toISOString();
1290
+ const nodeMeta = buildNodeExecutionMeta({
1291
+ status: "success",
1292
+ attempts: policyResult.attempts,
1293
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1294
+ endedAt: succeededAt,
1295
+ durationMs,
1296
+ });
1297
+ persistNodeExecutionMeta({
1298
+ context: runtimeContext,
1299
+ label: nodeLabel,
1300
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1301
+ meta: nodeMeta,
1302
+ });
1303
+ updateWorkflowMetaState({
1304
+ context: runtimeContext,
1305
+ records,
1306
+ localStatuses,
1307
+ startedAt,
1308
+ currentNode: null,
1309
+ lastNode: title,
1310
+ endedAt: succeededAt,
1311
+ });
986
1312
  await emit({
987
1313
  type: "step:success",
988
1314
  runId,
@@ -991,7 +1317,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
991
1317
  stepType: node.type,
992
1318
  title,
993
1319
  status: successStatus,
994
- at: new Date().toISOString(),
1320
+ at: succeededAt,
995
1321
  attempts: policyResult.attempts,
996
1322
  currentAttempt: policyResult.attempts,
997
1323
  maxAttempts: policyResult.maxAttempts,
@@ -1001,6 +1327,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1001
1327
  failurePolicy: executionPolicy.onFailure,
1002
1328
  continuedAfterFailure: false,
1003
1329
  errors: policyResult.errors,
1330
+ nodeMeta,
1004
1331
  durationMs,
1005
1332
  statusCode,
1006
1333
  response,
@@ -1070,7 +1397,30 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1070
1397
  const skipDurationMs = isRecord(outputData) && typeof outputData.actualDurationMs === "number"
1071
1398
  ? Math.max(0, Math.round(outputData.actualDurationMs))
1072
1399
  : Math.max(0, Math.round(Date.now() - executionStartedAtMs));
1400
+ const skippedAt = new Date().toISOString();
1073
1401
  localStatuses.set(stepId, "skipped");
1402
+ const nodeMeta = buildNodeExecutionMeta({
1403
+ status: "skipped",
1404
+ attempts: 0,
1405
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1406
+ endedAt: skippedAt,
1407
+ durationMs: skipDurationMs,
1408
+ });
1409
+ persistNodeExecutionMeta({
1410
+ context: runtimeContext,
1411
+ label: nodeLabel,
1412
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1413
+ meta: nodeMeta,
1414
+ });
1415
+ updateWorkflowMetaState({
1416
+ context: runtimeContext,
1417
+ records,
1418
+ localStatuses,
1419
+ startedAt,
1420
+ currentNode: null,
1421
+ lastNode: title,
1422
+ endedAt: skippedAt,
1423
+ });
1074
1424
  await emit({
1075
1425
  type: "step:skip",
1076
1426
  runId,
@@ -1078,8 +1428,9 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1078
1428
  stepId,
1079
1429
  stepType: node.type,
1080
1430
  title,
1081
- at: new Date().toISOString(),
1431
+ at: skippedAt,
1082
1432
  reason: "Execution stopped",
1433
+ nodeMeta,
1083
1434
  durationMs: skipDurationMs,
1084
1435
  inputData,
1085
1436
  outputData,
@@ -1110,29 +1461,57 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1110
1461
  }
1111
1462
  await markRemainingNodesSkipped("Node was not reached during this run.");
1112
1463
  finalStatus = degradedStepIds.size > 0 ? "completed_with_failures" : "success";
1464
+ const endedAt = new Date().toISOString();
1465
+ const workflowMeta = updateWorkflowMetaState({
1466
+ context: runtimeContext,
1467
+ records,
1468
+ localStatuses,
1469
+ startedAt,
1470
+ currentNode: null,
1471
+ endedAt,
1472
+ });
1113
1473
  await emit({
1114
1474
  type: "scenario:end",
1115
1475
  runId,
1116
1476
  scenarioId: scenario.id,
1117
1477
  status: finalStatus,
1118
- at: new Date().toISOString(),
1478
+ at: endedAt,
1119
1479
  durationMs: Date.now() - startedAtMs,
1480
+ workflowMeta,
1120
1481
  });
1121
1482
  }
1122
1483
  catch (error) {
1123
1484
  const isStopped = error instanceof ScenarioStopError || Boolean(signal?.aborted);
1124
1485
  await markRemainingNodesSkipped(isStopped ? "Skipped because execution was stopped." : "Skipped after failure.");
1125
1486
  finalStatus = isStopped ? "stopped" : "failed";
1487
+ const endedAt = new Date().toISOString();
1488
+ const workflowMeta = updateWorkflowMetaState({
1489
+ context: runtimeContext,
1490
+ records,
1491
+ localStatuses,
1492
+ startedAt,
1493
+ currentNode: null,
1494
+ endedAt,
1495
+ });
1126
1496
  await emit({
1127
1497
  type: "scenario:end",
1128
1498
  runId,
1129
1499
  scenarioId: scenario.id,
1130
1500
  status: finalStatus,
1131
- at: new Date().toISOString(),
1501
+ at: endedAt,
1132
1502
  durationMs: Date.now() - startedAtMs,
1503
+ workflowMeta,
1133
1504
  });
1134
1505
  }
1135
1506
  const endedAt = new Date().toISOString();
1507
+ const finalWorkflowMeta = updateWorkflowMetaState({
1508
+ context: runtimeContext,
1509
+ records,
1510
+ localStatuses,
1511
+ startedAt,
1512
+ currentNode: null,
1513
+ endedAt,
1514
+ });
1136
1515
  const report = {
1137
1516
  runId,
1138
1517
  scenarioId: scenario.id,
@@ -1144,6 +1523,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1144
1523
  totalSteps: runtimeStepIds.length,
1145
1524
  completedSteps: Array.from(localStatuses.values()).filter(isTerminalStatus).length,
1146
1525
  context: createContextSnapshot(runtimeContext),
1526
+ workflowMeta: cloneWorkflowExecutionMeta(finalWorkflowMeta),
1147
1527
  nodeExecutions: runtimeStepIds.map((stepId) => {
1148
1528
  const descriptor = runtimeStepDescriptors.find((entry) => entry.stepId === stepId);
1149
1529
  return (records.get(stepId) ??