@auto-engineer/pipeline 1.135.0 → 1.136.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.
package/ketchup-plan.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Ketchup Plan: Fix Pipeline Diagram Wrong Status for Retried Commands
2
2
 
3
+ ### Bottle: RetryStatusFix
4
+
3
5
  ## TODO
4
6
 
5
7
  ## DONE
@@ -8,3 +10,6 @@
8
10
  - [x] Burst 2: Add `autoRegisterItemKeyExtractor` and call from `registerCommandHandlers` [depends: Burst 1] (0bd4e2a7)
9
11
  - [x] Burst 3: Covered by Bursts 1+2 tests [depends: Burst 2]
10
12
  - [x] Burst 4: Test manual extractor takes precedence over auto-derived [depends: Burst 2]
13
+ - [x] Burst 5: Add `batchId` field to `ItemStatusDocument` and `ItemStatusChangedEvent`, preserve via `evolve` fallback [depends: none] (752e1329)
14
+ - [x] Burst 6: Scope `computeCommandStats` to latest batch — filter items by highest `batchId` with backward compat fallback [depends: Burst 5] (a34a7e6a)
15
+ - [x] Burst 7+8: Wire sync `resolveBatchId` with pending counter, update test expectations for batch-scoped counts [depends: Burst 6] (7e33cf98)
package/package.json CHANGED
@@ -15,8 +15,8 @@
15
15
  "jose": "^5.9.6",
16
16
  "nanoid": "^5.0.0",
17
17
  "uuid": "^11.0.0",
18
- "@auto-engineer/file-store": "1.135.0",
19
- "@auto-engineer/message-bus": "1.135.0"
18
+ "@auto-engineer/file-store": "1.136.0",
19
+ "@auto-engineer/message-bus": "1.136.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/cors": "^2.8.17",
@@ -25,7 +25,7 @@
25
25
  "publishConfig": {
26
26
  "access": "public"
27
27
  },
28
- "version": "1.135.0",
28
+ "version": "1.136.0",
29
29
  "scripts": {
30
30
  "build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
31
31
  "test": "vitest run --reporter=dot",
@@ -179,6 +179,68 @@ describe('ItemStatusProjection', () => {
179
179
  expect(result.endedAt).toEqual(endTime.toISOString());
180
180
  });
181
181
 
182
+ it('preserves batchId from creation event on subsequent updates', () => {
183
+ const existing: ItemStatusDocument = {
184
+ correlationId: 'c1',
185
+ commandType: 'ProcessItem',
186
+ itemKey: 'item-1',
187
+ currentRequestId: 'req-1',
188
+ status: 'running',
189
+ attemptCount: 1,
190
+ batchId: '2025-01-01T00:00:00.000Z',
191
+ };
192
+ const event: ItemStatusChangedEvent = {
193
+ type: 'ItemStatusChanged',
194
+ data: {
195
+ correlationId: 'c1',
196
+ commandType: 'ProcessItem',
197
+ itemKey: 'item-1',
198
+ requestId: 'req-1',
199
+ status: 'success',
200
+ attemptCount: 1,
201
+ },
202
+ };
203
+
204
+ const result = evolve(existing, event);
205
+
206
+ expect(result).toEqual({
207
+ correlationId: 'c1',
208
+ commandType: 'ProcessItem',
209
+ itemKey: 'item-1',
210
+ currentRequestId: 'req-1',
211
+ status: 'success',
212
+ attemptCount: 1,
213
+ batchId: '2025-01-01T00:00:00.000Z',
214
+ });
215
+ });
216
+
217
+ it('sets batchId from event data on creation', () => {
218
+ const event: ItemStatusChangedEvent = {
219
+ type: 'ItemStatusChanged',
220
+ data: {
221
+ correlationId: 'c1',
222
+ commandType: 'ProcessItem',
223
+ itemKey: 'item-1',
224
+ requestId: 'req-1',
225
+ status: 'running',
226
+ attemptCount: 1,
227
+ batchId: '2025-01-01T00:00:00.000Z',
228
+ },
229
+ };
230
+
231
+ const result = evolve(null, event);
232
+
233
+ expect(result).toEqual({
234
+ correlationId: 'c1',
235
+ commandType: 'ProcessItem',
236
+ itemKey: 'item-1',
237
+ currentRequestId: 'req-1',
238
+ status: 'running',
239
+ attemptCount: 1,
240
+ batchId: '2025-01-01T00:00:00.000Z',
241
+ });
242
+ });
243
+
182
244
  it('sets endedAt when status becomes error', () => {
183
245
  const startTime = new Date('2025-01-01T00:00:00Z');
184
246
  const endTime = new Date('2025-01-01T00:00:03Z');
@@ -8,6 +8,7 @@ export interface ItemStatusDocument {
8
8
  attemptCount: number;
9
9
  startedAt?: string;
10
10
  endedAt?: string;
11
+ batchId?: string;
11
12
  }
12
13
 
13
14
  export interface ItemStatusChangedEvent {
@@ -20,6 +21,7 @@ export interface ItemStatusChangedEvent {
20
21
  status: 'running' | 'success' | 'error';
21
22
  attemptCount: number;
22
23
  timestamp?: string;
24
+ batchId?: string;
23
25
  };
24
26
  }
25
27
 
@@ -31,6 +33,7 @@ export function evolve(document: ItemStatusDocument | null, event: ItemStatusCha
31
33
  currentRequestId: event.data.requestId,
32
34
  status: event.data.status,
33
35
  attemptCount: event.data.attemptCount,
36
+ batchId: event.data.batchId ?? document?.batchId,
34
37
  };
35
38
 
36
39
  if (event.data.status === 'running') {
@@ -832,7 +832,7 @@ describe('PipelineServer', () => {
832
832
  const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
833
833
  const cmdNode = data.nodes.find((n) => n.id === 'cmd:IndependentCmd');
834
834
  expect(cmdNode?.status).toBe('success');
835
- expect(cmdNode?.endedCount).toBe(2);
835
+ expect(cmdNode?.endedCount).toBe(1);
836
836
 
837
837
  await server.stop();
838
838
  });
@@ -1707,10 +1707,16 @@ describe('PipelineServer', () => {
1707
1707
  });
1708
1708
 
1709
1709
  it('should count multiple parallel items correctly', async () => {
1710
+ const resolvers: Array<() => void> = [];
1710
1711
  const handler = {
1711
1712
  name: 'ImplementSlice',
1712
1713
  events: ['SliceImplemented'],
1713
- handle: async () => ({ type: 'SliceImplemented', data: {} }),
1714
+ handle: async () => {
1715
+ await new Promise<void>((resolve) => {
1716
+ resolvers.push(resolve);
1717
+ });
1718
+ return { type: 'SliceImplemented', data: {} };
1719
+ },
1714
1720
  };
1715
1721
  const pipeline = define('test').on('Trigger').emit('ImplementSlice', {}).build();
1716
1722
  const server = new PipelineServer({ port: 0 });
@@ -1719,24 +1725,26 @@ describe('PipelineServer', () => {
1719
1725
  server.registerItemKeyExtractor('ImplementSlice', (d) => (d as { slicePath?: string }).slicePath);
1720
1726
  await server.start();
1721
1727
 
1722
- await Promise.all([
1723
- fetch(`http://localhost:${server.port}/command`, {
1724
- method: 'POST',
1725
- headers: { 'Content-Type': 'application/json' },
1726
- body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-1' } }),
1727
- }),
1728
- fetch(`http://localhost:${server.port}/command`, {
1729
- method: 'POST',
1730
- headers: { 'Content-Type': 'application/json' },
1731
- body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-2' } }),
1732
- }),
1733
- fetch(`http://localhost:${server.port}/command`, {
1734
- method: 'POST',
1735
- headers: { 'Content-Type': 'application/json' },
1736
- body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-3' } }),
1737
- }),
1738
- ]);
1728
+ void fetch(`http://localhost:${server.port}/command`, {
1729
+ method: 'POST',
1730
+ headers: { 'Content-Type': 'application/json' },
1731
+ body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-1' } }),
1732
+ });
1733
+ void fetch(`http://localhost:${server.port}/command`, {
1734
+ method: 'POST',
1735
+ headers: { 'Content-Type': 'application/json' },
1736
+ body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-2' } }),
1737
+ });
1738
+ void fetch(`http://localhost:${server.port}/command`, {
1739
+ method: 'POST',
1740
+ headers: { 'Content-Type': 'application/json' },
1741
+ body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-3' } }),
1742
+ });
1739
1743
 
1744
+ while (resolvers.length < 3) {
1745
+ await new Promise((r) => setTimeout(r, 10));
1746
+ }
1747
+ resolvers.forEach((r) => r());
1740
1748
  await new Promise((r) => setTimeout(r, 100));
1741
1749
 
1742
1750
  const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
@@ -1805,10 +1813,14 @@ describe('PipelineServer', () => {
1805
1813
  });
1806
1814
 
1807
1815
  it('should show error status when any item fails', async () => {
1816
+ const resolvers: Array<() => void> = [];
1808
1817
  const handler = {
1809
1818
  name: 'MixedSlice',
1810
1819
  events: ['MixedSliceDone', 'MixedSliceFailed'],
1811
1820
  handle: async (cmd: { data: { shouldFail?: boolean } }) => {
1821
+ await new Promise<void>((resolve) => {
1822
+ resolvers.push(resolve);
1823
+ });
1812
1824
  if (cmd.data.shouldFail === true) {
1813
1825
  return { type: 'MixedSliceFailed', data: {} };
1814
1826
  }
@@ -1822,24 +1834,26 @@ describe('PipelineServer', () => {
1822
1834
  server.registerItemKeyExtractor('MixedSlice', (d) => (d as { id?: string }).id);
1823
1835
  await server.start();
1824
1836
 
1825
- await Promise.all([
1826
- fetch(`http://localhost:${server.port}/command`, {
1827
- method: 'POST',
1828
- headers: { 'Content-Type': 'application/json' },
1829
- body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-1' } }),
1830
- }),
1831
- fetch(`http://localhost:${server.port}/command`, {
1832
- method: 'POST',
1833
- headers: { 'Content-Type': 'application/json' },
1834
- body: JSON.stringify({ type: 'MixedSlice', data: { id: 'fail-1', shouldFail: true } }),
1835
- }),
1836
- fetch(`http://localhost:${server.port}/command`, {
1837
- method: 'POST',
1838
- headers: { 'Content-Type': 'application/json' },
1839
- body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-2' } }),
1840
- }),
1841
- ]);
1837
+ void fetch(`http://localhost:${server.port}/command`, {
1838
+ method: 'POST',
1839
+ headers: { 'Content-Type': 'application/json' },
1840
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-1' } }),
1841
+ });
1842
+ void fetch(`http://localhost:${server.port}/command`, {
1843
+ method: 'POST',
1844
+ headers: { 'Content-Type': 'application/json' },
1845
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'fail-1', shouldFail: true } }),
1846
+ });
1847
+ void fetch(`http://localhost:${server.port}/command`, {
1848
+ method: 'POST',
1849
+ headers: { 'Content-Type': 'application/json' },
1850
+ body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-2' } }),
1851
+ });
1842
1852
 
1853
+ while (resolvers.length < 3) {
1854
+ await new Promise((r) => setTimeout(r, 10));
1855
+ }
1856
+ resolvers.forEach((r) => r());
1843
1857
  await new Promise((r) => setTimeout(r, 100));
1844
1858
 
1845
1859
  const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
@@ -2120,7 +2134,7 @@ describe('PipelineServer', () => {
2120
2134
  label: 'RetryNoExtractor',
2121
2135
  status: 'success',
2122
2136
  pendingCount: 0,
2123
- endedCount: 2,
2137
+ endedCount: 1,
2124
2138
  lastDurationMs: expect.any(Number),
2125
2139
  });
2126
2140
 
@@ -2168,6 +2182,78 @@ describe('PipelineServer', () => {
2168
2182
  resolveHandler();
2169
2183
  await server.stop();
2170
2184
  });
2185
+
2186
+ it('should not let stale error items from a previous batch contaminate status', async () => {
2187
+ let callCount = 0;
2188
+ const resolvers: Array<() => void> = [];
2189
+ const handler = {
2190
+ name: 'BatchCmd',
2191
+ events: ['BatchDone', 'BatchFailed'],
2192
+ handle: async () => {
2193
+ callCount++;
2194
+ const thisCall = callCount;
2195
+ await new Promise<void>((resolve) => {
2196
+ resolvers.push(resolve);
2197
+ });
2198
+ if (thisCall <= 2) {
2199
+ return { type: 'BatchFailed', data: {} };
2200
+ }
2201
+ return { type: 'BatchDone', data: {} };
2202
+ },
2203
+ };
2204
+ const pipeline = define('test').on('Trigger').emit('BatchCmd', {}).build();
2205
+ const server = new PipelineServer({ port: 0 });
2206
+ server.registerCommandHandlers([handler]);
2207
+ server.registerPipeline(pipeline);
2208
+ server.registerItemKeyExtractor('BatchCmd', (d) => (d as { id?: string }).id);
2209
+ await server.start();
2210
+
2211
+ void fetch(`http://localhost:${server.port}/command`, {
2212
+ method: 'POST',
2213
+ headers: { 'Content-Type': 'application/json' },
2214
+ body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-a' } }),
2215
+ });
2216
+ void fetch(`http://localhost:${server.port}/command`, {
2217
+ method: 'POST',
2218
+ headers: { 'Content-Type': 'application/json' },
2219
+ body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-b' } }),
2220
+ });
2221
+
2222
+ while (resolvers.length < 2) {
2223
+ await new Promise((r) => setTimeout(r, 10));
2224
+ }
2225
+ resolvers.forEach((r) => r());
2226
+ await new Promise((r) => setTimeout(r, 100));
2227
+
2228
+ const firstBatch = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
2229
+ const firstNode = firstBatch.nodes.find((n) => n.id === 'cmd:BatchCmd');
2230
+ expect(firstNode?.status).toBe('error');
2231
+ expect(firstNode?.endedCount).toBe(2);
2232
+
2233
+ void fetch(`http://localhost:${server.port}/command`, {
2234
+ method: 'POST',
2235
+ headers: { 'Content-Type': 'application/json' },
2236
+ body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-c' } }),
2237
+ });
2238
+ void fetch(`http://localhost:${server.port}/command`, {
2239
+ method: 'POST',
2240
+ headers: { 'Content-Type': 'application/json' },
2241
+ body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-d' } }),
2242
+ });
2243
+
2244
+ while (resolvers.length < 4) {
2245
+ await new Promise((r) => setTimeout(r, 10));
2246
+ }
2247
+ resolvers.slice(2).forEach((r) => r());
2248
+ await new Promise((r) => setTimeout(r, 100));
2249
+
2250
+ const secondBatch = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
2251
+ const secondNode = secondBatch.nodes.find((n) => n.id === 'cmd:BatchCmd');
2252
+ expect(secondNode?.status).toBe('success');
2253
+ expect(secondNode?.endedCount).toBe(2);
2254
+
2255
+ await server.stop();
2256
+ });
2171
2257
  });
2172
2258
 
2173
2259
  describe('auto-derived item key extractors', () => {
@@ -76,6 +76,9 @@ export class PipelineServer {
76
76
  private currentSessionId = '';
77
77
  private readonly quiescenceTracker: QuiescenceTracker;
78
78
  private readonly requestIdToSourceEvent = new Map<string, string>();
79
+ private readonly settledRequestIds = new Set<string>();
80
+ private readonly currentBatchIds = new Map<string, string>();
81
+ private readonly batchPendingCounts = new Map<string, number>();
79
82
 
80
83
  constructor(config: PipelineServerConfig) {
81
84
  this.storeFileName = config.storeFileName;
@@ -666,6 +669,7 @@ export class PipelineServer {
666
669
  requestId: string,
667
670
  status: 'running' | 'success' | 'error',
668
671
  attemptCount: number,
672
+ batchId?: string,
669
673
  ): Promise<void> {
670
674
  await this.eventStoreContext.eventStore.appendToStream(`pipeline-${correlationId}`, [
671
675
  {
@@ -678,6 +682,7 @@ export class PipelineServer {
678
682
  status,
679
683
  attemptCount,
680
684
  timestamp: new Date().toISOString(),
685
+ ...(batchId !== undefined && { batchId }),
681
686
  },
682
687
  },
683
688
  ]);
@@ -828,6 +833,30 @@ export class PipelineServer {
828
833
  this.sseManager.broadcast(event);
829
834
  }
830
835
 
836
+ private resolveBatchId(correlationId: string, commandType: string, requestId: string): string {
837
+ const key = `${correlationId}:${commandType}`;
838
+ const isFromSettled = this.settledRequestIds.delete(requestId);
839
+
840
+ if (isFromSettled) {
841
+ const existing = this.currentBatchIds.get(key);
842
+ if (existing) return existing;
843
+ } else {
844
+ const pending = this.batchPendingCounts.get(key) ?? 0;
845
+ if (pending === 0) {
846
+ const batchId = new Date().toISOString();
847
+ this.currentBatchIds.set(key, batchId);
848
+ return batchId;
849
+ }
850
+ }
851
+
852
+ const existing = this.currentBatchIds.get(key);
853
+ if (existing) return existing;
854
+
855
+ const batchId = new Date().toISOString();
856
+ this.currentBatchIds.set(key, batchId);
857
+ return batchId;
858
+ }
859
+
831
860
  private extractItemKey(commandType: string, data: unknown, requestId: string): string {
832
861
  const extractor = this.itemKeyExtractors.get(commandType);
833
862
  if (extractor !== undefined) {
@@ -842,11 +871,12 @@ export class PipelineServer {
842
871
  commandType: string,
843
872
  itemKey: string,
844
873
  requestId: string,
874
+ batchId?: string,
845
875
  ): Promise<{ attemptCount: number }> {
846
876
  const existing = await this.eventStoreContext.readModel.getItemStatus(correlationId, commandType, itemKey);
847
877
  const attemptCount = (existing?.attemptCount ?? 0) + 1;
848
878
 
849
- await this.emitItemStatusChanged(correlationId, commandType, itemKey, requestId, 'running', attemptCount);
879
+ await this.emitItemStatusChanged(correlationId, commandType, itemKey, requestId, 'running', attemptCount, batchId);
850
880
 
851
881
  return { attemptCount };
852
882
  }
@@ -1171,8 +1201,12 @@ export class PipelineServer {
1171
1201
  handler: CommandHandlerWithMetadata,
1172
1202
  signal: AbortSignal,
1173
1203
  ): Promise<void> {
1204
+ const batchKey = `${this.currentSessionId}:${command.type}`;
1205
+ const batchId = this.resolveBatchId(this.currentSessionId, command.type, command.requestId);
1206
+ this.batchPendingCounts.set(batchKey, (this.batchPendingCounts.get(batchKey) ?? 0) + 1);
1207
+
1174
1208
  const itemKey = this.extractItemKey(command.type, command.data, command.requestId);
1175
- await this.getOrCreateItemStatus(this.currentSessionId, command.type, itemKey, command.requestId);
1209
+ await this.getOrCreateItemStatus(this.currentSessionId, command.type, itemKey, command.requestId, batchId);
1176
1210
 
1177
1211
  await this.updateNodeStatus(this.currentSessionId, command.type, 'running');
1178
1212
  const sourceEventType = this.requestIdToSourceEvent.get(command.requestId);
@@ -1202,10 +1236,14 @@ export class PipelineServer {
1202
1236
  ];
1203
1237
  }
1204
1238
 
1205
- if (signal.aborted) return;
1239
+ if (signal.aborted) {
1240
+ this.batchPendingCounts.set(batchKey, (this.batchPendingCounts.get(batchKey) ?? 1) - 1);
1241
+ return;
1242
+ }
1206
1243
 
1207
1244
  const finalStatus = this.getStatusFromEvents(events);
1208
1245
  await this.updateItemStatus(this.currentSessionId, command.type, itemKey, finalStatus);
1246
+ this.batchPendingCounts.set(batchKey, (this.batchPendingCounts.get(batchKey) ?? 1) - 1);
1209
1247
  const completedItem = await this.eventStoreContext.readModel.getItemStatus(
1210
1248
  this.currentSessionId,
1211
1249
  command.type,
@@ -1255,9 +1293,10 @@ export class PipelineServer {
1255
1293
  return 'success';
1256
1294
  }
1257
1295
 
1258
- /* v8 ignore next 11 - integration callback tested via v2-runtime-bridge.specs.ts */
1296
+ /* v8 ignore next 12 - integration callback tested via v2-runtime-bridge.specs.ts */
1259
1297
  private async dispatchFromSettled(commandType: string, data: unknown, correlationId: string): Promise<void> {
1260
1298
  const requestId = `req-${nanoid()}`;
1299
+ this.settledRequestIds.add(requestId);
1261
1300
  const command: Command & { correlationId: string; requestId: string } = {
1262
1301
  type: commandType,
1263
1302
  data: data as Record<string, unknown>,
@@ -277,6 +277,88 @@ describe('PipelineReadModel', () => {
277
277
  });
278
278
  });
279
279
 
280
+ it('should scope to latest batch and ignore stale errors from previous batches', async () => {
281
+ const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
282
+ await collection.insertOne({
283
+ _id: 'c1-Cmd-old-a',
284
+ correlationId: 'c1',
285
+ commandType: 'Cmd',
286
+ itemKey: 'old-a',
287
+ currentRequestId: 'r1',
288
+ status: 'error',
289
+ attemptCount: 1,
290
+ batchId: '2025-01-01T00:00:00.000Z',
291
+ });
292
+ await collection.insertOne({
293
+ _id: 'c1-Cmd-old-b',
294
+ correlationId: 'c1',
295
+ commandType: 'Cmd',
296
+ itemKey: 'old-b',
297
+ currentRequestId: 'r2',
298
+ status: 'error',
299
+ attemptCount: 1,
300
+ batchId: '2025-01-01T00:00:00.000Z',
301
+ });
302
+ await collection.insertOne({
303
+ _id: 'c1-Cmd-new-a',
304
+ correlationId: 'c1',
305
+ commandType: 'Cmd',
306
+ itemKey: 'new-a',
307
+ currentRequestId: 'r3',
308
+ status: 'success',
309
+ attemptCount: 1,
310
+ batchId: '2025-01-01T00:01:00.000Z',
311
+ });
312
+ await collection.insertOne({
313
+ _id: 'c1-Cmd-new-b',
314
+ correlationId: 'c1',
315
+ commandType: 'Cmd',
316
+ itemKey: 'new-b',
317
+ currentRequestId: 'r4',
318
+ status: 'success',
319
+ attemptCount: 1,
320
+ batchId: '2025-01-01T00:01:00.000Z',
321
+ });
322
+
323
+ const result = await readModel.computeCommandStats('c1', 'Cmd');
324
+
325
+ expect(result).toEqual({
326
+ pendingCount: 0,
327
+ endedCount: 2,
328
+ aggregateStatus: 'success',
329
+ });
330
+ });
331
+
332
+ it('should use all items when no batchId exists for backward compatibility', async () => {
333
+ const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
334
+ await collection.insertOne({
335
+ _id: 'c1-Cmd-a',
336
+ correlationId: 'c1',
337
+ commandType: 'Cmd',
338
+ itemKey: 'a',
339
+ currentRequestId: 'r1',
340
+ status: 'success',
341
+ attemptCount: 1,
342
+ });
343
+ await collection.insertOne({
344
+ _id: 'c1-Cmd-b',
345
+ correlationId: 'c1',
346
+ commandType: 'Cmd',
347
+ itemKey: 'b',
348
+ currentRequestId: 'r2',
349
+ status: 'error',
350
+ attemptCount: 1,
351
+ });
352
+
353
+ const result = await readModel.computeCommandStats('c1', 'Cmd');
354
+
355
+ expect(result).toEqual({
356
+ pendingCount: 0,
357
+ endedCount: 2,
358
+ aggregateStatus: 'error',
359
+ });
360
+ });
361
+
280
362
  it('documents behavior: returns error when stale error items exist (BUG: requestId-based itemKey needs proper extractor)', async () => {
281
363
  const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
282
364
  await collection.insertOne({
@@ -50,11 +50,18 @@ export class PipelineReadModel {
50
50
  return { pendingCount: 0, endedCount: 0, aggregateStatus: 'idle' };
51
51
  }
52
52
 
53
+ const latestBatchId = items.reduce<string | undefined>((latest, item) => {
54
+ if (item.batchId === undefined) return latest;
55
+ if (latest === undefined || item.batchId > latest) return item.batchId;
56
+ return latest;
57
+ }, undefined);
58
+ const currentItems = latestBatchId !== undefined ? items.filter((item) => item.batchId === latestBatchId) : items;
59
+
53
60
  let pendingCount = 0;
54
61
  let endedCount = 0;
55
62
  let hasError = false;
56
63
 
57
- for (const item of items) {
64
+ for (const item of currentItems) {
58
65
  if (item.status === 'running') {
59
66
  pendingCount++;
60
67
  } else {
@@ -117,6 +124,7 @@ export class PipelineReadModel {
117
124
  attemptCount: item.attemptCount,
118
125
  startedAt: item.startedAt,
119
126
  endedAt: item.endedAt,
127
+ batchId: item.batchId,
120
128
  };
121
129
  }
122
130
 
@@ -131,6 +139,7 @@ export class PipelineReadModel {
131
139
  attemptCount: item.attemptCount,
132
140
  startedAt: item.startedAt,
133
141
  endedAt: item.endedAt,
142
+ batchId: item.batchId,
134
143
  }));
135
144
  }
136
145