@auto-engineer/pipeline 1.128.2 → 1.129.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.
@@ -0,0 +1,10 @@
1
+ # Ketchup Plan: Fix Pipeline Diagram Wrong Status for Retried Commands
2
+
3
+ ## TODO
4
+
5
+ ## DONE
6
+
7
+ - [x] Burst 1: Fix `addStatusToCommandNode` to use `nodeStatus.status` when no extractor exists and pendingCount is 0 [depends: none] (cf3f1034)
8
+ - [x] Burst 2: Add `autoRegisterItemKeyExtractor` and call from `registerCommandHandlers` [depends: Burst 1] (0bd4e2a7)
9
+ - [x] Burst 3: Covered by Bursts 1+2 tests [depends: Burst 2]
10
+ - [x] Burst 4: Test manual extractor takes precedence over auto-derived [depends: Burst 2]
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.128.2",
19
- "@auto-engineer/message-bus": "1.128.2"
18
+ "@auto-engineer/file-store": "1.129.0",
19
+ "@auto-engineer/message-bus": "1.129.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.128.2",
28
+ "version": "1.129.0",
29
29
  "scripts": {
30
30
  "build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
31
31
  "test": "vitest run --reporter=dot",
@@ -2049,7 +2049,7 @@ describe('PipelineServer', () => {
2049
2049
  await server.stop();
2050
2050
  });
2051
2051
 
2052
- it('documents behavior: status remains error after retry without itemKey extractor (fix: register extractor)', async () => {
2052
+ it('should show success status after retry succeeds even without itemKey extractor', async () => {
2053
2053
  let callCount = 0;
2054
2054
  const handler = {
2055
2055
  name: 'RetryNoExtractor',
@@ -2076,7 +2076,16 @@ describe('PipelineServer', () => {
2076
2076
  await new Promise((r) => setTimeout(r, 50));
2077
2077
 
2078
2078
  const afterFailure = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
2079
- expect(afterFailure.nodes.find((n) => n.id === 'cmd:RetryNoExtractor')?.status).toBe('error');
2079
+ const failNode = afterFailure.nodes.find((n) => n.id === 'cmd:RetryNoExtractor');
2080
+ expect(failNode).toEqual({
2081
+ id: 'cmd:RetryNoExtractor',
2082
+ type: 'command',
2083
+ label: 'RetryNoExtractor',
2084
+ status: 'error',
2085
+ pendingCount: 0,
2086
+ endedCount: 1,
2087
+ lastDurationMs: expect.any(Number),
2088
+ });
2080
2089
 
2081
2090
  await fetch(`http://localhost:${server.port}/command`, {
2082
2091
  method: 'POST',
@@ -2086,9 +2095,173 @@ describe('PipelineServer', () => {
2086
2095
  await new Promise((r) => setTimeout(r, 50));
2087
2096
 
2088
2097
  const afterRetry = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
2089
- const node = afterRetry.nodes.find((n) => n.id === 'cmd:RetryNoExtractor');
2090
- expect(node?.status).toBe('error');
2091
- expect(node?.endedCount).toBe(2);
2098
+ const retryNode = afterRetry.nodes.find((n) => n.id === 'cmd:RetryNoExtractor');
2099
+ expect(retryNode).toEqual({
2100
+ id: 'cmd:RetryNoExtractor',
2101
+ type: 'command',
2102
+ label: 'RetryNoExtractor',
2103
+ status: 'success',
2104
+ pendingCount: 0,
2105
+ endedCount: 2,
2106
+ lastDurationMs: expect.any(Number),
2107
+ });
2108
+
2109
+ await server.stop();
2110
+ });
2111
+
2112
+ it('should show running status via nodeStatus fallback when command is in-flight without extractor', async () => {
2113
+ let resolveHandler: () => void = () => {};
2114
+ const handlerPromise = new Promise<void>((resolve) => {
2115
+ resolveHandler = resolve;
2116
+ });
2117
+ const handler = {
2118
+ name: 'SlowNoExtractor',
2119
+ events: ['SlowNoExtractorDone'],
2120
+ handle: async () => {
2121
+ await handlerPromise;
2122
+ return { type: 'SlowNoExtractorDone', data: {} };
2123
+ },
2124
+ };
2125
+ const pipeline = define('test').on('Trigger').emit('SlowNoExtractor', {}).build();
2126
+ const server = new PipelineServer({ port: 0 });
2127
+ server.registerCommandHandlers([handler]);
2128
+ server.registerPipeline(pipeline);
2129
+ await server.start();
2130
+
2131
+ await fetch(`http://localhost:${server.port}/command`, {
2132
+ method: 'POST',
2133
+ headers: { 'Content-Type': 'application/json' },
2134
+ body: JSON.stringify({ type: 'SlowNoExtractor', data: {} }),
2135
+ });
2136
+
2137
+ await new Promise((r) => setTimeout(r, 50));
2138
+
2139
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
2140
+ const cmdNode = data.nodes.find((n) => n.id === 'cmd:SlowNoExtractor');
2141
+ expect(cmdNode).toEqual({
2142
+ id: 'cmd:SlowNoExtractor',
2143
+ type: 'command',
2144
+ label: 'SlowNoExtractor',
2145
+ status: 'running',
2146
+ pendingCount: 1,
2147
+ endedCount: 0,
2148
+ });
2149
+
2150
+ resolveHandler();
2151
+ await server.stop();
2152
+ });
2153
+ });
2154
+
2155
+ describe('auto-derived item key extractors', () => {
2156
+ it('should deduplicate retried items via auto-derived extractor showing success after retry', async () => {
2157
+ let callCount = 0;
2158
+ const handler = {
2159
+ name: 'AutoKeyCmd',
2160
+ events: ['AutoKeyDone', 'AutoKeyFailed'],
2161
+ fields: {
2162
+ targetDirectory: { type: 'string', required: true },
2163
+ },
2164
+ handle: async () => {
2165
+ callCount++;
2166
+ if (callCount === 1) {
2167
+ return { type: 'AutoKeyFailed', data: {} };
2168
+ }
2169
+ return { type: 'AutoKeyDone', data: {} };
2170
+ },
2171
+ };
2172
+ const pipeline = define('test').on('Trigger').emit('AutoKeyCmd', {}).build();
2173
+ const server = new PipelineServer({ port: 0 });
2174
+ server.registerCommandHandlers([handler]);
2175
+ server.registerPipeline(pipeline);
2176
+ await server.start();
2177
+
2178
+ await fetch(`http://localhost:${server.port}/command`, {
2179
+ method: 'POST',
2180
+ headers: { 'Content-Type': 'application/json' },
2181
+ body: JSON.stringify({ type: 'AutoKeyCmd', data: { targetDirectory: '/slice1' } }),
2182
+ });
2183
+ await new Promise((r) => setTimeout(r, 50));
2184
+
2185
+ await fetch(`http://localhost:${server.port}/command`, {
2186
+ method: 'POST',
2187
+ headers: { 'Content-Type': 'application/json' },
2188
+ body: JSON.stringify({ type: 'AutoKeyCmd', data: { targetDirectory: '/slice1' } }),
2189
+ });
2190
+ await new Promise((r) => setTimeout(r, 50));
2191
+
2192
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
2193
+ const node = data.nodes.find((n) => n.id === 'cmd:AutoKeyCmd');
2194
+ expect(node).toEqual({
2195
+ id: 'cmd:AutoKeyCmd',
2196
+ type: 'command',
2197
+ label: 'AutoKeyCmd',
2198
+ status: 'success',
2199
+ pendingCount: 0,
2200
+ endedCount: 1,
2201
+ lastDurationMs: expect.any(Number),
2202
+ });
2203
+
2204
+ await server.stop();
2205
+ });
2206
+
2207
+ it('should preserve manual extractor when handler also has fields', async () => {
2208
+ let callCount = 0;
2209
+ const handler = {
2210
+ name: 'ManualKeyCmd',
2211
+ events: ['ManualKeyDone', 'ManualKeyFailed'],
2212
+ fields: {
2213
+ targetDirectory: { type: 'string', required: true },
2214
+ customId: { type: 'string', required: false },
2215
+ },
2216
+ handle: async () => {
2217
+ callCount++;
2218
+ if (callCount === 1) {
2219
+ return { type: 'ManualKeyFailed', data: {} };
2220
+ }
2221
+ return { type: 'ManualKeyDone', data: {} };
2222
+ },
2223
+ };
2224
+ const pipeline = define('test').on('Trigger').emit('ManualKeyCmd', {}).build();
2225
+ const server = new PipelineServer({ port: 0 });
2226
+ server.registerItemKeyExtractor('ManualKeyCmd', (d) => {
2227
+ const data = d as Record<string, string>;
2228
+ return data['customId'];
2229
+ });
2230
+ server.registerCommandHandlers([handler]);
2231
+ server.registerPipeline(pipeline);
2232
+ await server.start();
2233
+
2234
+ await fetch(`http://localhost:${server.port}/command`, {
2235
+ method: 'POST',
2236
+ headers: { 'Content-Type': 'application/json' },
2237
+ body: JSON.stringify({
2238
+ type: 'ManualKeyCmd',
2239
+ data: { targetDirectory: '/a', customId: 'my-key' },
2240
+ }),
2241
+ });
2242
+ await new Promise((r) => setTimeout(r, 50));
2243
+
2244
+ await fetch(`http://localhost:${server.port}/command`, {
2245
+ method: 'POST',
2246
+ headers: { 'Content-Type': 'application/json' },
2247
+ body: JSON.stringify({
2248
+ type: 'ManualKeyCmd',
2249
+ data: { targetDirectory: '/b', customId: 'my-key' },
2250
+ }),
2251
+ });
2252
+ await new Promise((r) => setTimeout(r, 50));
2253
+
2254
+ const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
2255
+ const node = data.nodes.find((n) => n.id === 'cmd:ManualKeyCmd');
2256
+ expect(node).toEqual({
2257
+ id: 'cmd:ManualKeyCmd',
2258
+ type: 'command',
2259
+ label: 'ManualKeyCmd',
2260
+ status: 'success',
2261
+ pendingCount: 0,
2262
+ endedCount: 1,
2263
+ lastDurationMs: expect.any(Number),
2264
+ });
2092
2265
 
2093
2266
  await server.stop();
2094
2267
  });
@@ -131,9 +131,31 @@ export class PipelineServer {
131
131
  this.commandHandlers.set(handler.name, handler);
132
132
  this.messageBus.registerCommandHandler(handler);
133
133
  this.eventCommandMapper.addHandler(handler);
134
+ this.autoRegisterItemKeyExtractor(handler);
134
135
  }
135
136
  }
136
137
 
138
+ private autoRegisterItemKeyExtractor(handler: CommandHandlerWithMetadata): void {
139
+ if (this.itemKeyExtractors.has(handler.name)) return;
140
+ if (!handler.fields) return;
141
+ const requiredFieldNames: string[] = [];
142
+ for (const [name, def] of Object.entries(handler.fields)) {
143
+ if (typeof def !== 'object' || def === null) continue;
144
+ if (!('required' in def)) continue;
145
+ if (def.required !== true) continue;
146
+ requiredFieldNames.push(name);
147
+ }
148
+ if (requiredFieldNames.length === 0) return;
149
+ const fieldSet = new Set(requiredFieldNames);
150
+ this.registerItemKeyExtractor(handler.name, (data: unknown) => {
151
+ if (typeof data !== 'object' || data === null) return undefined;
152
+ for (const [key, value] of Object.entries(data)) {
153
+ if (fieldSet.has(key) && typeof value === 'string') return value;
154
+ }
155
+ return undefined;
156
+ });
157
+ }
158
+
137
159
  getRegisteredCommands(): string[] {
138
160
  return Array.from(this.commandHandlers.keys());
139
161
  }
@@ -598,9 +620,19 @@ export class PipelineServer {
598
620
  }
599
621
  const stats = await this.computeCommandStats(correlationId, commandName);
600
622
  const nodeStatus = await this.eventStoreContext.readModel.getNodeStatus(correlationId, commandName);
623
+
624
+ let status: NodeStatus;
625
+ if (stats.pendingCount > 0) {
626
+ status = 'running';
627
+ } else if (!this.itemKeyExtractors.has(commandName) && nodeStatus !== null) {
628
+ status = nodeStatus.status;
629
+ } else {
630
+ status = stats.aggregateStatus;
631
+ }
632
+
601
633
  return {
602
634
  ...node,
603
- status: stats.aggregateStatus,
635
+ status,
604
636
  pendingCount: stats.pendingCount,
605
637
  endedCount: stats.endedCount,
606
638
  lastDurationMs: nodeStatus?.lastDurationMs,