@auto-engineer/pipeline 1.74.0 → 1.76.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 (32) 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 +54 -0
  5. package/dist/src/projections/item-status-projection.d.ts +4 -1
  6. package/dist/src/projections/item-status-projection.d.ts.map +1 -1
  7. package/dist/src/projections/item-status-projection.js +10 -2
  8. package/dist/src/projections/item-status-projection.js.map +1 -1
  9. package/dist/src/projections/message-log-projection.d.ts +1 -0
  10. package/dist/src/projections/message-log-projection.d.ts.map +1 -1
  11. package/dist/src/projections/message-log-projection.js +1 -0
  12. package/dist/src/projections/message-log-projection.js.map +1 -1
  13. package/dist/src/projections/node-status-projection.d.ts +3 -1
  14. package/dist/src/projections/node-status-projection.d.ts.map +1 -1
  15. package/dist/src/projections/node-status-projection.js +2 -1
  16. package/dist/src/projections/node-status-projection.js.map +1 -1
  17. package/dist/src/server/pipeline-server.d.ts.map +1 -1
  18. package/dist/src/server/pipeline-server.js +17 -6
  19. package/dist/src/server/pipeline-server.js.map +1 -1
  20. package/dist/src/store/pipeline-read-model.d.ts.map +1 -1
  21. package/dist/src/store/pipeline-read-model.js +3 -0
  22. package/dist/src/store/pipeline-read-model.js.map +1 -1
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +3 -3
  25. package/src/projections/item-status-projection.specs.ts +83 -0
  26. package/src/projections/item-status-projection.ts +14 -2
  27. package/src/projections/message-log-projection.ts +2 -0
  28. package/src/projections/node-status-projection.specs.ts +108 -0
  29. package/src/projections/node-status-projection.ts +4 -1
  30. package/src/server/pipeline-server.specs.ts +76 -0
  31. package/src/server/pipeline-server.ts +31 -5
  32. package/src/store/pipeline-read-model.ts +3 -0
package/package.json CHANGED
@@ -13,8 +13,8 @@
13
13
  "get-port": "^7.1.0",
14
14
  "jose": "^5.9.6",
15
15
  "nanoid": "^5.0.0",
16
- "@auto-engineer/file-store": "1.74.0",
17
- "@auto-engineer/message-bus": "1.74.0"
16
+ "@auto-engineer/file-store": "1.76.0",
17
+ "@auto-engineer/message-bus": "1.76.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/cors": "^2.8.17",
@@ -23,7 +23,7 @@
23
23
  "publishConfig": {
24
24
  "access": "public"
25
25
  },
26
- "version": "1.74.0",
26
+ "version": "1.76.0",
27
27
  "scripts": {
28
28
  "build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
29
29
  "test": "vitest run --reporter=dot",
@@ -126,5 +126,88 @@ describe('ItemStatusProjection', () => {
126
126
  attemptCount: 2,
127
127
  });
128
128
  });
129
+
130
+ it('sets startedAt when status becomes running', () => {
131
+ const now = new Date('2025-01-01T00:00:00Z');
132
+ const event: ItemStatusChangedEvent = {
133
+ type: 'ItemStatusChanged',
134
+ data: {
135
+ correlationId: 'c1',
136
+ commandType: 'ProcessItem',
137
+ itemKey: 'item-1',
138
+ requestId: 'req-1',
139
+ status: 'running',
140
+ attemptCount: 1,
141
+ timestamp: now.toISOString(),
142
+ },
143
+ };
144
+
145
+ const result = evolve(null, event);
146
+
147
+ expect(result.startedAt).toEqual(now.toISOString());
148
+ expect(result.endedAt).toBeUndefined();
149
+ });
150
+
151
+ it('sets endedAt when status becomes success', () => {
152
+ const startTime = new Date('2025-01-01T00:00:00Z');
153
+ const endTime = new Date('2025-01-01T00:00:05Z');
154
+ const existing: ItemStatusDocument = {
155
+ correlationId: 'c1',
156
+ commandType: 'ProcessItem',
157
+ itemKey: 'item-1',
158
+ currentRequestId: 'req-1',
159
+ status: 'running',
160
+ attemptCount: 1,
161
+ startedAt: startTime.toISOString(),
162
+ };
163
+ const event: ItemStatusChangedEvent = {
164
+ type: 'ItemStatusChanged',
165
+ data: {
166
+ correlationId: 'c1',
167
+ commandType: 'ProcessItem',
168
+ itemKey: 'item-1',
169
+ requestId: 'req-1',
170
+ status: 'success',
171
+ attemptCount: 1,
172
+ timestamp: endTime.toISOString(),
173
+ },
174
+ };
175
+
176
+ const result = evolve(existing, event);
177
+
178
+ expect(result.startedAt).toEqual(startTime.toISOString());
179
+ expect(result.endedAt).toEqual(endTime.toISOString());
180
+ });
181
+
182
+ it('sets endedAt when status becomes error', () => {
183
+ const startTime = new Date('2025-01-01T00:00:00Z');
184
+ const endTime = new Date('2025-01-01T00:00:03Z');
185
+ const existing: ItemStatusDocument = {
186
+ correlationId: 'c1',
187
+ commandType: 'ProcessItem',
188
+ itemKey: 'item-1',
189
+ currentRequestId: 'req-1',
190
+ status: 'running',
191
+ attemptCount: 1,
192
+ startedAt: startTime.toISOString(),
193
+ };
194
+ const event: ItemStatusChangedEvent = {
195
+ type: 'ItemStatusChanged',
196
+ data: {
197
+ correlationId: 'c1',
198
+ commandType: 'ProcessItem',
199
+ itemKey: 'item-1',
200
+ requestId: 'req-1',
201
+ status: 'error',
202
+ attemptCount: 1,
203
+ timestamp: endTime.toISOString(),
204
+ },
205
+ };
206
+
207
+ const result = evolve(existing, event);
208
+
209
+ expect(result.startedAt).toEqual(startTime.toISOString());
210
+ expect(result.endedAt).toEqual(endTime.toISOString());
211
+ });
129
212
  });
130
213
  });
@@ -6,6 +6,8 @@ export interface ItemStatusDocument {
6
6
  currentRequestId: string;
7
7
  status: 'running' | 'success' | 'error';
8
8
  attemptCount: number;
9
+ startedAt?: string;
10
+ endedAt?: string;
9
11
  }
10
12
 
11
13
  export interface ItemStatusChangedEvent {
@@ -17,11 +19,12 @@ export interface ItemStatusChangedEvent {
17
19
  requestId: string;
18
20
  status: 'running' | 'success' | 'error';
19
21
  attemptCount: number;
22
+ timestamp?: string;
20
23
  };
21
24
  }
22
25
 
23
- export function evolve(_document: ItemStatusDocument | null, event: ItemStatusChangedEvent): ItemStatusDocument {
24
- return {
26
+ export function evolve(document: ItemStatusDocument | null, event: ItemStatusChangedEvent): ItemStatusDocument {
27
+ const base: ItemStatusDocument = {
25
28
  correlationId: event.data.correlationId,
26
29
  commandType: event.data.commandType,
27
30
  itemKey: event.data.itemKey,
@@ -29,4 +32,13 @@ export function evolve(_document: ItemStatusDocument | null, event: ItemStatusCh
29
32
  status: event.data.status,
30
33
  attemptCount: event.data.attemptCount,
31
34
  };
35
+
36
+ if (event.data.status === 'running') {
37
+ base.startedAt = event.data.timestamp;
38
+ } else {
39
+ base.startedAt = document?.startedAt;
40
+ base.endedAt = event.data.timestamp;
41
+ }
42
+
43
+ return base;
32
44
  }
@@ -50,6 +50,7 @@ export interface NodeStatusChangedLogEvent {
50
50
  previousStatus: NodeStatus;
51
51
  pendingCount: number;
52
52
  endedCount: number;
53
+ lastDurationMs?: number;
53
54
  };
54
55
  }
55
56
 
@@ -107,6 +108,7 @@ export function evolve(_document: MessageLogDocument | null, event: MessageLogEv
107
108
  previousStatus: event.data.previousStatus,
108
109
  pendingCount: event.data.pendingCount,
109
110
  endedCount: event.data.endedCount,
111
+ lastDurationMs: event.data.lastDurationMs,
110
112
  },
111
113
  timestamp: new Date(),
112
114
  };
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { NodeStatus } from '../graph/types';
3
+ import { evolve, type NodeStatusChangedEvent, type NodeStatusDocument } from './node-status-projection';
4
+
5
+ describe('NodeStatusProjection', () => {
6
+ describe('evolve', () => {
7
+ it('creates node document from event', () => {
8
+ const event: NodeStatusChangedEvent = {
9
+ type: 'NodeStatusChanged',
10
+ data: {
11
+ correlationId: 'c1',
12
+ commandName: 'RunCmd',
13
+ nodeId: 'cmd:RunCmd',
14
+ status: 'running' as NodeStatus,
15
+ previousStatus: 'idle' as NodeStatus,
16
+ pendingCount: 1,
17
+ endedCount: 0,
18
+ },
19
+ };
20
+
21
+ const result = evolve(null, event);
22
+
23
+ expect(result).toEqual({
24
+ correlationId: 'c1',
25
+ commandName: 'RunCmd',
26
+ status: 'running',
27
+ pendingCount: 1,
28
+ endedCount: 0,
29
+ lastDurationMs: undefined,
30
+ });
31
+ });
32
+
33
+ it('stores lastDurationMs when provided in event', () => {
34
+ const event: NodeStatusChangedEvent = {
35
+ type: 'NodeStatusChanged',
36
+ data: {
37
+ correlationId: 'c1',
38
+ commandName: 'RunCmd',
39
+ nodeId: 'cmd:RunCmd',
40
+ status: 'success' as NodeStatus,
41
+ previousStatus: 'running' as NodeStatus,
42
+ pendingCount: 0,
43
+ endedCount: 1,
44
+ lastDurationMs: 5000,
45
+ },
46
+ };
47
+
48
+ const result = evolve(null, event);
49
+
50
+ expect(result.lastDurationMs).toBe(5000);
51
+ });
52
+
53
+ it('preserves lastDurationMs from existing document when event has none', () => {
54
+ const existing: NodeStatusDocument = {
55
+ correlationId: 'c1',
56
+ commandName: 'RunCmd',
57
+ status: 'success' as NodeStatus,
58
+ pendingCount: 0,
59
+ endedCount: 1,
60
+ lastDurationMs: 3000,
61
+ };
62
+ const event: NodeStatusChangedEvent = {
63
+ type: 'NodeStatusChanged',
64
+ data: {
65
+ correlationId: 'c1',
66
+ commandName: 'RunCmd',
67
+ nodeId: 'cmd:RunCmd',
68
+ status: 'running' as NodeStatus,
69
+ previousStatus: 'success' as NodeStatus,
70
+ pendingCount: 1,
71
+ endedCount: 1,
72
+ },
73
+ };
74
+
75
+ const result = evolve(existing, event);
76
+
77
+ expect(result.lastDurationMs).toBe(3000);
78
+ });
79
+
80
+ it('overwrites lastDurationMs when event provides new value', () => {
81
+ const existing: NodeStatusDocument = {
82
+ correlationId: 'c1',
83
+ commandName: 'RunCmd',
84
+ status: 'success' as NodeStatus,
85
+ pendingCount: 0,
86
+ endedCount: 1,
87
+ lastDurationMs: 3000,
88
+ };
89
+ const event: NodeStatusChangedEvent = {
90
+ type: 'NodeStatusChanged',
91
+ data: {
92
+ correlationId: 'c1',
93
+ commandName: 'RunCmd',
94
+ nodeId: 'cmd:RunCmd',
95
+ status: 'success' as NodeStatus,
96
+ previousStatus: 'running' as NodeStatus,
97
+ pendingCount: 0,
98
+ endedCount: 2,
99
+ lastDurationMs: 7000,
100
+ },
101
+ };
102
+
103
+ const result = evolve(existing, event);
104
+
105
+ expect(result.lastDurationMs).toBe(7000);
106
+ });
107
+ });
108
+ });
@@ -7,6 +7,7 @@ export interface NodeStatusDocument {
7
7
  status: NodeStatus;
8
8
  pendingCount: number;
9
9
  endedCount: number;
10
+ lastDurationMs?: number;
10
11
  }
11
12
 
12
13
  export interface NodeStatusChangedEvent {
@@ -19,15 +20,17 @@ export interface NodeStatusChangedEvent {
19
20
  previousStatus: NodeStatus;
20
21
  pendingCount: number;
21
22
  endedCount: number;
23
+ lastDurationMs?: number;
22
24
  };
23
25
  }
24
26
 
25
- export function evolve(_document: NodeStatusDocument | null, event: NodeStatusChangedEvent): NodeStatusDocument {
27
+ export function evolve(document: NodeStatusDocument | null, event: NodeStatusChangedEvent): NodeStatusDocument {
26
28
  return {
27
29
  correlationId: event.data.correlationId,
28
30
  commandName: event.data.commandName,
29
31
  status: event.data.status,
30
32
  pendingCount: event.data.pendingCount,
31
33
  endedCount: event.data.endedCount,
34
+ lastDurationMs: event.data.lastDurationMs ?? document?.lastDurationMs,
32
35
  };
33
36
  }
@@ -33,6 +33,7 @@ interface GraphNode {
33
33
  status?: string;
34
34
  pendingCount?: number;
35
35
  endedCount?: number;
36
+ lastDurationMs?: number;
36
37
  }
37
38
 
38
39
  interface PipelineResponse {
@@ -1852,6 +1853,81 @@ describe('PipelineServer', () => {
1852
1853
  await server.stop();
1853
1854
  });
1854
1855
 
1856
+ it('should include lastDurationMs in pipeline graph after command completes', async () => {
1857
+ const handler = {
1858
+ name: 'DurationCmd',
1859
+ events: ['DurationDone'],
1860
+ handle: async () => {
1861
+ await new Promise((r) => setTimeout(r, 10));
1862
+ return { type: 'DurationDone', data: {} };
1863
+ },
1864
+ };
1865
+ const pipeline = define('test').on('Trigger').emit('DurationCmd', {}).build();
1866
+ const server = new PipelineServer({ port: 0 });
1867
+ server.registerCommandHandlers([handler]);
1868
+ server.registerPipeline(pipeline);
1869
+ server.registerItemKeyExtractor('DurationCmd', (d) => (d as { id?: string }).id);
1870
+ await server.start();
1871
+
1872
+ await fetch(`http://localhost:${server.port}/command`, {
1873
+ method: 'POST',
1874
+ headers: { 'Content-Type': 'application/json' },
1875
+ body: JSON.stringify({ type: 'DurationCmd', data: { id: 'item-1' } }),
1876
+ });
1877
+
1878
+ await new Promise((r) => setTimeout(r, 200));
1879
+
1880
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
1881
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:DurationCmd');
1882
+ expect(cmdNode).toBeDefined();
1883
+ expect(cmdNode?.lastDurationMs).toBeTypeOf('number');
1884
+ expect(cmdNode?.lastDurationMs).toBeGreaterThanOrEqual(0);
1885
+
1886
+ await server.stop();
1887
+ });
1888
+
1889
+ it('should include lastDurationMs in NodeStatusChanged events on completion', async () => {
1890
+ const handler = {
1891
+ name: 'DurationEvtCmd',
1892
+ events: ['DurationEvtDone'],
1893
+ handle: async () => {
1894
+ await new Promise((r) => setTimeout(r, 10));
1895
+ return { type: 'DurationEvtDone', data: {} };
1896
+ },
1897
+ };
1898
+ const pipeline = define('test').on('Trigger').emit('DurationEvtCmd', {}).build();
1899
+ const server = new PipelineServer({ port: 0 });
1900
+ server.registerCommandHandlers([handler]);
1901
+ server.registerPipeline(pipeline);
1902
+ server.registerItemKeyExtractor('DurationEvtCmd', (d) => (d as { id?: string }).id);
1903
+ await server.start();
1904
+
1905
+ await fetch(`http://localhost:${server.port}/command`, {
1906
+ method: 'POST',
1907
+ headers: { 'Content-Type': 'application/json' },
1908
+ body: JSON.stringify({ type: 'DurationEvtCmd', data: { id: 'item-1' } }),
1909
+ });
1910
+
1911
+ await new Promise((r) => setTimeout(r, 200));
1912
+
1913
+ const msgs = await fetchAs<StoredMessage[]>(`http://localhost:${server.port}/messages`);
1914
+ type NodeStatusChangedMessage = {
1915
+ type: string;
1916
+ data?: {
1917
+ status?: string;
1918
+ lastDurationMs?: number;
1919
+ };
1920
+ };
1921
+ const nodeStatusChanged = msgs.filter((m) => m.message.type === 'NodeStatusChanged');
1922
+ const successEvent = nodeStatusChanged.find(
1923
+ (m) => (m.message as NodeStatusChangedMessage).data?.status === 'success',
1924
+ );
1925
+ expect(successEvent).toBeDefined();
1926
+ expect((successEvent?.message as NodeStatusChangedMessage).data?.lastDurationMs).toBeTypeOf('number');
1927
+
1928
+ await server.stop();
1929
+ });
1930
+
1855
1931
  it('should use requestId as fallback when no itemKey extractor is registered', async () => {
1856
1932
  const handler = {
1857
1933
  name: 'NoExtractorCmd',
@@ -46,6 +46,10 @@ interface EventWithCorrelation extends Event {
46
46
  correlationId: string;
47
47
  }
48
48
 
49
+ interface GraphNodeWithDuration extends GraphNode {
50
+ lastDurationMs?: number;
51
+ }
52
+
49
53
  export class PipelineServer {
50
54
  private app: express.Application;
51
55
  private httpServer: HttpServer;
@@ -370,6 +374,7 @@ export class PipelineServer {
370
374
  messageType: m.messageType,
371
375
  revision: String(index),
372
376
  position: String(index),
377
+ timestamp: m.timestamp,
373
378
  }));
374
379
  res.json(serialized);
375
380
  })();
@@ -494,17 +499,19 @@ export class PipelineServer {
494
499
  };
495
500
  }
496
501
 
497
- private async addStatusToCommandNode(node: GraphNode, correlationId?: string): Promise<GraphNode> {
502
+ private async addStatusToCommandNode(node: GraphNode, correlationId?: string): Promise<GraphNodeWithDuration> {
498
503
  const commandName = node.id.replace(/^cmd:/, '');
499
504
  if (correlationId === undefined) {
500
505
  return { ...node, status: 'idle' as NodeStatus, pendingCount: 0, endedCount: 0 };
501
506
  }
502
507
  const stats = await this.computeCommandStats(correlationId, commandName);
508
+ const nodeStatus = await this.eventStoreContext.readModel.getNodeStatus(correlationId, commandName);
503
509
  return {
504
510
  ...node,
505
511
  status: stats.aggregateStatus,
506
512
  pendingCount: stats.pendingCount,
507
513
  endedCount: stats.endedCount,
514
+ lastDurationMs: nodeStatus?.lastDurationMs,
508
515
  };
509
516
  }
510
517
 
@@ -541,6 +548,7 @@ export class PipelineServer {
541
548
  requestId,
542
549
  status,
543
550
  attemptCount,
551
+ timestamp: new Date().toISOString(),
544
552
  },
545
553
  },
546
554
  ]);
@@ -551,6 +559,7 @@ export class PipelineServer {
551
559
  commandName: string,
552
560
  status: NodeStatus,
553
561
  previousStatus: NodeStatus,
562
+ lastDurationMs?: number,
554
563
  ): Promise<void> {
555
564
  const stats = await this.computeCommandStats(correlationId, commandName);
556
565
  await this.eventStoreContext.eventStore.appendToStream(`pipeline-${correlationId}`, [
@@ -564,6 +573,7 @@ export class PipelineServer {
564
573
  previousStatus,
565
574
  pendingCount: stats.pendingCount,
566
575
  endedCount: stats.endedCount,
576
+ lastDurationMs,
567
577
  },
568
578
  },
569
579
  ]);
@@ -621,11 +631,16 @@ export class PipelineServer {
621
631
  ]);
622
632
  }
623
633
 
624
- private async updateNodeStatus(correlationId: string, commandName: string, status: NodeStatus): Promise<void> {
634
+ private async updateNodeStatus(
635
+ correlationId: string,
636
+ commandName: string,
637
+ status: NodeStatus,
638
+ lastDurationMs?: number,
639
+ ): Promise<void> {
625
640
  const existing = await this.eventStoreContext.readModel.getNodeStatus(correlationId, commandName);
626
641
  const previousStatus: NodeStatus = existing?.status ?? 'idle';
627
- await this.emitNodeStatusChanged(correlationId, commandName, status, previousStatus);
628
- await this.broadcastNodeStatusChanged(correlationId, commandName, status, previousStatus);
642
+ await this.emitNodeStatusChanged(correlationId, commandName, status, previousStatus, lastDurationMs);
643
+ await this.broadcastNodeStatusChanged(correlationId, commandName, status, previousStatus, lastDurationMs);
629
644
  }
630
645
 
631
646
  private async broadcastNodeStatusChanged(
@@ -633,6 +648,7 @@ export class PipelineServer {
633
648
  commandName: string,
634
649
  status: NodeStatus,
635
650
  previousStatus: NodeStatus,
651
+ lastDurationMs?: number,
636
652
  ): Promise<void> {
637
653
  const stats = await this.computeCommandStats(correlationId, commandName);
638
654
  const event: Event & { correlationId: string } = {
@@ -643,6 +659,7 @@ export class PipelineServer {
643
659
  previousStatus,
644
660
  pendingCount: stats.pendingCount,
645
661
  endedCount: stats.endedCount,
662
+ lastDurationMs,
646
663
  },
647
664
  correlationId,
648
665
  };
@@ -1022,7 +1039,16 @@ export class PipelineServer {
1022
1039
 
1023
1040
  const finalStatus = this.getStatusFromEvents(events);
1024
1041
  await this.updateItemStatus(this.currentSessionId, command.type, itemKey, finalStatus);
1025
- await this.updateNodeStatus(this.currentSessionId, command.type, finalStatus);
1042
+ const completedItem = await this.eventStoreContext.readModel.getItemStatus(
1043
+ this.currentSessionId,
1044
+ command.type,
1045
+ itemKey,
1046
+ );
1047
+ let durationMs: number | undefined;
1048
+ if (completedItem?.startedAt && completedItem?.endedAt) {
1049
+ durationMs = new Date(completedItem.endedAt).getTime() - new Date(completedItem.startedAt).getTime();
1050
+ }
1051
+ await this.updateNodeStatus(this.currentSessionId, command.type, finalStatus, durationMs);
1026
1052
 
1027
1053
  const eventsWithIds: EventWithCorrelation[] = events.map((event) => ({
1028
1054
  ...event,
@@ -91,6 +91,7 @@ export class PipelineReadModel {
91
91
  status: node.status,
92
92
  pendingCount: node.pendingCount,
93
93
  endedCount: node.endedCount,
94
+ lastDurationMs: node.lastDurationMs,
94
95
  };
95
96
  }
96
97
 
@@ -109,6 +110,8 @@ export class PipelineReadModel {
109
110
  currentRequestId: item.currentRequestId,
110
111
  status: item.status,
111
112
  attemptCount: item.attemptCount,
113
+ startedAt: item.startedAt,
114
+ endedAt: item.endedAt,
112
115
  };
113
116
  }
114
117