callman-core 1.0.3 → 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.
Files changed (36) hide show
  1. package/dist/scenario-runner/condition.d.ts.map +1 -1
  2. package/dist/scenario-runner/condition.js +176 -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/executionPolicyRunner.d.ts +2 -1
  7. package/dist/scenario-runner/executionPolicyRunner.d.ts.map +1 -1
  8. package/dist/scenario-runner/executionPolicyRunner.js +2 -1
  9. package/dist/scenario-runner/executionPolicyRunner.js.map +1 -1
  10. package/dist/scenario-runner/index.d.ts +1 -0
  11. package/dist/scenario-runner/index.d.ts.map +1 -1
  12. package/dist/scenario-runner/index.js +1 -0
  13. package/dist/scenario-runner/index.js.map +1 -1
  14. package/dist/scenario-runner/nodePolicy.d.ts +2 -2
  15. package/dist/scenario-runner/nodePolicy.d.ts.map +1 -1
  16. package/dist/scenario-runner/nodePolicy.js +1 -0
  17. package/dist/scenario-runner/nodePolicy.js.map +1 -1
  18. package/dist/scenario-runner/retryExecutor.d.ts +2 -1
  19. package/dist/scenario-runner/retryExecutor.d.ts.map +1 -1
  20. package/dist/scenario-runner/retryExecutor.js +4 -1
  21. package/dist/scenario-runner/retryExecutor.js.map +1 -1
  22. package/dist/scenario-runner/runScenario.d.ts.map +1 -1
  23. package/dist/scenario-runner/runScenario.js +346 -14
  24. package/dist/scenario-runner/runScenario.js.map +1 -1
  25. package/dist/scenario-runner/templateResolver.d.ts +3 -2
  26. package/dist/scenario-runner/templateResolver.d.ts.map +1 -1
  27. package/dist/scenario-runner/templateResolver.js +19 -1
  28. package/dist/scenario-runner/templateResolver.js.map +1 -1
  29. package/dist/scenario-runner/types.d.ts +59 -3
  30. package/dist/scenario-runner/types.d.ts.map +1 -1
  31. package/dist/scenario-runner/waitNodeExecutor.d.ts +16 -0
  32. package/dist/scenario-runner/waitNodeExecutor.d.ts.map +1 -0
  33. package/dist/scenario-runner/waitNodeExecutor.js +65 -0
  34. package/dist/scenario-runner/waitNodeExecutor.js.map +1 -0
  35. package/package.json +3 -2
  36. package/tests/waitNode.test.mjs +311 -0
@@ -3,6 +3,7 @@ import { createSubScenarioStepId, buildRuntimeStepDescriptors, DEFAULT_END_LOOP_
3
3
  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
+ import { executeWaitNode, ScenarioWaitNodeCancelledError, ScenarioWaitNodeValidationError, } from "./waitNodeExecutor.js";
6
7
  class ScenarioStopError extends Error {
7
8
  constructor() {
8
9
  super("Scenario execution stopped");
@@ -30,6 +31,51 @@ const buildTitle = (node) => {
30
31
  };
31
32
  const isKafkaNode = (node) => node?.type === "kafka";
32
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
+ });
33
79
  const createInputSnapshot = (context) => ({
34
80
  env: context.environment,
35
81
  global: context.globals,
@@ -47,6 +93,8 @@ const createContextSnapshot = (context) => ({
47
93
  environment: { ...context.environment },
48
94
  globals: { ...context.globals },
49
95
  workflowContext: { ...context.workflowContext },
96
+ nodeMetaByLabel: cloneNodeMetaByLabel(context.nodeMetaByLabel),
97
+ workflowMeta: cloneWorkflowExecutionMeta(context.workflowMeta),
50
98
  responseRoot: context.responseRoot ? { ...context.responseRoot } : null,
51
99
  lastResponse: context.lastResponse,
52
100
  dbResult: context.dbResult,
@@ -125,11 +173,78 @@ const throwIfStopped = (signal) => {
125
173
  throw new ScenarioStopError();
126
174
  }
127
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
+ };
128
242
  const createStepExecutionRecord = ({ stepId, stepType, title, }) => ({
129
243
  stepId,
130
244
  stepType,
131
245
  title,
132
246
  status: "idle",
247
+ meta: null,
133
248
  attempts: 0,
134
249
  currentAttempt: null,
135
250
  maxAttempts: 1,
@@ -200,6 +315,7 @@ const updateRecordFromEvent = (records, event) => {
200
315
  stepType: event.stepType,
201
316
  title: event.title,
202
317
  status: "running",
318
+ meta: null,
203
319
  attempts: event.attempt ?? 1,
204
320
  currentAttempt: event.attempt ?? 1,
205
321
  maxAttempts: event.maxAttempts ?? 1,
@@ -222,6 +338,7 @@ const updateRecordFromEvent = (records, event) => {
222
338
  stepType: event.stepType,
223
339
  title: event.title,
224
340
  status: "retrying",
341
+ meta: current.meta,
225
342
  attempts: event.attempt,
226
343
  currentAttempt: event.attempt,
227
344
  maxAttempts: event.maxAttempts,
@@ -256,8 +373,11 @@ const updateRecordFromEvent = (records, event) => {
256
373
  stepType: event.stepType,
257
374
  title: event.title,
258
375
  status: "skipped",
376
+ meta: event.nodeMeta ?? current.meta,
259
377
  inputData: event.inputData ?? current.inputData,
378
+ outputData: event.outputData ?? current.outputData,
260
379
  incomingEdgeId: event.incomingEdgeId ?? current.incomingEdgeId,
380
+ durationMs: event.durationMs ?? current.durationMs,
261
381
  startedAt: current.startedAt ?? event.at,
262
382
  completedAt: event.at,
263
383
  });
@@ -269,6 +389,7 @@ const updateRecordFromEvent = (records, event) => {
269
389
  stepType: event.stepType,
270
390
  title: event.title,
271
391
  status: event.status ?? (isSuccess ? "success" : "failed"),
392
+ meta: event.nodeMeta ?? current.meta,
272
393
  attempts: event.attempts ?? current.attempts,
273
394
  currentAttempt: event.currentAttempt ?? current.currentAttempt,
274
395
  maxAttempts: event.maxAttempts ?? current.maxAttempts,
@@ -350,10 +471,20 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
350
471
  const localStatuses = new Map(runtimeStepIds.map((stepId) => [stepId, "idle"]));
351
472
  const records = new Map();
352
473
  const events = [];
474
+ const initialWorkflowMeta = createInitialWorkflowMeta({
475
+ startedAt,
476
+ totalNodes: runtimeStepIds.length,
477
+ });
353
478
  const runtimeContext = {
354
479
  environment: { ...environment },
355
480
  globals: { ...globals },
356
- workflowContext: {},
481
+ workflowContext: {
482
+ workflow: {
483
+ __meta: cloneWorkflowExecutionMeta(initialWorkflowMeta),
484
+ },
485
+ },
486
+ nodeMetaByLabel: {},
487
+ workflowMeta: initialWorkflowMeta,
357
488
  responseRoot: null,
358
489
  lastResponse: null,
359
490
  dbResult: null,
@@ -416,6 +547,20 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
416
547
  continue;
417
548
  }
418
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
+ });
419
564
  await emit({
420
565
  type: "step:skip",
421
566
  runId,
@@ -423,10 +568,19 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
423
568
  stepId,
424
569
  stepType: skippedNode.type,
425
570
  title: buildTitle(skippedNode),
426
- at: new Date().toISOString(),
571
+ at: skippedAt,
427
572
  reason,
573
+ nodeMeta,
428
574
  inputData: createInputSnapshot(runtimeContext),
429
575
  });
576
+ updateWorkflowMetaState({
577
+ context: runtimeContext,
578
+ records,
579
+ localStatuses,
580
+ startedAt,
581
+ currentNode: null,
582
+ lastNode: buildTitle(skippedNode),
583
+ });
430
584
  }
431
585
  };
432
586
  const markRemainingNodesSkipped = async (reason) => {
@@ -481,6 +635,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
481
635
  return;
482
636
  }
483
637
  const title = buildTitle(node);
638
+ const nodeLabel = node.data.label.trim();
484
639
  const executionPolicy = supportsScenarioNodeExecutionPolicy(node)
485
640
  ? getScenarioNodeExecutionPolicy(node)
486
641
  : getLegacyScenarioNodeExecutionPolicy();
@@ -507,6 +662,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
507
662
  let conditionMode = null;
508
663
  let conditionDebug = null;
509
664
  let shouldMarkPartialSuccess = false;
665
+ let workflowOutput = undefined;
510
666
  const resetAttemptArtifacts = () => {
511
667
  statusCode = null;
512
668
  response = null;
@@ -529,6 +685,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
529
685
  conditionMode = null;
530
686
  conditionDebug = null;
531
687
  shouldMarkPartialSuccess = false;
688
+ workflowOutput = undefined;
532
689
  };
533
690
  const executeNodeAttempt = async (attempt, maxAttempts) => {
534
691
  throwIfStopped(signal);
@@ -552,10 +709,18 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
552
709
  inputData,
553
710
  incomingEdgeId: incomingEdgeId ?? null,
554
711
  });
712
+ updateWorkflowMetaState({
713
+ context: runtimeContext,
714
+ records,
715
+ localStatuses,
716
+ startedAt,
717
+ currentNode: title,
718
+ });
555
719
  const templateValues = buildScenarioTemplateValues({
556
720
  environment: runtimeContext.environment,
557
721
  globals: runtimeContext.globals,
558
722
  workflowContext: runtimeContext.workflowContext,
723
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
559
724
  responseRoot: runtimeContext.responseRoot,
560
725
  dbResult: runtimeContext.dbResult,
561
726
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -571,6 +736,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
571
736
  environment: runtimeContext.environment,
572
737
  globals: runtimeContext.globals,
573
738
  workflowContext: runtimeContext.workflowContext,
739
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
574
740
  responseRoot: runtimeContext.responseRoot,
575
741
  dbResult: runtimeContext.dbResult,
576
742
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -630,6 +796,25 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
630
796
  throw new ScenarioNodeFailureError(dbResult.failureMessage);
631
797
  }
632
798
  }
799
+ if (node.type === "wait") {
800
+ try {
801
+ outputData = await executeWaitNode({
802
+ durationMs: node.data.config.durationMs,
803
+ signal,
804
+ });
805
+ }
806
+ catch (error) {
807
+ if (error instanceof ScenarioWaitNodeCancelledError) {
808
+ outputData = error.summary;
809
+ throw new ScenarioStopError();
810
+ }
811
+ if (error instanceof ScenarioWaitNodeValidationError) {
812
+ outputData = error.summary;
813
+ throw new ScenarioNodeFailureError(error.message);
814
+ }
815
+ throw error;
816
+ }
817
+ }
633
818
  if (node.type === "script") {
634
819
  const scriptResult = await runtimeAdapters.scriptExecutor({
635
820
  runId,
@@ -705,6 +890,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
705
890
  environment: runtimeContext.environment,
706
891
  globals: runtimeContext.globals,
707
892
  workflowContext: runtimeContext.workflowContext,
893
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
708
894
  responseRoot: runtimeContext.responseRoot,
709
895
  dbResult: runtimeContext.dbResult,
710
896
  kafkaEvent: kafkaEventValue,
@@ -731,6 +917,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
731
917
  environment: runtimeContext.environment,
732
918
  globals: runtimeContext.globals,
733
919
  workflowContext: runtimeContext.workflowContext,
920
+ nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
734
921
  responseRoot: runtimeContext.responseRoot,
735
922
  dbResult: runtimeContext.dbResult,
736
923
  kafkaEvent: runtimeContext.kafkaEvent,
@@ -809,13 +996,14 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
809
996
  }
810
997
  }
811
998
  throwIfStopped(signal);
812
- const nodeLabel = node.data.label.trim();
813
- const workflowOutput = node.type === "script"
814
- ? scriptHasReturnValue
815
- ? scriptReturnValue
816
- : undefined
817
- : outputData;
999
+ workflowOutput =
1000
+ node.type === "script"
1001
+ ? scriptHasReturnValue
1002
+ ? scriptReturnValue
1003
+ : undefined
1004
+ : outputData;
818
1005
  if (nodeLabel &&
1006
+ !RESERVED_WORKFLOW_ROOT_LABELS.has(nodeLabel) &&
819
1007
  typeof workflowOutput !== "undefined" &&
820
1008
  (node.type === "script" || workflowOutput !== null)) {
821
1009
  runtimeContext.workflowContext[nodeLabel] = workflowOutput;
@@ -837,6 +1025,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
837
1025
  policy: executionPolicy,
838
1026
  execute: executeNodeAttempt,
839
1027
  wait: waitForDuration,
1028
+ shouldRethrow: (error) => error instanceof ScenarioStopError,
840
1029
  onRetry: async (event) => {
841
1030
  localStatuses.set(stepId, "retrying");
842
1031
  await emit({
@@ -857,11 +1046,43 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
857
1046
  inputData,
858
1047
  incomingEdgeId: incomingEdgeId ?? null,
859
1048
  });
1049
+ updateWorkflowMetaState({
1050
+ context: runtimeContext,
1051
+ records,
1052
+ localStatuses,
1053
+ startedAt,
1054
+ currentNode: title,
1055
+ });
860
1056
  },
861
1057
  });
862
1058
  const durationMs = Math.round(Date.now() - executionStartedAtMs);
863
1059
  if (policyResult.outcome === "failed") {
864
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
+ });
865
1086
  await emit({
866
1087
  type: "step:fail",
867
1088
  runId,
@@ -870,7 +1091,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
870
1091
  stepType: node.type,
871
1092
  title,
872
1093
  status: "failed",
873
- at: new Date().toISOString(),
1094
+ at: failedAt,
874
1095
  attempts: policyResult.attempts,
875
1096
  currentAttempt: policyResult.attempts,
876
1097
  maxAttempts: policyResult.maxAttempts,
@@ -880,6 +1101,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
880
1101
  failurePolicy: executionPolicy.onFailure,
881
1102
  continuedAfterFailure: false,
882
1103
  errors: policyResult.errors,
1104
+ nodeMeta,
883
1105
  errorMessage: policyResult.finalErrorMessage ?? "Scenario node failed",
884
1106
  durationMs,
885
1107
  statusCode,
@@ -908,6 +1130,33 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
908
1130
  if (policyResult.outcome === "continued") {
909
1131
  degradedStepIds.add(stepId);
910
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
+ });
911
1160
  await emit({
912
1161
  type: "step:fail",
913
1162
  runId,
@@ -916,7 +1165,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
916
1165
  stepType: node.type,
917
1166
  title,
918
1167
  status: "failed_continued",
919
- at: new Date().toISOString(),
1168
+ at: continuedAt,
920
1169
  attempts: policyResult.attempts,
921
1170
  currentAttempt: policyResult.attempts,
922
1171
  maxAttempts: policyResult.maxAttempts,
@@ -926,6 +1175,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
926
1175
  failurePolicy: executionPolicy.onFailure,
927
1176
  continuedAfterFailure: true,
928
1177
  errors: policyResult.errors,
1178
+ nodeMeta,
929
1179
  errorMessage: policyResult.finalErrorMessage ??
930
1180
  "Scenario node failed but execution continued.",
931
1181
  durationMs,
@@ -960,6 +1210,29 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
960
1210
  degradedStepIds.delete(stepId);
961
1211
  }
962
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
+ });
963
1236
  await emit({
964
1237
  type: "step:success",
965
1238
  runId,
@@ -968,7 +1241,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
968
1241
  stepType: node.type,
969
1242
  title,
970
1243
  status: successStatus,
971
- at: new Date().toISOString(),
1244
+ at: succeededAt,
972
1245
  attempts: policyResult.attempts,
973
1246
  currentAttempt: policyResult.attempts,
974
1247
  maxAttempts: policyResult.maxAttempts,
@@ -978,6 +1251,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
978
1251
  failurePolicy: executionPolicy.onFailure,
979
1252
  continuedAfterFailure: false,
980
1253
  errors: policyResult.errors,
1254
+ nodeMeta,
981
1255
  durationMs,
982
1256
  statusCode,
983
1257
  response,
@@ -1044,7 +1318,33 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1044
1318
  if (error instanceof ScenarioStopError) {
1045
1319
  const stepState = localStatuses.get(stepId);
1046
1320
  if (stepState === "running" || stepState === "retrying") {
1321
+ const skipDurationMs = isRecord(outputData) && typeof outputData.actualDurationMs === "number"
1322
+ ? Math.max(0, Math.round(outputData.actualDurationMs))
1323
+ : Math.max(0, Math.round(Date.now() - executionStartedAtMs));
1324
+ const skippedAt = new Date().toISOString();
1047
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
+ });
1048
1348
  await emit({
1049
1349
  type: "step:skip",
1050
1350
  runId,
@@ -1052,9 +1352,12 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1052
1352
  stepId,
1053
1353
  stepType: node.type,
1054
1354
  title,
1055
- at: new Date().toISOString(),
1355
+ at: skippedAt,
1056
1356
  reason: "Execution stopped",
1357
+ nodeMeta,
1358
+ durationMs: skipDurationMs,
1057
1359
  inputData,
1360
+ outputData,
1058
1361
  incomingEdgeId: incomingEdgeId ?? null,
1059
1362
  });
1060
1363
  }
@@ -1082,29 +1385,57 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1082
1385
  }
1083
1386
  await markRemainingNodesSkipped("Node was not reached during this run.");
1084
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
+ });
1085
1397
  await emit({
1086
1398
  type: "scenario:end",
1087
1399
  runId,
1088
1400
  scenarioId: scenario.id,
1089
1401
  status: finalStatus,
1090
- at: new Date().toISOString(),
1402
+ at: endedAt,
1091
1403
  durationMs: Date.now() - startedAtMs,
1404
+ workflowMeta,
1092
1405
  });
1093
1406
  }
1094
1407
  catch (error) {
1095
1408
  const isStopped = error instanceof ScenarioStopError || Boolean(signal?.aborted);
1096
1409
  await markRemainingNodesSkipped(isStopped ? "Skipped because execution was stopped." : "Skipped after failure.");
1097
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
+ });
1098
1420
  await emit({
1099
1421
  type: "scenario:end",
1100
1422
  runId,
1101
1423
  scenarioId: scenario.id,
1102
1424
  status: finalStatus,
1103
- at: new Date().toISOString(),
1425
+ at: endedAt,
1104
1426
  durationMs: Date.now() - startedAtMs,
1427
+ workflowMeta,
1105
1428
  });
1106
1429
  }
1107
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
+ });
1108
1439
  const report = {
1109
1440
  runId,
1110
1441
  scenarioId: scenario.id,
@@ -1116,6 +1447,7 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
1116
1447
  totalSteps: runtimeStepIds.length,
1117
1448
  completedSteps: Array.from(localStatuses.values()).filter(isTerminalStatus).length,
1118
1449
  context: createContextSnapshot(runtimeContext),
1450
+ workflowMeta: cloneWorkflowExecutionMeta(finalWorkflowMeta),
1119
1451
  nodeExecutions: runtimeStepIds.map((stepId) => {
1120
1452
  const descriptor = runtimeStepDescriptors.find((entry) => entry.stepId === stepId);
1121
1453
  return (records.get(stepId) ??