@auto-engineer/pipeline 1.68.0 → 1.70.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.
Files changed (71) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +6 -6
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +63 -0
  5. package/dist/src/engine/workflow-processor.d.ts +3 -0
  6. package/dist/src/engine/workflow-processor.d.ts.map +1 -1
  7. package/dist/src/engine/workflow-processor.js +50 -7
  8. package/dist/src/engine/workflow-processor.js.map +1 -1
  9. package/dist/src/index.d.ts +0 -2
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +0 -2
  12. package/dist/src/index.js.map +1 -1
  13. package/dist/src/server/phased-bridge.d.ts +13 -0
  14. package/dist/src/server/phased-bridge.d.ts.map +1 -0
  15. package/dist/src/server/phased-bridge.js +103 -0
  16. package/dist/src/server/phased-bridge.js.map +1 -0
  17. package/dist/src/server/pipeline-server.d.ts +2 -2
  18. package/dist/src/server/pipeline-server.d.ts.map +1 -1
  19. package/dist/src/server/pipeline-server.js +19 -39
  20. package/dist/src/server/pipeline-server.js.map +1 -1
  21. package/dist/src/server/v2-runtime-bridge.d.ts +21 -0
  22. package/dist/src/server/v2-runtime-bridge.d.ts.map +1 -0
  23. package/dist/src/server/v2-runtime-bridge.js +184 -0
  24. package/dist/src/server/v2-runtime-bridge.js.map +1 -0
  25. package/dist/src/store/pipeline-event-store.d.ts.map +1 -1
  26. package/dist/src/store/pipeline-event-store.js +0 -30
  27. package/dist/src/store/pipeline-event-store.js.map +1 -1
  28. package/dist/src/store/pipeline-read-model.d.ts +0 -15
  29. package/dist/src/store/pipeline-read-model.d.ts.map +1 -1
  30. package/dist/src/store/pipeline-read-model.js +0 -49
  31. package/dist/src/store/pipeline-read-model.js.map +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/ketchup-plan.md +10 -12
  34. package/package.json +3 -3
  35. package/src/engine/workflow-processor.specs.ts +101 -0
  36. package/src/engine/workflow-processor.ts +54 -8
  37. package/src/index.ts +0 -2
  38. package/src/server/phased-bridge.specs.ts +272 -0
  39. package/src/server/phased-bridge.ts +130 -0
  40. package/src/server/pipeline-server.specs.ts +94 -0
  41. package/src/server/pipeline-server.ts +21 -42
  42. package/src/server/v2-runtime-bridge.specs.ts +347 -0
  43. package/src/server/v2-runtime-bridge.ts +255 -0
  44. package/src/store/pipeline-event-store.specs.ts +0 -137
  45. package/src/store/pipeline-event-store.ts +0 -35
  46. package/src/store/pipeline-read-model.specs.ts +0 -567
  47. package/src/store/pipeline-read-model.ts +0 -71
  48. package/dist/src/projections/phased-execution-projection.d.ts +0 -77
  49. package/dist/src/projections/phased-execution-projection.d.ts.map +0 -1
  50. package/dist/src/projections/phased-execution-projection.js +0 -54
  51. package/dist/src/projections/phased-execution-projection.js.map +0 -1
  52. package/dist/src/projections/settled-instance-projection.d.ts +0 -67
  53. package/dist/src/projections/settled-instance-projection.d.ts.map +0 -1
  54. package/dist/src/projections/settled-instance-projection.js +0 -66
  55. package/dist/src/projections/settled-instance-projection.js.map +0 -1
  56. package/dist/src/runtime/phased-executor.d.ts +0 -34
  57. package/dist/src/runtime/phased-executor.d.ts.map +0 -1
  58. package/dist/src/runtime/phased-executor.js +0 -172
  59. package/dist/src/runtime/phased-executor.js.map +0 -1
  60. package/dist/src/runtime/settled-tracker.d.ts +0 -44
  61. package/dist/src/runtime/settled-tracker.d.ts.map +0 -1
  62. package/dist/src/runtime/settled-tracker.js +0 -170
  63. package/dist/src/runtime/settled-tracker.js.map +0 -1
  64. package/src/projections/phased-execution-projection.specs.ts +0 -202
  65. package/src/projections/phased-execution-projection.ts +0 -146
  66. package/src/projections/settled-instance-projection.specs.ts +0 -296
  67. package/src/projections/settled-instance-projection.ts +0 -160
  68. package/src/runtime/phased-executor.specs.ts +0 -680
  69. package/src/runtime/phased-executor.ts +0 -230
  70. package/src/runtime/settled-tracker.specs.ts +0 -1044
  71. package/src/runtime/settled-tracker.ts +0 -235
@@ -465,6 +465,44 @@ describe('PipelineServer', () => {
465
465
  await server.stop();
466
466
  });
467
467
 
468
+ it('should show settled node success after command dispatched via /command completes', async () => {
469
+ const handler = {
470
+ name: 'CheckTests',
471
+ events: ['TestsPassed'],
472
+ handle: async () => ({ type: 'TestsPassed', data: {} }),
473
+ };
474
+ const pipeline = define('test')
475
+ .on('Start')
476
+ .emit('CheckTests', {})
477
+ .settled(['CheckTests'])
478
+ .dispatch({ dispatches: [] }, () => {})
479
+ .build();
480
+ const server = new PipelineServer({ port: 0 });
481
+ server.registerCommandHandlers([handler]);
482
+ server.registerPipeline(pipeline);
483
+ await server.start();
484
+
485
+ await fetch(`http://localhost:${server.port}/command`, {
486
+ method: 'POST',
487
+ headers: { 'Content-Type': 'application/json' },
488
+ body: JSON.stringify({ type: 'CheckTests', data: {} }),
489
+ });
490
+
491
+ await new Promise((r) => setTimeout(r, 100));
492
+
493
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
494
+ const settledNode = data.nodes.find((n) => n.id === 'settled:CheckTests');
495
+ expect(settledNode).toEqual({
496
+ id: 'settled:CheckTests',
497
+ type: 'settled',
498
+ label: 'Settled',
499
+ status: 'success',
500
+ pendingCount: 0,
501
+ endedCount: 1,
502
+ });
503
+ await server.stop();
504
+ });
505
+
468
506
  it('should show running status for command being executed', async () => {
469
507
  let resolveHandler: () => void = () => {};
470
508
  const handlerPromise = new Promise<void>((resolve) => {
@@ -2400,4 +2438,60 @@ describe('PipelineServer', () => {
2400
2438
  await server.stop();
2401
2439
  });
2402
2440
  });
2441
+
2442
+ describe('emit-before-broadcast ordering', () => {
2443
+ it('should write to event store before broadcasting PipelineRunStarted via SSE', async () => {
2444
+ const server = new PipelineServer({ port: 0 });
2445
+
2446
+ const serverAny = server as unknown as {
2447
+ eventStoreContext: {
2448
+ eventStore: {
2449
+ appendToStream: (
2450
+ streamName: string,
2451
+ events: Array<{ type: string; data: unknown }>,
2452
+ options?: unknown,
2453
+ ) => Promise<unknown>;
2454
+ };
2455
+ };
2456
+ sseManager: {
2457
+ broadcast: (event: unknown) => void;
2458
+ };
2459
+ };
2460
+
2461
+ await server.start();
2462
+
2463
+ const callOrder: string[] = [];
2464
+
2465
+ const originalAppend = serverAny.eventStoreContext.eventStore.appendToStream.bind(
2466
+ serverAny.eventStoreContext.eventStore,
2467
+ );
2468
+ serverAny.eventStoreContext.eventStore.appendToStream = async (streamName, events, options) => {
2469
+ const result = await originalAppend(streamName, events, options);
2470
+ if (events.some((e) => e.type === 'PipelineRunStarted')) {
2471
+ callOrder.push('emit');
2472
+ }
2473
+ return result;
2474
+ };
2475
+
2476
+ const originalBroadcast = serverAny.sseManager.broadcast.bind(serverAny.sseManager);
2477
+ serverAny.sseManager.broadcast = (event: { type?: string }) => {
2478
+ if (event.type === 'PipelineRunStarted') {
2479
+ callOrder.push('broadcast');
2480
+ }
2481
+ originalBroadcast(event);
2482
+ };
2483
+
2484
+ await fetch(`http://localhost:${server.port}/command`, {
2485
+ method: 'POST',
2486
+ headers: { 'Content-Type': 'application/json' },
2487
+ body: JSON.stringify({ type: 'RestartPipeline', data: {} }),
2488
+ });
2489
+
2490
+ await new Promise((r) => setTimeout(r, 50));
2491
+
2492
+ expect(callOrder).toEqual(['emit', 'broadcast']);
2493
+
2494
+ await server.stop();
2495
+ });
2496
+ });
2403
2497
  });
@@ -18,11 +18,11 @@ import type { FilterOptions, GraphIR, GraphNode, NodeStatus, NodeType } from '..
18
18
  import type { PipelineContext } from '../runtime/context';
19
19
  import type { EventDefinition } from '../runtime/event-command-map';
20
20
  import { EventCommandMapper } from '../runtime/event-command-map';
21
- import { PhasedExecutor } from '../runtime/phased-executor';
22
21
  import { PipelineRuntime } from '../runtime/pipeline-runtime';
23
- import { SettledTracker } from '../runtime/settled-tracker';
24
22
  import { createPipelineEventStore, type PipelineEventStoreContext } from '../store/pipeline-event-store';
23
+ import { createPhasedBridge } from './phased-bridge';
25
24
  import { SSEManager } from './sse-manager';
25
+ import { createV2RuntimeBridge } from './v2-runtime-bridge';
26
26
 
27
27
  export type { EventDefinition };
28
28
 
@@ -54,9 +54,9 @@ export class PipelineServer {
54
54
  private readonly runtimes: Map<string, PipelineRuntime> = new Map();
55
55
  private actualPort: number;
56
56
  private readonly requestedPort: number;
57
- private readonly settledTracker: SettledTracker;
57
+ private readonly settledBridge: ReturnType<typeof createV2RuntimeBridge>;
58
58
  private readonly eventCommandMapper: EventCommandMapper;
59
- private readonly phasedExecutor: PhasedExecutor;
59
+ private readonly phasedBridge: ReturnType<typeof createPhasedBridge>;
60
60
  private readonly sseManager: SSEManager;
61
61
  private readonly eventStoreContext: PipelineEventStoreContext;
62
62
  private readonly itemKeyExtractors = new Map<string, (data: unknown) => string | undefined>();
@@ -76,35 +76,18 @@ export class PipelineServer {
76
76
  this.messageBus = createMessageBus();
77
77
  this.eventStoreContext = createPipelineEventStore();
78
78
  this.eventCommandMapper = new EventCommandMapper([]);
79
- this.settledTracker = new SettledTracker({
80
- readModel: this.eventStoreContext.readModel,
81
- /* v8 ignore next 3 - integration callback tested via settled-tracker.specs.ts */
79
+ this.settledBridge = createV2RuntimeBridge({
82
80
  onDispatch: (commandType, data, correlationId) => {
83
81
  void this.dispatchFromSettled(commandType, data, correlationId);
84
82
  },
85
- /* v8 ignore next 4 - integration callback tested via settled-tracker.specs.ts */
86
- onEventEmit: async (event) => {
87
- const correlationId = event.data.correlationId;
88
- await this.eventStoreContext.eventStore.appendToStream(`pipeline-${correlationId}`, [event]);
89
- },
90
83
  });
91
- this.phasedExecutor = new PhasedExecutor({
92
- readModel: this.eventStoreContext.readModel,
93
- /* v8 ignore next 3 - integration callback tested via phased-executor.specs.ts */
84
+ this.phasedBridge = createPhasedBridge({
94
85
  onDispatch: (commandType, data, correlationId) => {
95
86
  void this.dispatchFromSettled(commandType, data, correlationId);
96
87
  },
97
- /* v8 ignore next 3 - integration callback tested via phased-executor.specs.ts */
98
- onComplete: (event, correlationId) => {
88
+ onPhasedComplete: (event, correlationId) => {
99
89
  void this.handlePhasedComplete(event, correlationId);
100
90
  },
101
- /* v8 ignore next 5 - integration callback tested via phased-executor.specs.ts */
102
- onEventEmit: async (event) => {
103
- const data = event.data as Record<string, unknown>;
104
- const correlationId =
105
- (data.correlationId as string) ?? (data.executionId as string)?.split('-')[1] ?? 'default';
106
- await this.eventStoreContext.eventStore.appendToStream(`pipeline-${correlationId}`, [event]);
107
- },
108
91
  });
109
92
  this.sseManager = new SSEManager();
110
93
  }
@@ -143,12 +126,9 @@ export class PipelineServer {
143
126
 
144
127
  for (const handler of pipeline.descriptor.handlers) {
145
128
  if (handler.type === 'settled') {
146
- this.settledTracker.registerHandler({
147
- commandTypes: handler.commandTypes,
148
- handler: handler.handler,
149
- });
129
+ this.settledBridge.registerSettled(handler);
150
130
  } else if (handler.type === 'foreach-phased') {
151
- this.phasedExecutor.registerHandler(handler);
131
+ this.phasedBridge.registerPhased(handler);
152
132
  }
153
133
  }
154
134
  }
@@ -521,13 +501,13 @@ export class PipelineServer {
521
501
  };
522
502
  }
523
503
 
524
- private async addStatusToSettledNode(node: GraphNode, correlationId?: string): Promise<GraphNode> {
504
+ private addStatusToSettledNode(node: GraphNode, correlationId?: string): GraphNode {
525
505
  if (correlationId === undefined) {
526
506
  return { ...node, status: 'idle' as NodeStatus, pendingCount: 0, endedCount: 0 };
527
507
  }
528
508
  const commandTypes = node.id.replace(/^settled:/, '');
529
509
  const templateId = `template-${commandTypes}`;
530
- const stats = await this.eventStoreContext.readModel.computeSettledStats(correlationId, templateId);
510
+ const stats = this.settledBridge.getSettledStats(correlationId, templateId);
531
511
  return {
532
512
  ...node,
533
513
  status: stats.status,
@@ -663,13 +643,13 @@ export class PipelineServer {
663
643
  }
664
644
 
665
645
  private async broadcastPipelineRunStarted(correlationId: string, triggerCommand: string): Promise<void> {
646
+ await this.emitPipelineRunStarted(correlationId, triggerCommand);
666
647
  const event: Event & { correlationId: string } = {
667
648
  type: 'PipelineRunStarted',
668
649
  data: { correlationId, triggerCommand },
669
650
  correlationId,
670
651
  };
671
652
  this.sseManager.broadcast(event);
672
- await this.emitPipelineRunStarted(correlationId, triggerCommand);
673
653
  }
674
654
 
675
655
  private extractItemKey(commandType: string, data: unknown, requestId: string): string {
@@ -997,7 +977,7 @@ export class PipelineServer {
997
977
  await this.getOrCreateItemStatus(this.currentSessionId, command.type, itemKey, command.requestId);
998
978
 
999
979
  await this.updateNodeStatus(this.currentSessionId, command.type, 'running');
1000
- await this.settledTracker.onCommandStarted(command);
980
+ this.settledBridge.onCommandStarted(command, this.currentSessionId);
1001
981
 
1002
982
  const ctx = this.createContext(command.correlationId);
1003
983
  let events: Event[];
@@ -1043,10 +1023,11 @@ export class PipelineServer {
1043
1023
 
1044
1024
  const sourceCommand = this.eventCommandMapper.getSourceCommand(eventWithIds.type);
1045
1025
  if (sourceCommand !== undefined) {
1046
- await this.settledTracker.onEventReceived(eventWithIds, sourceCommand);
1026
+ const result = eventWithIds.type.includes('Failed') ? 'failure' : 'success';
1027
+ this.settledBridge.onEventReceived(eventWithIds, sourceCommand, result, this.currentSessionId);
1047
1028
  }
1048
1029
 
1049
- await this.routeEventToPhasedExecutor(eventWithIds);
1030
+ this.routeEventToPhasedExecutor(eventWithIds);
1050
1031
  }
1051
1032
 
1052
1033
  await Promise.all(eventsWithIds.map((e) => this.routeEventToPipelines(e)));
@@ -1061,7 +1042,7 @@ export class PipelineServer {
1061
1042
  return 'success';
1062
1043
  }
1063
1044
 
1064
- /* v8 ignore next 11 - integration path tested via settled-tracker.specs.ts */
1045
+ /* v8 ignore next 11 - integration callback tested via v2-runtime-bridge.specs.ts */
1065
1046
  private async dispatchFromSettled(commandType: string, data: unknown, correlationId: string): Promise<void> {
1066
1047
  const requestId = `req-${nanoid()}`;
1067
1048
  const command: Command & { correlationId: string; requestId: string } = {
@@ -1074,7 +1055,7 @@ export class PipelineServer {
1074
1055
  await this.processCommand(command);
1075
1056
  }
1076
1057
 
1077
- /* v8 ignore next 10 - integration path tested via phased-executor.specs.ts */
1058
+ /* v8 ignore next 10 - integration callback tested via phased-bridge.specs.ts */
1078
1059
  private async handlePhasedComplete(event: Event, correlationId: string): Promise<void> {
1079
1060
  const requestId = `req-${nanoid()}`;
1080
1061
  const eventWithIds: EventWithCorrelation = {
@@ -1118,22 +1099,20 @@ export class PipelineServer {
1118
1099
  await this.emitCommandDispatched(effectiveCorrelationId, requestId, type, data as Record<string, unknown>);
1119
1100
  void this.processCommand(command);
1120
1101
  },
1121
- /* v8 ignore next 3 - integration path tested via pipeline-runtime.specs.ts */
1122
1102
  startPhased: async (handler, event) => {
1123
- await this.phasedExecutor.startPhased(handler, event, correlationId);
1103
+ this.phasedBridge.startPhased(handler, event, correlationId);
1124
1104
  },
1125
1105
  eventStore: this.eventStoreContext.eventStore,
1126
1106
  messageBus: this.messageBus,
1127
1107
  };
1128
1108
  }
1129
1109
 
1130
- /* v8 ignore next 10 - integration path tested via phased-executor.specs.ts */
1131
- private async routeEventToPhasedExecutor(event: EventWithCorrelation): Promise<void> {
1110
+ private routeEventToPhasedExecutor(event: EventWithCorrelation): void {
1132
1111
  for (const pipeline of this.pipelines.values()) {
1133
1112
  for (const handler of pipeline.descriptor.handlers) {
1134
1113
  if (handler.type === 'foreach-phased') {
1135
1114
  const itemKey = handler.completion.itemKey(event);
1136
- await this.phasedExecutor.onEventReceived(event, itemKey);
1115
+ this.phasedBridge.onPhasedItemEvent(event, itemKey);
1137
1116
  }
1138
1117
  }
1139
1118
  }
@@ -0,0 +1,347 @@
1
+ import type { Command, Event } from '@auto-engineer/message-bus';
2
+ import type { SettledHandlerDescriptor } from '../core/descriptors.js';
3
+ import { createV2RuntimeBridge } from './v2-runtime-bridge.js';
4
+
5
+ function makeCommand(type: string, correlationId: string, requestId = 'req-1'): Command {
6
+ return { type, data: {}, correlationId, requestId };
7
+ }
8
+
9
+ function makeEvent(type: string, correlationId: string, data: Record<string, unknown> = {}): Event {
10
+ return { type, data, correlationId };
11
+ }
12
+
13
+ describe('V2RuntimeBridge', () => {
14
+ describe('registerSettled', () => {
15
+ it('creates workflow in processor that accepts keyed events', () => {
16
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
17
+ const descriptor: SettledHandlerDescriptor = {
18
+ type: 'settled',
19
+ commandTypes: ['CheckTests', 'CheckTypes'],
20
+ handler: () => undefined,
21
+ };
22
+
23
+ bridge.registerSettled(descriptor);
24
+
25
+ const stats = bridge.getSettledStats('corr-1', 'template-CheckTests,CheckTypes');
26
+ expect(stats).toEqual({ status: 'idle', pendingCount: 0, endedCount: 0 });
27
+ });
28
+ });
29
+
30
+ describe('onCommandStarted', () => {
31
+ it('emits StartSettled for relevant groups only', () => {
32
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
33
+ bridge.registerSettled({
34
+ type: 'settled',
35
+ commandTypes: ['A', 'B'],
36
+ handler: () => undefined,
37
+ });
38
+ bridge.registerSettled({
39
+ type: 'settled',
40
+ commandTypes: ['C', 'D'],
41
+ handler: () => undefined,
42
+ });
43
+
44
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
45
+
46
+ const statsAB = bridge.getSettledStats('corr-1', 'template-A,B');
47
+ expect(statsAB).toEqual({ status: 'running', pendingCount: 1, endedCount: 0 });
48
+
49
+ const statsCD = bridge.getSettledStats('corr-1', 'template-C,D');
50
+ expect(statsCD).toEqual({ status: 'idle', pendingCount: 0, endedCount: 0 });
51
+ });
52
+
53
+ it('ignores commands with invalid correlationId or requestId', () => {
54
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
55
+ bridge.registerSettled({
56
+ type: 'settled',
57
+ commandTypes: ['A'],
58
+ handler: () => undefined,
59
+ });
60
+
61
+ bridge.onCommandStarted(makeCommand('A', ''));
62
+ bridge.onCommandStarted(makeCommand('A', 'corr-1', ''));
63
+
64
+ const stats1 = bridge.getSettledStats('', 'template-A');
65
+ expect(stats1).toEqual({ status: 'idle', pendingCount: 0, endedCount: 0 });
66
+
67
+ const stats2 = bridge.getSettledStats('corr-1', 'template-A');
68
+ expect(stats2).toEqual({ status: 'idle', pendingCount: 0, endedCount: 0 });
69
+ });
70
+
71
+ it('ignores commands for unregistered command types', () => {
72
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
73
+ bridge.registerSettled({
74
+ type: 'settled',
75
+ commandTypes: ['A'],
76
+ handler: () => undefined,
77
+ });
78
+
79
+ bridge.onCommandStarted(makeCommand('Z', 'corr-1'));
80
+
81
+ const stats = bridge.getSettledStats('corr-1', 'template-A');
82
+ expect(stats).toEqual({ status: 'idle', pendingCount: 0, endedCount: 0 });
83
+ });
84
+ });
85
+
86
+ describe('onEventReceived', () => {
87
+ it('translates to CommandCompleted and buffers event', () => {
88
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
89
+ const handlerCalls: Record<string, Event[]>[] = [];
90
+ bridge.registerSettled({
91
+ type: 'settled',
92
+ commandTypes: ['A'],
93
+ handler: (events) => {
94
+ handlerCalls.push(events);
95
+ return undefined;
96
+ },
97
+ });
98
+
99
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
100
+ bridge.onEventReceived(makeEvent('ACompleted', 'corr-1', { value: 42 }), 'A');
101
+
102
+ expect(handlerCalls).toHaveLength(1);
103
+ expect(handlerCalls[0]).toEqual({
104
+ A: [{ type: 'ACompleted', data: { value: 42 }, correlationId: 'corr-1' }],
105
+ });
106
+ });
107
+
108
+ it('ignores events for unregistered command types', () => {
109
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
110
+ const handlerCalls: unknown[] = [];
111
+ bridge.registerSettled({
112
+ type: 'settled',
113
+ commandTypes: ['A'],
114
+ handler: (events) => {
115
+ handlerCalls.push(events);
116
+ return undefined;
117
+ },
118
+ });
119
+
120
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
121
+ bridge.onEventReceived(makeEvent('XCompleted', 'corr-1'), 'X');
122
+
123
+ expect(handlerCalls).toHaveLength(0);
124
+ });
125
+
126
+ it('ignores events with empty correlationId', () => {
127
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
128
+ const handlerCalls: unknown[] = [];
129
+ bridge.registerSettled({
130
+ type: 'settled',
131
+ commandTypes: ['A'],
132
+ handler: (events) => {
133
+ handlerCalls.push(events);
134
+ return undefined;
135
+ },
136
+ });
137
+
138
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
139
+ bridge.onEventReceived(makeEvent('ACompleted', ''), 'A');
140
+
141
+ expect(handlerCalls).toHaveLength(0);
142
+ });
143
+ });
144
+
145
+ describe('AllSettled fires v1 callback', () => {
146
+ it('invokes handler with correct buffered events grouped by commandType', () => {
147
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
148
+ const handlerCalls: Record<string, Event[]>[] = [];
149
+ bridge.registerSettled({
150
+ type: 'settled',
151
+ commandTypes: ['A', 'B'],
152
+ handler: (events) => {
153
+ handlerCalls.push(events);
154
+ return undefined;
155
+ },
156
+ });
157
+
158
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
159
+ bridge.onCommandStarted(makeCommand('B', 'corr-1'));
160
+ bridge.onEventReceived(makeEvent('ADone', 'corr-1', { a: 1 }), 'A');
161
+ bridge.onEventReceived(makeEvent('BDone', 'corr-1', { b: 2 }), 'B');
162
+
163
+ expect(handlerCalls).toHaveLength(1);
164
+ expect(handlerCalls[0]).toEqual({
165
+ A: [{ type: 'ADone', data: { a: 1 }, correlationId: 'corr-1' }],
166
+ B: [{ type: 'BDone', data: { b: 2 }, correlationId: 'corr-1' }],
167
+ });
168
+ });
169
+
170
+ it('passes send function that dispatches via onDispatch', () => {
171
+ const dispatched: Array<{ commandType: string; data: unknown; correlationId: string }> = [];
172
+ const bridge = createV2RuntimeBridge({
173
+ onDispatch: (commandType, data, correlationId) => {
174
+ dispatched.push({ commandType, data, correlationId });
175
+ },
176
+ });
177
+
178
+ bridge.registerSettled({
179
+ type: 'settled',
180
+ commandTypes: ['A'],
181
+ handler: (_events, send) => {
182
+ send('NextCommand', { payload: true });
183
+ return undefined;
184
+ },
185
+ });
186
+
187
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
188
+ bridge.onEventReceived(makeEvent('ADone', 'corr-1'), 'A');
189
+
190
+ expect(dispatched).toEqual([{ commandType: 'NextCommand', data: { payload: true }, correlationId: 'corr-1' }]);
191
+ });
192
+ });
193
+
194
+ describe('RetryCommands', () => {
195
+ it('triggers onDispatch for each failed commandType', () => {
196
+ const dispatched: Array<{ commandType: string; data: unknown; correlationId: string }> = [];
197
+ const bridge = createV2RuntimeBridge({
198
+ onDispatch: (commandType, data, correlationId) => {
199
+ dispatched.push({ commandType, data, correlationId });
200
+ },
201
+ });
202
+
203
+ bridge.registerSettled({
204
+ type: 'settled',
205
+ commandTypes: ['A', 'B'],
206
+ handler: () => undefined,
207
+ });
208
+
209
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
210
+ bridge.onCommandStarted(makeCommand('B', 'corr-1'));
211
+ bridge.onEventReceived(makeEvent('ADone', 'corr-1'), 'A');
212
+ bridge.onEventReceived(makeEvent('BFailed', 'corr-1'), 'B', 'failure');
213
+
214
+ expect(dispatched).toEqual([{ commandType: 'B', data: {}, correlationId: 'corr-1' }]);
215
+ });
216
+ });
217
+
218
+ describe('persist: true resets instance', () => {
219
+ it('allows receiving new commands after reset', () => {
220
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
221
+ const handlerCalls: Record<string, Event[]>[] = [];
222
+ bridge.registerSettled({
223
+ type: 'settled',
224
+ commandTypes: ['A'],
225
+ handler: (events) => {
226
+ handlerCalls.push(events);
227
+ return { persist: true };
228
+ },
229
+ });
230
+
231
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
232
+ bridge.onEventReceived(makeEvent('ADone', 'corr-1', { round: 1 }), 'A');
233
+
234
+ expect(handlerCalls).toHaveLength(1);
235
+
236
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
237
+ bridge.onEventReceived(makeEvent('ADone', 'corr-1', { round: 2 }), 'A');
238
+
239
+ expect(handlerCalls).toHaveLength(2);
240
+ expect(handlerCalls[1]).toEqual({
241
+ A: [{ type: 'ADone', data: { round: 2 }, correlationId: 'corr-1' }],
242
+ });
243
+ });
244
+ });
245
+
246
+ describe('independent correlationIds', () => {
247
+ it('tracks two correlationIds independently', () => {
248
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
249
+ const handlerCalls: Array<{ correlationId: string; events: Record<string, Event[]> }> = [];
250
+
251
+ bridge.registerSettled({
252
+ type: 'settled',
253
+ commandTypes: ['A', 'B'],
254
+ handler: (events) => {
255
+ const corrId = Object.values(events).flat()[0]?.correlationId ?? '';
256
+ handlerCalls.push({ correlationId: corrId, events });
257
+ return undefined;
258
+ },
259
+ });
260
+
261
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
262
+ bridge.onCommandStarted(makeCommand('B', 'corr-1'));
263
+ bridge.onCommandStarted(makeCommand('A', 'corr-2'));
264
+ bridge.onCommandStarted(makeCommand('B', 'corr-2'));
265
+
266
+ bridge.onEventReceived(makeEvent('ADone', 'corr-1', { v: 1 }), 'A');
267
+ bridge.onEventReceived(makeEvent('ADone', 'corr-2', { v: 2 }), 'A');
268
+ bridge.onEventReceived(makeEvent('BDone', 'corr-1', { v: 3 }), 'B');
269
+
270
+ expect(handlerCalls).toHaveLength(1);
271
+ expect(handlerCalls[0].correlationId).toBe('corr-1');
272
+
273
+ bridge.onEventReceived(makeEvent('BDone', 'corr-2', { v: 4 }), 'B');
274
+
275
+ expect(handlerCalls).toHaveLength(2);
276
+ expect(handlerCalls[1].correlationId).toBe('corr-2');
277
+ });
278
+ });
279
+
280
+ describe('getSettledStats', () => {
281
+ it('returns idle for unknown correlationId', () => {
282
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
283
+ bridge.registerSettled({
284
+ type: 'settled',
285
+ commandTypes: ['A'],
286
+ handler: () => undefined,
287
+ });
288
+
289
+ const stats = bridge.getSettledStats('unknown', 'template-A');
290
+ expect(stats).toEqual({ status: 'idle', pendingCount: 0, endedCount: 0 });
291
+ });
292
+
293
+ it('returns running while waiting for completions', () => {
294
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
295
+ bridge.registerSettled({
296
+ type: 'settled',
297
+ commandTypes: ['A', 'B'],
298
+ handler: () => undefined,
299
+ });
300
+
301
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
302
+
303
+ const stats = bridge.getSettledStats('corr-1', 'template-A,B');
304
+ expect(stats).toEqual({ status: 'running', pendingCount: 1, endedCount: 0 });
305
+ });
306
+
307
+ it('returns success after AllSettled', () => {
308
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
309
+ bridge.registerSettled({
310
+ type: 'settled',
311
+ commandTypes: ['A'],
312
+ handler: () => undefined,
313
+ });
314
+
315
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
316
+ bridge.onEventReceived(makeEvent('ADone', 'corr-1'), 'A');
317
+
318
+ const stats = bridge.getSettledStats('corr-1', 'template-A');
319
+ expect(stats).toEqual({ status: 'success', pendingCount: 0, endedCount: 1 });
320
+ });
321
+
322
+ it('returns error after SettledFailed', () => {
323
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
324
+ bridge.registerSettled(
325
+ {
326
+ type: 'settled',
327
+ commandTypes: ['A'],
328
+ handler: () => undefined,
329
+ },
330
+ { maxRetries: 0 },
331
+ );
332
+
333
+ bridge.onCommandStarted(makeCommand('A', 'corr-1'));
334
+ bridge.onEventReceived(makeEvent('AFailed', 'corr-1'), 'A', 'failure');
335
+
336
+ const stats = bridge.getSettledStats('corr-1', 'template-A');
337
+ expect(stats).toEqual({ status: 'error', pendingCount: 0, endedCount: 0 });
338
+ });
339
+
340
+ it('returns idle for unknown templateId', () => {
341
+ const bridge = createV2RuntimeBridge({ onDispatch: () => {} });
342
+
343
+ const stats = bridge.getSettledStats('corr-1', 'template-Unknown');
344
+ expect(stats).toEqual({ status: 'idle', pendingCount: 0, endedCount: 0 });
345
+ });
346
+ });
347
+ });