callman-core 1.0.4 → 1.0.5

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.
@@ -31,6 +31,51 @@ const buildTitle = (node) => {
31
31
  };
32
32
  const isKafkaNode = (node) => node?.type === "kafka";
33
33
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
34
+ const RESERVED_WORKFLOW_ROOT_LABELS = new Set(["workflow", "__meta"]);
35
+ const cloneNodeExecutionMeta = (meta) => ({
36
+ ...meta,
37
+ ...(meta.error ? { error: { ...meta.error } } : {}),
38
+ });
39
+ const cloneWorkflowExecutionMeta = (meta) => ({
40
+ ...meta,
41
+ });
42
+ const cloneNodeMetaByLabel = (value) => Object.fromEntries(Object.entries(value).map(([label, meta]) => [label, cloneNodeExecutionMeta(meta)]));
43
+ const attachNodeMetaToOutput = (output, meta) => {
44
+ const normalizedMeta = cloneNodeExecutionMeta(meta);
45
+ if (Array.isArray(output)) {
46
+ const nextValue = [...output];
47
+ nextValue.__meta = normalizedMeta;
48
+ return nextValue;
49
+ }
50
+ if (isRecord(output)) {
51
+ return {
52
+ ...output,
53
+ __meta: normalizedMeta,
54
+ };
55
+ }
56
+ if (output === null || typeof output === "undefined") {
57
+ return {
58
+ __meta: normalizedMeta,
59
+ };
60
+ }
61
+ return output;
62
+ };
63
+ const createInitialWorkflowMeta = ({ startedAt, totalNodes, }) => ({
64
+ status: "success",
65
+ success: true,
66
+ failed: false,
67
+ totalNodes,
68
+ successfulNodes: 0,
69
+ failedNodes: 0,
70
+ skippedNodes: 0,
71
+ totalRetries: 0,
72
+ startedAt,
73
+ endedAt: startedAt,
74
+ durationMs: 0,
75
+ currentNode: null,
76
+ lastNode: null,
77
+ failedNode: null,
78
+ });
34
79
  const createInputSnapshot = (context) => ({
35
80
  env: context.environment,
36
81
  global: context.globals,
@@ -48,6 +93,8 @@ const createContextSnapshot = (context) => ({
48
93
  environment: { ...context.environment },
49
94
  globals: { ...context.globals },
50
95
  workflowContext: { ...context.workflowContext },
96
+ nodeMetaByLabel: cloneNodeMetaByLabel(context.nodeMetaByLabel),
97
+ workflowMeta: cloneWorkflowExecutionMeta(context.workflowMeta),
51
98
  responseRoot: context.responseRoot ? { ...context.responseRoot } : null,
52
99
  lastResponse: context.lastResponse,
53
100
  dbResult: context.dbResult,
@@ -126,11 +173,78 @@ const throwIfStopped = (signal) => {
126
173
  throw new ScenarioStopError();
127
174
  }
128
175
  };
176
+ const isWorkflowFailureStatus = (status) => status === "failed" || status === "failed_continued";
177
+ const isWorkflowSuccessStatus = (status) => status === "success" || status === "partial_success";
178
+ const buildNodeExecutionMeta = ({ status, attempts, startedAt, endedAt, durationMs, continueAfterFailure = false, errorMessage, }) => ({
179
+ status,
180
+ success: status === "success",
181
+ failed: status === "failed",
182
+ skipped: status === "skipped",
183
+ attempts,
184
+ startedAt,
185
+ endedAt,
186
+ durationMs,
187
+ ...(continueAfterFailure ? { continueAfterFailure: true } : {}),
188
+ ...(errorMessage
189
+ ? {
190
+ error: {
191
+ message: errorMessage,
192
+ type: "ScenarioNodeFailureError",
193
+ },
194
+ }
195
+ : {}),
196
+ });
197
+ const updateWorkflowMetaState = ({ context, records, localStatuses, startedAt, currentNode, lastNode, failedNode, endedAt = new Date().toISOString(), }) => {
198
+ const statuses = Array.from(localStatuses.values());
199
+ const successfulNodes = statuses.filter(isWorkflowSuccessStatus).length;
200
+ const failedNodes = statuses.filter(isWorkflowFailureStatus).length;
201
+ const skippedNodes = statuses.filter((status) => status === "skipped").length;
202
+ const totalRetries = Array.from(records.values()).reduce((sum, record) => sum + Math.max(0, record.retryCount), 0);
203
+ const hasPartialNodes = statuses.some((status) => status === "failed_continued" || status === "partial_success");
204
+ const nextStatus = failedNodes > 0 && !hasPartialNodes
205
+ ? "failed"
206
+ : failedNodes > 0 || hasPartialNodes
207
+ ? "partial"
208
+ : "success";
209
+ const nextMeta = {
210
+ status: nextStatus,
211
+ success: nextStatus === "success",
212
+ failed: nextStatus === "failed",
213
+ totalNodes: context.workflowMeta.totalNodes,
214
+ successfulNodes,
215
+ failedNodes,
216
+ skippedNodes,
217
+ totalRetries,
218
+ startedAt,
219
+ endedAt,
220
+ durationMs: Math.max(0, new Date(endedAt).getTime() - new Date(startedAt).getTime()),
221
+ currentNode: currentNode ?? context.workflowMeta.currentNode ?? null,
222
+ lastNode: lastNode ?? context.workflowMeta.lastNode ?? null,
223
+ failedNode: failedNode ?? context.workflowMeta.failedNode ?? null,
224
+ };
225
+ context.workflowMeta = nextMeta;
226
+ context.workflowContext.workflow = {
227
+ __meta: cloneWorkflowExecutionMeta(nextMeta),
228
+ };
229
+ return nextMeta;
230
+ };
231
+ const persistNodeExecutionMeta = ({ context, label, output, meta, }) => {
232
+ const trimmedLabel = label.trim();
233
+ if (!trimmedLabel) {
234
+ return;
235
+ }
236
+ context.nodeMetaByLabel[trimmedLabel] = cloneNodeExecutionMeta(meta);
237
+ if (RESERVED_WORKFLOW_ROOT_LABELS.has(trimmedLabel)) {
238
+ return;
239
+ }
240
+ context.workflowContext[trimmedLabel] = attachNodeMetaToOutput(output, meta);
241
+ };
129
242
  const createStepExecutionRecord = ({ stepId, stepType, title, }) => ({
130
243
  stepId,
131
244
  stepType,
132
245
  title,
133
246
  status: "idle",
247
+ meta: null,
134
248
  attempts: 0,
135
249
  currentAttempt: null,
136
250
  maxAttempts: 1,
@@ -201,6 +315,7 @@ const updateRecordFromEvent = (records, event) => {
201
315
  stepType: event.stepType,
202
316
  title: event.title,
203
317
  status: "running",
318
+ meta: null,
204
319
  attempts: event.attempt ?? 1,
205
320
  currentAttempt: event.attempt ?? 1,
206
321
  maxAttempts: event.maxAttempts ?? 1,
@@ -223,6 +338,7 @@ const updateRecordFromEvent = (records, event) => {
223
338
  stepType: event.stepType,
224
339
  title: event.title,
225
340
  status: "retrying",
341
+ meta: current.meta,
226
342
  attempts: event.attempt,
227
343
  currentAttempt: event.attempt,
228
344
  maxAttempts: event.maxAttempts,
@@ -257,6 +373,7 @@ const updateRecordFromEvent = (records, event) => {
257
373
  stepType: event.stepType,
258
374
  title: event.title,
259
375
  status: "skipped",
376
+ meta: event.nodeMeta ?? current.meta,
260
377
  inputData: event.inputData ?? current.inputData,
261
378
  outputData: event.outputData ?? current.outputData,
262
379
  incomingEdgeId: event.incomingEdgeId ?? current.incomingEdgeId,
@@ -272,6 +389,7 @@ const updateRecordFromEvent = (records, event) => {
272
389
  stepType: event.stepType,
273
390
  title: event.title,
274
391
  status: event.status ?? (isSuccess ? "success" : "failed"),
392
+ meta: event.nodeMeta ?? current.meta,
275
393
  attempts: event.attempts ?? current.attempts,
276
394
  currentAttempt: event.currentAttempt ?? current.currentAttempt,
277
395
  maxAttempts: event.maxAttempts ?? current.maxAttempts,
@@ -353,10 +471,20 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
353
471
  const localStatuses = new Map(runtimeStepIds.map((stepId) => [stepId, "idle"]));
354
472
  const records = new Map();
355
473
  const events = [];
474
+ const initialWorkflowMeta = createInitialWorkflowMeta({
475
+ startedAt,
476
+ totalNodes: runtimeStepIds.length,
477
+ });
356
478
  const runtimeContext = {
357
479
  environment: { ...environment },
358
480
  globals: { ...globals },
359
- workflowContext: {},
481
+ workflowContext: {
482
+ workflow: {
483
+ __meta: cloneWorkflowExecutionMeta(initialWorkflowMeta),
484
+ },
485
+ },
486
+ nodeMetaByLabel: {},
487
+ workflowMeta: initialWorkflowMeta,
360
488
  responseRoot: null,
361
489
  lastResponse: null,
362
490
  dbResult: null,
@@ -419,6 +547,20 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
419
547
  continue;
420
548
  }
421
549
  localStatuses.set(stepId, "skipped");
550
+ const skippedAt = new Date().toISOString();
551
+ const nodeMeta = buildNodeExecutionMeta({
552
+ status: "skipped",
553
+ attempts: 0,
554
+ startedAt: skippedAt,
555
+ endedAt: skippedAt,
556
+ durationMs: 0,
557
+ });
558
+ persistNodeExecutionMeta({
559
+ context: runtimeContext,
560
+ label: skippedNode.data.label.trim(),
561
+ output: null,
562
+ meta: nodeMeta,
563
+ });
422
564
  await emit({
423
565
  type: "step:skip",
424
566
  runId,
@@ -426,10 +568,19 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
426
568
  stepId,
427
569
  stepType: skippedNode.type,
428
570
  title: buildTitle(skippedNode),
429
- at: new Date().toISOString(),
571
+ at: skippedAt,
430
572
  reason,
573
+ nodeMeta,
431
574
  inputData: createInputSnapshot(runtimeContext),
432
575
  });
576
+ updateWorkflowMetaState({
577
+ context: runtimeContext,
578
+ records,
579
+ localStatuses,
580
+ startedAt,
581
+ currentNode: null,
582
+ lastNode: buildTitle(skippedNode),
583
+ });
433
584
  }
434
585
  };
435
586
  const markRemainingNodesSkipped = async (reason) => {
@@ -484,6 +635,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
484
635
  return;
485
636
  }
486
637
  const title = buildTitle(node);
638
+ const nodeLabel = node.data.label.trim();
487
639
  const executionPolicy = supportsScenarioNodeExecutionPolicy(node)
488
640
  ? getScenarioNodeExecutionPolicy(node)
489
641
  : getLegacyScenarioNodeExecutionPolicy();
@@ -510,6 +662,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
510
662
  let conditionMode = null;
511
663
  let conditionDebug = null;
512
664
  let shouldMarkPartialSuccess = false;
665
+ let workflowOutput = undefined;
513
666
  const resetAttemptArtifacts = () => {
514
667
  statusCode = null;
515
668
  response = null;
@@ -532,6 +685,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
532
685
  conditionMode = null;
533
686
  conditionDebug = null;
534
687
  shouldMarkPartialSuccess = false;
688
+ workflowOutput = undefined;
535
689
  };
536
690
  const executeNodeAttempt = async (attempt, maxAttempts) => {
537
691
  throwIfStopped(signal);
@@ -555,10 +709,18 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
555
709
  inputData,
556
710
  incomingEdgeId: incomingEdgeId ?? null,
557
711
  });
712
+ updateWorkflowMetaState({
713
+ context: runtimeContext,
714
+ records,
715
+ localStatuses,
716
+ startedAt,
717
+ currentNode: title,
718
+ });
558
719
  const templateValues = buildScenarioTemplateValues({
559
720
  environment: runtimeContext.environment,
560
721
  globals: runtimeContext.globals,
561
722
  workflowContext: runtimeContext.workflowContext,
723
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
562
724
  responseRoot: runtimeContext.responseRoot,
563
725
  dbResult: runtimeContext.dbResult,
564
726
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -574,6 +736,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
574
736
  environment: runtimeContext.environment,
575
737
  globals: runtimeContext.globals,
576
738
  workflowContext: runtimeContext.workflowContext,
739
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
577
740
  responseRoot: runtimeContext.responseRoot,
578
741
  dbResult: runtimeContext.dbResult,
579
742
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -727,6 +890,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
727
890
  environment: runtimeContext.environment,
728
891
  globals: runtimeContext.globals,
729
892
  workflowContext: runtimeContext.workflowContext,
893
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
730
894
  responseRoot: runtimeContext.responseRoot,
731
895
  dbResult: runtimeContext.dbResult,
732
896
  kafkaEvent: kafkaEventValue,
@@ -753,6 +917,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
753
917
  environment: runtimeContext.environment,
754
918
  globals: runtimeContext.globals,
755
919
  workflowContext: runtimeContext.workflowContext,
920
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
756
921
  responseRoot: runtimeContext.responseRoot,
757
922
  dbResult: runtimeContext.dbResult,
758
923
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -831,13 +996,14 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
831
996
  }
832
997
  }
833
998
  throwIfStopped(signal);
834
- const nodeLabel = node.data.label.trim();
835
- const workflowOutput = node.type === "script"
836
- ? scriptHasReturnValue
837
- ? scriptReturnValue
838
- : undefined
839
- : outputData;
999
+ workflowOutput =
1000
+ node.type === "script"
1001
+ ? scriptHasReturnValue
1002
+ ? scriptReturnValue
1003
+ : undefined
1004
+ : outputData;
840
1005
  if (nodeLabel &&
1006
+ !RESERVED_WORKFLOW_ROOT_LABELS.has(nodeLabel) &&
841
1007
  typeof workflowOutput !== "undefined" &&
842
1008
  (node.type === "script" || workflowOutput !== null)) {
843
1009
  runtimeContext.workflowContext[nodeLabel] = workflowOutput;
@@ -880,11 +1046,43 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
880
1046
  inputData,
881
1047
  incomingEdgeId: incomingEdgeId ?? null,
882
1048
  });
1049
+ updateWorkflowMetaState({
1050
+ context: runtimeContext,
1051
+ records,
1052
+ localStatuses,
1053
+ startedAt,
1054
+ currentNode: title,
1055
+ });
883
1056
  },
884
1057
  });
885
1058
  const durationMs = Math.round(Date.now() - executionStartedAtMs);
886
1059
  if (policyResult.outcome === "failed") {
887
1060
  localStatuses.set(stepId, "failed");
1061
+ const failedAt = new Date().toISOString();
1062
+ const nodeMeta = buildNodeExecutionMeta({
1063
+ status: "failed",
1064
+ attempts: policyResult.attempts,
1065
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1066
+ endedAt: failedAt,
1067
+ durationMs,
1068
+ errorMessage: policyResult.finalErrorMessage ?? "Scenario node failed",
1069
+ });
1070
+ persistNodeExecutionMeta({
1071
+ context: runtimeContext,
1072
+ label: nodeLabel,
1073
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1074
+ meta: nodeMeta,
1075
+ });
1076
+ updateWorkflowMetaState({
1077
+ context: runtimeContext,
1078
+ records,
1079
+ localStatuses,
1080
+ startedAt,
1081
+ currentNode: null,
1082
+ lastNode: title,
1083
+ failedNode: title,
1084
+ endedAt: failedAt,
1085
+ });
888
1086
  await emit({
889
1087
  type: "step:fail",
890
1088
  runId,
@@ -893,7 +1091,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
893
1091
  stepType: node.type,
894
1092
  title,
895
1093
  status: "failed",
896
- at: new Date().toISOString(),
1094
+ at: failedAt,
897
1095
  attempts: policyResult.attempts,
898
1096
  currentAttempt: policyResult.attempts,
899
1097
  maxAttempts: policyResult.maxAttempts,
@@ -903,6 +1101,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
903
1101
  failurePolicy: executionPolicy.onFailure,
904
1102
  continuedAfterFailure: false,
905
1103
  errors: policyResult.errors,
1104
+ nodeMeta,
906
1105
  errorMessage: policyResult.finalErrorMessage ?? "Scenario node failed",
907
1106
  durationMs,
908
1107
  statusCode,
@@ -931,6 +1130,33 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
931
1130
  if (policyResult.outcome === "continued") {
932
1131
  degradedStepIds.add(stepId);
933
1132
  localStatuses.set(stepId, "failed_continued");
1133
+ const continuedAt = new Date().toISOString();
1134
+ const nodeMeta = buildNodeExecutionMeta({
1135
+ status: "failed",
1136
+ attempts: policyResult.attempts,
1137
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1138
+ endedAt: continuedAt,
1139
+ durationMs,
1140
+ continueAfterFailure: true,
1141
+ errorMessage: policyResult.finalErrorMessage ??
1142
+ "Scenario node failed but execution continued.",
1143
+ });
1144
+ persistNodeExecutionMeta({
1145
+ context: runtimeContext,
1146
+ label: nodeLabel,
1147
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1148
+ meta: nodeMeta,
1149
+ });
1150
+ updateWorkflowMetaState({
1151
+ context: runtimeContext,
1152
+ records,
1153
+ localStatuses,
1154
+ startedAt,
1155
+ currentNode: null,
1156
+ lastNode: title,
1157
+ failedNode: title,
1158
+ endedAt: continuedAt,
1159
+ });
934
1160
  await emit({
935
1161
  type: "step:fail",
936
1162
  runId,
@@ -939,7 +1165,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
939
1165
  stepType: node.type,
940
1166
  title,
941
1167
  status: "failed_continued",
942
- at: new Date().toISOString(),
1168
+ at: continuedAt,
943
1169
  attempts: policyResult.attempts,
944
1170
  currentAttempt: policyResult.attempts,
945
1171
  maxAttempts: policyResult.maxAttempts,
@@ -949,6 +1175,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
949
1175
  failurePolicy: executionPolicy.onFailure,
950
1176
  continuedAfterFailure: true,
951
1177
  errors: policyResult.errors,
1178
+ nodeMeta,
952
1179
  errorMessage: policyResult.finalErrorMessage ??
953
1180
  "Scenario node failed but execution continued.",
954
1181
  durationMs,
@@ -983,6 +1210,29 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
983
1210
  degradedStepIds.delete(stepId);
984
1211
  }
985
1212
  localStatuses.set(stepId, successStatus);
1213
+ const succeededAt = new Date().toISOString();
1214
+ const nodeMeta = buildNodeExecutionMeta({
1215
+ status: "success",
1216
+ attempts: policyResult.attempts,
1217
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1218
+ endedAt: succeededAt,
1219
+ durationMs,
1220
+ });
1221
+ persistNodeExecutionMeta({
1222
+ context: runtimeContext,
1223
+ label: nodeLabel,
1224
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1225
+ meta: nodeMeta,
1226
+ });
1227
+ updateWorkflowMetaState({
1228
+ context: runtimeContext,
1229
+ records,
1230
+ localStatuses,
1231
+ startedAt,
1232
+ currentNode: null,
1233
+ lastNode: title,
1234
+ endedAt: succeededAt,
1235
+ });
986
1236
  await emit({
987
1237
  type: "step:success",
988
1238
  runId,
@@ -991,7 +1241,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
991
1241
  stepType: node.type,
992
1242
  title,
993
1243
  status: successStatus,
994
- at: new Date().toISOString(),
1244
+ at: succeededAt,
995
1245
  attempts: policyResult.attempts,
996
1246
  currentAttempt: policyResult.attempts,
997
1247
  maxAttempts: policyResult.maxAttempts,
@@ -1001,6 +1251,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1001
1251
  failurePolicy: executionPolicy.onFailure,
1002
1252
  continuedAfterFailure: false,
1003
1253
  errors: policyResult.errors,
1254
+ nodeMeta,
1004
1255
  durationMs,
1005
1256
  statusCode,
1006
1257
  response,
@@ -1070,7 +1321,30 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1070
1321
  const skipDurationMs = isRecord(outputData) && typeof outputData.actualDurationMs === "number"
1071
1322
  ? Math.max(0, Math.round(outputData.actualDurationMs))
1072
1323
  : Math.max(0, Math.round(Date.now() - executionStartedAtMs));
1324
+ const skippedAt = new Date().toISOString();
1073
1325
  localStatuses.set(stepId, "skipped");
1326
+ const nodeMeta = buildNodeExecutionMeta({
1327
+ status: "skipped",
1328
+ attempts: 0,
1329
+ startedAt: new Date(executionStartedAtMs).toISOString(),
1330
+ endedAt: skippedAt,
1331
+ durationMs: skipDurationMs,
1332
+ });
1333
+ persistNodeExecutionMeta({
1334
+ context: runtimeContext,
1335
+ label: nodeLabel,
1336
+ output: typeof workflowOutput !== "undefined" ? workflowOutput : outputData,
1337
+ meta: nodeMeta,
1338
+ });
1339
+ updateWorkflowMetaState({
1340
+ context: runtimeContext,
1341
+ records,
1342
+ localStatuses,
1343
+ startedAt,
1344
+ currentNode: null,
1345
+ lastNode: title,
1346
+ endedAt: skippedAt,
1347
+ });
1074
1348
  await emit({
1075
1349
  type: "step:skip",
1076
1350
  runId,
@@ -1078,8 +1352,9 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1078
1352
  stepId,
1079
1353
  stepType: node.type,
1080
1354
  title,
1081
- at: new Date().toISOString(),
1355
+ at: skippedAt,
1082
1356
  reason: "Execution stopped",
1357
+ nodeMeta,
1083
1358
  durationMs: skipDurationMs,
1084
1359
  inputData,
1085
1360
  outputData,
@@ -1110,29 +1385,57 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1110
1385
  }
1111
1386
  await markRemainingNodesSkipped("Node was not reached during this run.");
1112
1387
  finalStatus = degradedStepIds.size > 0 ? "completed_with_failures" : "success";
1388
+ const endedAt = new Date().toISOString();
1389
+ const workflowMeta = updateWorkflowMetaState({
1390
+ context: runtimeContext,
1391
+ records,
1392
+ localStatuses,
1393
+ startedAt,
1394
+ currentNode: null,
1395
+ endedAt,
1396
+ });
1113
1397
  await emit({
1114
1398
  type: "scenario:end",
1115
1399
  runId,
1116
1400
  scenarioId: scenario.id,
1117
1401
  status: finalStatus,
1118
- at: new Date().toISOString(),
1402
+ at: endedAt,
1119
1403
  durationMs: Date.now() - startedAtMs,
1404
+ workflowMeta,
1120
1405
  });
1121
1406
  }
1122
1407
  catch (error) {
1123
1408
  const isStopped = error instanceof ScenarioStopError || Boolean(signal?.aborted);
1124
1409
  await markRemainingNodesSkipped(isStopped ? "Skipped because execution was stopped." : "Skipped after failure.");
1125
1410
  finalStatus = isStopped ? "stopped" : "failed";
1411
+ const endedAt = new Date().toISOString();
1412
+ const workflowMeta = updateWorkflowMetaState({
1413
+ context: runtimeContext,
1414
+ records,
1415
+ localStatuses,
1416
+ startedAt,
1417
+ currentNode: null,
1418
+ endedAt,
1419
+ });
1126
1420
  await emit({
1127
1421
  type: "scenario:end",
1128
1422
  runId,
1129
1423
  scenarioId: scenario.id,
1130
1424
  status: finalStatus,
1131
- at: new Date().toISOString(),
1425
+ at: endedAt,
1132
1426
  durationMs: Date.now() - startedAtMs,
1427
+ workflowMeta,
1133
1428
  });
1134
1429
  }
1135
1430
  const endedAt = new Date().toISOString();
1431
+ const finalWorkflowMeta = updateWorkflowMetaState({
1432
+ context: runtimeContext,
1433
+ records,
1434
+ localStatuses,
1435
+ startedAt,
1436
+ currentNode: null,
1437
+ endedAt,
1438
+ });
1136
1439
  const report = {
1137
1440
  runId,
1138
1441
  scenarioId: scenario.id,
@@ -1144,6 +1447,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1144
1447
  totalSteps: runtimeStepIds.length,
1145
1448
  completedSteps: Array.from(localStatuses.values()).filter(isTerminalStatus).length,
1146
1449
  context: createContextSnapshot(runtimeContext),
1450
+ workflowMeta: cloneWorkflowExecutionMeta(finalWorkflowMeta),
1147
1451
  nodeExecutions: runtimeStepIds.map((stepId) => {
1148
1452
  const descriptor = runtimeStepDescriptors.find((entry) => entry.stepId === stepId);
1149
1453
  return (records.get(stepId) ??