@auto-engineer/pipeline 1.128.2 → 1.130.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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +5 -5
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +76 -0
- package/dist/src/server/pipeline-server.d.ts +1 -0
- package/dist/src/server/pipeline-server.d.ts.map +1 -1
- package/dist/src/server/pipeline-server.js +40 -1
- package/dist/src/server/pipeline-server.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +10 -0
- package/package.json +3 -3
- package/src/server/pipeline-server.specs.ts +178 -5
- package/src/server/pipeline-server.ts +33 -1
package/ketchup-plan.md
ADDED
|
@@ -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.
|
|
19
|
-
"@auto-engineer/message-bus": "1.
|
|
18
|
+
"@auto-engineer/file-store": "1.130.0",
|
|
19
|
+
"@auto-engineer/message-bus": "1.130.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.
|
|
28
|
+
"version": "1.130.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('
|
|
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
|
-
|
|
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
|
|
2090
|
-
expect(
|
|
2091
|
-
|
|
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
|
|
635
|
+
status,
|
|
604
636
|
pendingCount: stats.pendingCount,
|
|
605
637
|
endedCount: stats.endedCount,
|
|
606
638
|
lastDurationMs: nodeStatus?.lastDurationMs,
|