@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.3
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/docs/http-api.md +66 -0
- package/package.json +1 -1
- package/src/api/__tests__/index.test.ts +311 -3
- package/src/api/index.ts +94 -45
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/config.test.ts +31 -1
- package/src/core/__tests__/job-concurrency.test.ts +60 -0
- package/src/core/__tests__/job-view.test.ts +859 -0
- package/src/core/__tests__/orchestrator.test.ts +242 -0
- package/src/core/__tests__/runner-liveness.test.ts +865 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/config.ts +19 -0
- package/src/core/job-concurrency.ts +6 -1
- package/src/core/job-view.ts +329 -0
- package/src/core/orchestrator.ts +78 -6
- package/src/core/runner-liveness.ts +276 -0
- package/src/core/status-writer.ts +24 -5
- package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
- package/src/ui/client/adapters/job-adapter.ts +10 -0
- package/src/ui/client/types.ts +2 -0
- package/src/ui/components/JobTable.tsx +29 -12
- package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-L6cvsCAx.js} +25 -12
- package/src/ui/dist/assets/{index--RH3sAt3.js.map → index-L6cvsCAx.js.map} +1 -1
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +57 -1
- package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
- package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +7 -68
- package/src/ui/server/endpoints/job-endpoints.ts +6 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/state/__tests__/snapshot.test.ts +51 -0
- package/src/ui/state/__tests__/types.test.ts +98 -5
- package/src/ui/state/snapshot.ts +1 -1
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
- package/src/ui/state/transformers/list-transformer.ts +7 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +9 -47
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { __resetWarnedFallbackKeys, normalizeJobView } from "../job-view";
|
|
3
|
+
import type { JobView } from "../job-view";
|
|
4
|
+
|
|
5
|
+
const JOB_ID = "job-abc-123";
|
|
6
|
+
const LOCATION = "current";
|
|
7
|
+
|
|
8
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">> | null = null;
|
|
9
|
+
|
|
10
|
+
function captureWarn(): ReturnType<typeof spyOn<Console, "warn">> {
|
|
11
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
12
|
+
return warnSpy;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function restoreWarn(): void {
|
|
16
|
+
warnSpy?.mockRestore();
|
|
17
|
+
warnSpy = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function callsContaining(spy: ReturnType<typeof spyOn<Console, "warn">>, fragment: string): number {
|
|
21
|
+
return spy.mock.calls.filter((call) => typeof call[0] === "string" && call[0].includes(fragment)).length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function warnCallsContaining(fragment: string): number {
|
|
25
|
+
return warnSpy ? callsContaining(warnSpy, fragment) : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
__resetWarnedFallbackKeys();
|
|
30
|
+
captureWarn();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
restoreWarn();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("normalizeJobView", () => {
|
|
38
|
+
describe("canonical output (AC-1)", () => {
|
|
39
|
+
test("never emits 'error' as a job status (persisted 'failed' maps to 'failed')", () => {
|
|
40
|
+
// Arrange / Act
|
|
41
|
+
const view: JobView = normalizeJobView(
|
|
42
|
+
{ state: "failed", tasks: { a: { state: "failed" } } },
|
|
43
|
+
JOB_ID,
|
|
44
|
+
LOCATION,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(view.status).toBe("failed");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("never emits 'done' as a job status (persisted 'done' maps to 'complete')", () => {
|
|
52
|
+
// Arrange / Act
|
|
53
|
+
const view: JobView = normalizeJobView(
|
|
54
|
+
{ state: "done", tasks: { a: { state: "done" } } },
|
|
55
|
+
JOB_ID,
|
|
56
|
+
LOCATION,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
60
|
+
expect(view.status).toBe("complete");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("always sets id, jobId, location, and a title", () => {
|
|
64
|
+
// Arrange / Act
|
|
65
|
+
const view: JobView = normalizeJobView(
|
|
66
|
+
{ name: "My Job", state: "pending" },
|
|
67
|
+
JOB_ID,
|
|
68
|
+
LOCATION,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(view.id).toBe(JOB_ID);
|
|
73
|
+
expect(view.jobId).toBe(JOB_ID);
|
|
74
|
+
expect(view.location).toBe(LOCATION);
|
|
75
|
+
expect(view.name).toBe("My Job");
|
|
76
|
+
expect(view.title).toBe("My Job");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("falls back to jobId for the title when name/title are missing", () => {
|
|
80
|
+
// Arrange / Act
|
|
81
|
+
const view: JobView = normalizeJobView({ state: "pending" }, JOB_ID, LOCATION);
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(view.name).toBe(JOB_ID);
|
|
85
|
+
expect(view.title).toBe(JOB_ID);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("persisted state authoritative (AC-2 precedence 1)", () => {
|
|
90
|
+
test("state:'pending' returns 'pending' even when raw.status says 'failed'", () => {
|
|
91
|
+
// Arrange / Act
|
|
92
|
+
const view: JobView = normalizeJobView(
|
|
93
|
+
{ state: "pending", status: "failed" },
|
|
94
|
+
JOB_ID,
|
|
95
|
+
LOCATION,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
expect(view.status).toBe("pending");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("state:'running' wins over a 'failed' raw.status", () => {
|
|
103
|
+
// Arrange / Act
|
|
104
|
+
const view: JobView = normalizeJobView(
|
|
105
|
+
{ state: "running", status: "failed" },
|
|
106
|
+
JOB_ID,
|
|
107
|
+
LOCATION,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
111
|
+
expect(view.status).toBe("running");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("state:'done' (persisted JobState) maps to 'complete'", () => {
|
|
115
|
+
// Arrange / Act
|
|
116
|
+
const view: JobView = normalizeJobView({ state: "done" }, JOB_ID, LOCATION);
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
119
|
+
expect(view.status).toBe("complete");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("state:'done' in current with pending tasks derives from tasks instead of hiding pending work", () => {
|
|
123
|
+
// Arrange / Act
|
|
124
|
+
const view: JobView = normalizeJobView(
|
|
125
|
+
{ state: "done", tasks: { a: { state: "done" }, b: { state: "pending" } } },
|
|
126
|
+
JOB_ID,
|
|
127
|
+
"current",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(view.status).toBe("pending");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("state:'done' in current with running tasks derives running status", () => {
|
|
135
|
+
// Arrange / Act
|
|
136
|
+
const view: JobView = normalizeJobView(
|
|
137
|
+
{ state: "done", tasks: { a: { state: "done" }, b: { state: "running" } } },
|
|
138
|
+
JOB_ID,
|
|
139
|
+
"current",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(view.status).toBe("running");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("state:'done' in complete location remains complete even when archived tasks disagree", () => {
|
|
147
|
+
// Arrange / Act
|
|
148
|
+
const view: JobView = normalizeJobView(
|
|
149
|
+
{ state: "done", tasks: { a: { state: "done" }, b: { state: "pending" } } },
|
|
150
|
+
JOB_ID,
|
|
151
|
+
"complete",
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(view.status).toBe("complete");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("state is case-insensitive: 'FAILED' → 'failed'", () => {
|
|
159
|
+
// Arrange / Act
|
|
160
|
+
const view: JobView = normalizeJobView({ state: "FAILED" }, JOB_ID, LOCATION);
|
|
161
|
+
|
|
162
|
+
// Assert
|
|
163
|
+
expect(view.status).toBe("failed");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("reconciled interrupted job regression (#315, AC-2)", () => {
|
|
168
|
+
test("state:'failed' + task failed with errorContext.kind='runner-interrupted' → status:'failed' and errorContext preserved", () => {
|
|
169
|
+
// Arrange / Act
|
|
170
|
+
const view: JobView = normalizeJobView(
|
|
171
|
+
{
|
|
172
|
+
state: "failed",
|
|
173
|
+
tasks: {
|
|
174
|
+
research: {
|
|
175
|
+
state: "failed",
|
|
176
|
+
error: "runner exited without completing this task",
|
|
177
|
+
errorContext: {
|
|
178
|
+
kind: "runner-interrupted",
|
|
179
|
+
code: 1,
|
|
180
|
+
signal: null,
|
|
181
|
+
source: "child-exit",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
JOB_ID,
|
|
187
|
+
LOCATION,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Assert
|
|
191
|
+
expect(view.status).toBe("failed");
|
|
192
|
+
expect(view.tasks.research?.errorContext).toEqual({
|
|
193
|
+
kind: "runner-interrupted",
|
|
194
|
+
code: 1,
|
|
195
|
+
signal: null,
|
|
196
|
+
source: "child-exit",
|
|
197
|
+
});
|
|
198
|
+
expect(view.tasks.research?.error).toEqual({ message: "runner exited without completing this task" });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("state:'failed' + job-level runner interruption preserves root errorContext", () => {
|
|
202
|
+
// Arrange / Act
|
|
203
|
+
const view: JobView = normalizeJobView(
|
|
204
|
+
{
|
|
205
|
+
state: "failed",
|
|
206
|
+
error: {
|
|
207
|
+
name: "RunnerInterrupted",
|
|
208
|
+
message: "runner exited while the job was still marked running",
|
|
209
|
+
},
|
|
210
|
+
errorContext: {
|
|
211
|
+
kind: "runner-interrupted",
|
|
212
|
+
code: 1,
|
|
213
|
+
signal: null,
|
|
214
|
+
source: "boot-sweep",
|
|
215
|
+
},
|
|
216
|
+
tasks: {
|
|
217
|
+
research: { state: "done", currentStage: null },
|
|
218
|
+
analysis: { state: "pending", currentStage: null },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
JOB_ID,
|
|
222
|
+
LOCATION,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
expect(view.status).toBe("failed");
|
|
227
|
+
expect(view.error).toEqual({
|
|
228
|
+
code: "RunnerInterrupted",
|
|
229
|
+
name: "RunnerInterrupted",
|
|
230
|
+
message: "runner exited while the job was still marked running",
|
|
231
|
+
});
|
|
232
|
+
expect(view.errorContext).toEqual({
|
|
233
|
+
kind: "runner-interrupted",
|
|
234
|
+
code: 1,
|
|
235
|
+
signal: null,
|
|
236
|
+
source: "boot-sweep",
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("waiting is never lost (AC-3)", () => {
|
|
242
|
+
test("state:'waiting' + all tasks done → 'waiting' (persisted state authoritative)", () => {
|
|
243
|
+
// Arrange / Act
|
|
244
|
+
const view: JobView = normalizeJobView(
|
|
245
|
+
{ state: "waiting", tasks: { a: { state: "done" }, b: { state: "done" } } },
|
|
246
|
+
JOB_ID,
|
|
247
|
+
LOCATION,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Assert
|
|
251
|
+
expect(view.status).toBe("waiting");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("state:'waiting' wins over a 'failed' raw.status", () => {
|
|
255
|
+
// Arrange / Act
|
|
256
|
+
const view: JobView = normalizeJobView(
|
|
257
|
+
{ state: "waiting", status: "failed" },
|
|
258
|
+
JOB_ID,
|
|
259
|
+
LOCATION,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
263
|
+
expect(view.status).toBe("waiting");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("raw.status fallback (AC-2 precedence 2)", () => {
|
|
268
|
+
test("state absent + status:'running' (no tasks) → 'running'", () => {
|
|
269
|
+
// Arrange / Act
|
|
270
|
+
const view: JobView = normalizeJobView({ status: "running" }, JOB_ID, LOCATION);
|
|
271
|
+
|
|
272
|
+
// Assert
|
|
273
|
+
expect(view.status).toBe("running");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("state absent + status:'complete' (no tasks) → 'complete'", () => {
|
|
277
|
+
// Arrange / Act
|
|
278
|
+
const view: JobView = normalizeJobView({ status: "complete" }, JOB_ID, LOCATION);
|
|
279
|
+
|
|
280
|
+
// Assert
|
|
281
|
+
expect(view.status).toBe("complete");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("state absent + status:'failed' (no tasks) → 'failed'", () => {
|
|
285
|
+
// Arrange / Act
|
|
286
|
+
const view: JobView = normalizeJobView({ status: "failed" }, JOB_ID, LOCATION);
|
|
287
|
+
|
|
288
|
+
// Assert
|
|
289
|
+
expect(view.status).toBe("failed");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("state absent + status:'error' (legacy synonym) → 'failed'", () => {
|
|
293
|
+
// Arrange / Act
|
|
294
|
+
const view: JobView = normalizeJobView({ status: "error" }, JOB_ID, LOCATION);
|
|
295
|
+
|
|
296
|
+
// Assert
|
|
297
|
+
expect(view.status).toBe("failed");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("state absent + status:'done' (legacy synonym) → 'complete'", () => {
|
|
301
|
+
// Arrange / Act
|
|
302
|
+
const view: JobView = normalizeJobView({ status: "done" }, JOB_ID, LOCATION);
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
305
|
+
expect(view.status).toBe("complete");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("status case-insensitive: 'ERROR' → 'failed'", () => {
|
|
309
|
+
// Arrange / Act
|
|
310
|
+
const view: JobView = normalizeJobView({ status: "ERROR" }, JOB_ID, LOCATION);
|
|
311
|
+
|
|
312
|
+
// Assert
|
|
313
|
+
expect(view.status).toBe("failed");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("state unrecognized + status:'error' → 'failed'", () => {
|
|
317
|
+
// Arrange / Act
|
|
318
|
+
const view: JobView = normalizeJobView(
|
|
319
|
+
{ state: "bogus-state", status: "error" },
|
|
320
|
+
JOB_ID,
|
|
321
|
+
LOCATION,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Assert
|
|
325
|
+
expect(view.status).toBe("failed");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("task-derived fallback (AC-2 precedence 3)", () => {
|
|
330
|
+
test("state absent + no status + all tasks done → 'complete' and warns once", () => {
|
|
331
|
+
// Arrange
|
|
332
|
+
const raw = { tasks: { a: { state: "done" }, b: { state: "done" } } };
|
|
333
|
+
|
|
334
|
+
// Act
|
|
335
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
336
|
+
|
|
337
|
+
// Assert
|
|
338
|
+
expect(view.status).toBe("complete");
|
|
339
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("state absent + no status + tasks failed + done → 'failed' (failed priority) and warns", () => {
|
|
343
|
+
// Arrange
|
|
344
|
+
const raw = { tasks: { a: { state: "failed" }, b: { state: "done" } } };
|
|
345
|
+
|
|
346
|
+
// Act
|
|
347
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
348
|
+
|
|
349
|
+
// Assert
|
|
350
|
+
expect(view.status).toBe("failed");
|
|
351
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("state absent + no status + tasks running + done → 'running' and warns", () => {
|
|
355
|
+
// Arrange
|
|
356
|
+
const raw = { tasks: { a: { state: "running" }, b: { state: "done" } } };
|
|
357
|
+
|
|
358
|
+
// Act
|
|
359
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
360
|
+
|
|
361
|
+
// Assert
|
|
362
|
+
expect(view.status).toBe("running");
|
|
363
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("state absent + no status + unrecognized status falls through to derivation", () => {
|
|
367
|
+
// Arrange
|
|
368
|
+
const raw = { status: "mystery-status", tasks: { a: { state: "done" } } };
|
|
369
|
+
|
|
370
|
+
// Act
|
|
371
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
372
|
+
|
|
373
|
+
// Assert
|
|
374
|
+
expect(view.status).toBe("complete");
|
|
375
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("task-derived fallback warns once across multiple calls for the same location:jobId", () => {
|
|
379
|
+
// Arrange
|
|
380
|
+
const raw = { tasks: { a: { state: "done" } } };
|
|
381
|
+
|
|
382
|
+
// Act
|
|
383
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
384
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
385
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
386
|
+
|
|
387
|
+
// Assert
|
|
388
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("task-derived fallback warns separately for different location:jobId keys", () => {
|
|
392
|
+
// Arrange
|
|
393
|
+
const raw = { tasks: { a: { state: "done" } } };
|
|
394
|
+
|
|
395
|
+
// Act
|
|
396
|
+
normalizeJobView(raw, "job-A", "current");
|
|
397
|
+
normalizeJobView(raw, "job-B", "current");
|
|
398
|
+
normalizeJobView(raw, "job-A", "complete");
|
|
399
|
+
|
|
400
|
+
// Assert
|
|
401
|
+
expect(warnCallsContaining("current:job-A")).toBe(1);
|
|
402
|
+
expect(warnCallsContaining("current:job-B")).toBe(1);
|
|
403
|
+
expect(warnCallsContaining("complete:job-A")).toBe(1);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("disagreement cases (AC-4)", () => {
|
|
408
|
+
test("state:'failed' + tasks all done → 'failed' (persisted state authoritative)", () => {
|
|
409
|
+
// Arrange / Act
|
|
410
|
+
const view: JobView = normalizeJobView(
|
|
411
|
+
{ state: "failed", tasks: { a: { state: "done" }, b: { state: "done" } } },
|
|
412
|
+
JOB_ID,
|
|
413
|
+
LOCATION,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
// Assert
|
|
417
|
+
expect(view.status).toBe("failed");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("state:'running' + tasks all done → 'running' (persisted state authoritative)", () => {
|
|
421
|
+
// Arrange / Act
|
|
422
|
+
const view: JobView = normalizeJobView(
|
|
423
|
+
{ state: "running", tasks: { a: { state: "done" }, b: { state: "done" } } },
|
|
424
|
+
JOB_ID,
|
|
425
|
+
LOCATION,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Assert
|
|
429
|
+
expect(view.status).toBe("running");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("state absent + tasks all done → 'complete'", () => {
|
|
433
|
+
// Arrange
|
|
434
|
+
const raw = { tasks: { a: { state: "done" }, b: { state: "done" } } };
|
|
435
|
+
|
|
436
|
+
// Act
|
|
437
|
+
const view: JobView = normalizeJobView(raw, JOB_ID, LOCATION);
|
|
438
|
+
|
|
439
|
+
// Assert
|
|
440
|
+
expect(view.status).toBe("complete");
|
|
441
|
+
expect(warnCallsContaining(`${LOCATION}:${JOB_ID}`)).toBe(1);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("state:'done' + tasks all done → 'complete' (agreement)", () => {
|
|
445
|
+
// Arrange / Act
|
|
446
|
+
const view: JobView = normalizeJobView(
|
|
447
|
+
{ state: "done", tasks: { a: { state: "done" } } },
|
|
448
|
+
JOB_ID,
|
|
449
|
+
LOCATION,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Assert
|
|
453
|
+
expect(view.status).toBe("complete");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("liberal input, canonical output (AC-5)", () => {
|
|
458
|
+
test("status:'error' (legacy) with no valid state → 'failed'", () => {
|
|
459
|
+
// Arrange / Act
|
|
460
|
+
const view: JobView = normalizeJobView({ status: "error" }, JOB_ID, LOCATION);
|
|
461
|
+
|
|
462
|
+
// Assert
|
|
463
|
+
expect(view.status).toBe("failed");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("state:'done' (persisted) → 'complete' and never 'done'", () => {
|
|
467
|
+
// Arrange / Act
|
|
468
|
+
const view: JobView = normalizeJobView({ state: "done" }, JOB_ID, LOCATION);
|
|
469
|
+
|
|
470
|
+
// Assert
|
|
471
|
+
expect(view.status).toBe("complete");
|
|
472
|
+
expect(view.status).not.toBe("done");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("status:'done' (legacy) → 'complete' and never 'done'", () => {
|
|
476
|
+
// Arrange / Act
|
|
477
|
+
const view: JobView = normalizeJobView({ status: "done" }, JOB_ID, LOCATION);
|
|
478
|
+
|
|
479
|
+
// Assert
|
|
480
|
+
expect(view.status).toBe("complete");
|
|
481
|
+
expect(view.status).not.toBe("done");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("case-insensitive: state:'DONE' → 'complete'", () => {
|
|
485
|
+
// Arrange / Act
|
|
486
|
+
const view: JobView = normalizeJobView({ state: "DONE" }, JOB_ID, LOCATION);
|
|
487
|
+
|
|
488
|
+
// Assert
|
|
489
|
+
expect(view.status).toBe("complete");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("case-insensitive: state:' failed ' → 'failed'", () => {
|
|
493
|
+
// Arrange / Act
|
|
494
|
+
const view: JobView = normalizeJobView({ state: " failed " }, JOB_ID, LOCATION);
|
|
495
|
+
|
|
496
|
+
// Assert
|
|
497
|
+
expect(view.status).toBe("failed");
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("field preservation", () => {
|
|
502
|
+
test("uses finite persisted progress when present", () => {
|
|
503
|
+
// Arrange / Act
|
|
504
|
+
const view: JobView = normalizeJobView(
|
|
505
|
+
{ state: "running", progress: 42, tasks: { a: { state: "done" }, b: { state: "running" } } },
|
|
506
|
+
JOB_ID,
|
|
507
|
+
LOCATION,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Assert
|
|
511
|
+
expect(view.progress).toBe(42);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("derives progress from completed tasks when persisted progress is missing", () => {
|
|
515
|
+
// Arrange / Act
|
|
516
|
+
const view: JobView = normalizeJobView(
|
|
517
|
+
{ state: "running", tasks: { a: { state: "done" }, b: { state: "skipped" }, c: { state: "pending" } } },
|
|
518
|
+
JOB_ID,
|
|
519
|
+
LOCATION,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
// Assert
|
|
523
|
+
expect(view.progress).toBe(66);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("derives progress when persisted progress is not finite", () => {
|
|
527
|
+
// Arrange / Act
|
|
528
|
+
const view: JobView = normalizeJobView(
|
|
529
|
+
{ state: "running", progress: Number.NaN, tasks: { a: { state: "done" }, b: { state: "pending" } } },
|
|
530
|
+
JOB_ID,
|
|
531
|
+
LOCATION,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// Assert
|
|
535
|
+
expect(view.progress).toBe(50);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("maps live snapshot lastUpdated to updatedAt", () => {
|
|
539
|
+
// Arrange / Act
|
|
540
|
+
const view: JobView = normalizeJobView(
|
|
541
|
+
{ state: "running", lastUpdated: "2026-06-19T12:00:00.000Z" },
|
|
542
|
+
JOB_ID,
|
|
543
|
+
LOCATION,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// Assert
|
|
547
|
+
expect(view.updatedAt).toBe("2026-06-19T12:00:00.000Z");
|
|
548
|
+
expect(view.lastUpdated).toBe("2026-06-19T12:00:00.000Z");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("prefers explicit updatedAt over lastUpdated", () => {
|
|
552
|
+
// Arrange / Act
|
|
553
|
+
const view: JobView = normalizeJobView(
|
|
554
|
+
{
|
|
555
|
+
state: "running",
|
|
556
|
+
updatedAt: "2026-06-19T12:05:00.000Z",
|
|
557
|
+
lastUpdated: "2026-06-19T12:00:00.000Z",
|
|
558
|
+
},
|
|
559
|
+
JOB_ID,
|
|
560
|
+
LOCATION,
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// Assert
|
|
564
|
+
expect(view.updatedAt).toBe("2026-06-19T12:05:00.000Z");
|
|
565
|
+
expect(view.lastUpdated).toBe("2026-06-19T12:00:00.000Z");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("preserves explicit top-level costs when present", () => {
|
|
569
|
+
// Arrange / Act
|
|
570
|
+
const view: JobView = normalizeJobView(
|
|
571
|
+
{ state: "done", costs: { totalCost: 7, totalTokens: 12 } },
|
|
572
|
+
JOB_ID,
|
|
573
|
+
LOCATION,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// Assert
|
|
577
|
+
expect(view.costs).toEqual({ totalCost: 7, totalTokens: 12 });
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("aggregates costs from task tokenUsage when top-level costs are absent", () => {
|
|
581
|
+
// Arrange / Act
|
|
582
|
+
const view: JobView = normalizeJobView(
|
|
583
|
+
{
|
|
584
|
+
state: "done",
|
|
585
|
+
tasks: {
|
|
586
|
+
alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25]] },
|
|
587
|
+
beta: { state: "done", tokenUsage: [["anthropic:claude", 7, 3, 0.5], ["bad"]] },
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
JOB_ID,
|
|
591
|
+
LOCATION,
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// Assert
|
|
595
|
+
expect(view.costs).toEqual({
|
|
596
|
+
totalCost: 0.75,
|
|
597
|
+
totalTokens: 25,
|
|
598
|
+
totalInputTokens: 17,
|
|
599
|
+
totalOutputTokens: 8,
|
|
600
|
+
});
|
|
601
|
+
expect(view.tasks.alpha?.tokenUsage).toEqual([["openai:gpt-4o", 10, 5, 0.25]]);
|
|
602
|
+
expect(view.tasks.beta?.tokenUsage).toEqual([
|
|
603
|
+
["anthropic:claude", 7, 3, 0.5],
|
|
604
|
+
["bad"],
|
|
605
|
+
]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("normalizes persisted string task errors to message objects", () => {
|
|
609
|
+
// Arrange / Act
|
|
610
|
+
const view: JobView = normalizeJobView(
|
|
611
|
+
{
|
|
612
|
+
state: "failed",
|
|
613
|
+
tasks: {
|
|
614
|
+
agent: { state: "failed", error: "Agent step failed" },
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
JOB_ID,
|
|
618
|
+
LOCATION,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
// Assert
|
|
622
|
+
expect(view.tasks.agent?.error).toEqual({ message: "Agent step failed" });
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("preserves readable:false when present", () => {
|
|
626
|
+
// Arrange / Act
|
|
627
|
+
const view: JobView = normalizeJobView(
|
|
628
|
+
{ state: "failed", readable: false },
|
|
629
|
+
JOB_ID,
|
|
630
|
+
LOCATION,
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
// Assert
|
|
634
|
+
expect(view.readable).toBe(false);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("preserves readable:true when present", () => {
|
|
638
|
+
// Arrange / Act
|
|
639
|
+
const view: JobView = normalizeJobView(
|
|
640
|
+
{ state: "done", readable: true },
|
|
641
|
+
JOB_ID,
|
|
642
|
+
LOCATION,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
// Assert
|
|
646
|
+
expect(view.readable).toBe(true);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("readable is undefined when absent (defaults to readable per #313)", () => {
|
|
650
|
+
// Arrange / Act
|
|
651
|
+
const view: JobView = normalizeJobView({ state: "done" }, JOB_ID, LOCATION);
|
|
652
|
+
|
|
653
|
+
// Assert
|
|
654
|
+
expect(view.readable).toBeUndefined();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("preserves error: { code, message, path } when well-formed", () => {
|
|
658
|
+
// Arrange / Act
|
|
659
|
+
const view: JobView = normalizeJobView(
|
|
660
|
+
{ readable: false, error: { code: "STATUS_CORRUPT", message: "bad json", path: "/tmp/x" } },
|
|
661
|
+
JOB_ID,
|
|
662
|
+
LOCATION,
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
// Assert
|
|
666
|
+
expect(view.error).toEqual({ code: "STATUS_CORRUPT", message: "bad json", path: "/tmp/x" });
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("preserves error without path", () => {
|
|
670
|
+
// Arrange / Act
|
|
671
|
+
const view: JobView = normalizeJobView(
|
|
672
|
+
{ readable: false, error: { code: "STATUS_CORRUPT", message: "bad json" } },
|
|
673
|
+
JOB_ID,
|
|
674
|
+
LOCATION,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// Assert
|
|
678
|
+
expect(view.error).toEqual({ code: "STATUS_CORRUPT", message: "bad json" });
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("normalizes gate rejection errors from name/message", () => {
|
|
682
|
+
// Arrange / Act
|
|
683
|
+
const view: JobView = normalizeJobView(
|
|
684
|
+
{ error: { name: "GateRejected", message: "needs another pass" } },
|
|
685
|
+
JOB_ID,
|
|
686
|
+
LOCATION,
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// Assert
|
|
690
|
+
expect(view.error).toEqual({
|
|
691
|
+
code: "GateRejected",
|
|
692
|
+
message: "needs another pass",
|
|
693
|
+
name: "GateRejected",
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("normalizes runner failures from name/message/stack", () => {
|
|
698
|
+
// Arrange / Act
|
|
699
|
+
const view: JobView = normalizeJobView(
|
|
700
|
+
{ error: { name: "Error", message: "boom", stack: "trace" } },
|
|
701
|
+
JOB_ID,
|
|
702
|
+
LOCATION,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
// Assert
|
|
706
|
+
expect(view.error).toEqual({
|
|
707
|
+
code: "Error",
|
|
708
|
+
message: "boom",
|
|
709
|
+
name: "Error",
|
|
710
|
+
stack: "trace",
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("uses a fallback code for message-only errors", () => {
|
|
715
|
+
// Arrange / Act
|
|
716
|
+
const view: JobView = normalizeJobView(
|
|
717
|
+
{ error: { code: 42, message: "bad" } },
|
|
718
|
+
JOB_ID,
|
|
719
|
+
LOCATION,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
// Assert
|
|
723
|
+
expect(view.error).toEqual({ code: "JOB_ERROR", message: "bad" });
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("drops error when message is not a string", () => {
|
|
727
|
+
// Arrange / Act
|
|
728
|
+
const view: JobView = normalizeJobView(
|
|
729
|
+
{ error: { code: "STATUS_CORRUPT", message: 42 } },
|
|
730
|
+
JOB_ID,
|
|
731
|
+
LOCATION,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// Assert
|
|
735
|
+
expect(view.error).toBeUndefined();
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe("task id synthesis (AC-8 server side)", () => {
|
|
740
|
+
test("array tasks with explicit name fields are keyed by name", () => {
|
|
741
|
+
// Arrange / Act
|
|
742
|
+
const view: JobView = normalizeJobView(
|
|
743
|
+
{ state: "done", tasks: [{ name: "alpha" }, { name: "beta" }] },
|
|
744
|
+
JOB_ID,
|
|
745
|
+
LOCATION,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
// Assert
|
|
749
|
+
expect(Object.keys(view.tasks)).toEqual(["alpha", "beta"]);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("array tasks with no name fall back to zero-based 'task-0', 'task-1'", () => {
|
|
753
|
+
// Arrange / Act
|
|
754
|
+
const view: JobView = normalizeJobView(
|
|
755
|
+
{ state: "done", tasks: [{}, {}] },
|
|
756
|
+
JOB_ID,
|
|
757
|
+
LOCATION,
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
// Assert
|
|
761
|
+
expect(Object.keys(view.tasks)).toEqual(["task-0", "task-1"]);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("array tasks with no name preserve order in the fallback", () => {
|
|
765
|
+
// Arrange / Act
|
|
766
|
+
const view: JobView = normalizeJobView(
|
|
767
|
+
{ state: "done", tasks: [{ state: "done" }, { state: "done" }, { state: "done" }] },
|
|
768
|
+
JOB_ID,
|
|
769
|
+
LOCATION,
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
// Assert
|
|
773
|
+
expect(Object.keys(view.tasks)).toEqual(["task-0", "task-1", "task-2"]);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("mixed array (named + unnamed) uses name where present, 'task-N' otherwise", () => {
|
|
777
|
+
// Arrange / Act
|
|
778
|
+
const view: JobView = normalizeJobView(
|
|
779
|
+
{ state: "done", tasks: [{ name: "alpha" }, {}] },
|
|
780
|
+
JOB_ID,
|
|
781
|
+
LOCATION,
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
// Assert
|
|
785
|
+
expect(Object.keys(view.tasks)).toEqual(["alpha", "task-1"]);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("empty-string name falls back to 'task-${index}'", () => {
|
|
789
|
+
// Arrange / Act
|
|
790
|
+
const view: JobView = normalizeJobView(
|
|
791
|
+
{ state: "done", tasks: [{ name: "" }] },
|
|
792
|
+
JOB_ID,
|
|
793
|
+
LOCATION,
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
// Assert
|
|
797
|
+
expect(Object.keys(view.tasks)).toEqual(["task-0"]);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("object tasks are keyed by their existing keys (pipeline order preserved)", () => {
|
|
801
|
+
// Arrange / Act
|
|
802
|
+
const view: JobView = normalizeJobView(
|
|
803
|
+
{ state: "done", tasks: { plan: { state: "done" }, review: { state: "done" } } },
|
|
804
|
+
JOB_ID,
|
|
805
|
+
LOCATION,
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
// Assert
|
|
809
|
+
expect(Object.keys(view.tasks)).toEqual(["plan", "review"]);
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
describe("robustness", () => {
|
|
814
|
+
test("non-object raw returns a JobView with empty tasks and pending status (no throw)", () => {
|
|
815
|
+
// Arrange / Act
|
|
816
|
+
const view: JobView = normalizeJobView("not an object", JOB_ID, LOCATION);
|
|
817
|
+
|
|
818
|
+
// Assert
|
|
819
|
+
expect(view.tasks).toEqual({});
|
|
820
|
+
expect(view.status).toBe("pending");
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test("null raw returns a JobView with empty tasks and pending status (no throw)", () => {
|
|
824
|
+
// Arrange / Act
|
|
825
|
+
const view: JobView = normalizeJobView(null, JOB_ID, LOCATION);
|
|
826
|
+
|
|
827
|
+
// Assert
|
|
828
|
+
expect(view.tasks).toEqual({});
|
|
829
|
+
expect(view.status).toBe("pending");
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("undefined raw returns a JobView with empty tasks and pending status (no throw)", () => {
|
|
833
|
+
// Arrange / Act
|
|
834
|
+
const view: JobView = normalizeJobView(undefined, JOB_ID, LOCATION);
|
|
835
|
+
|
|
836
|
+
// Assert
|
|
837
|
+
expect(view.tasks).toEqual({});
|
|
838
|
+
expect(view.status).toBe("pending");
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
describe("__resetWarnedFallbackKeys", () => {
|
|
844
|
+
test("clears the warn cache so a subsequent call warns again", () => {
|
|
845
|
+
// Arrange
|
|
846
|
+
const raw = { tasks: { a: { state: "done" } } };
|
|
847
|
+
|
|
848
|
+
// Act
|
|
849
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
850
|
+
const firstCalls = warnCallsContaining(`${LOCATION}:${JOB_ID}`);
|
|
851
|
+
__resetWarnedFallbackKeys();
|
|
852
|
+
normalizeJobView(raw, JOB_ID, LOCATION);
|
|
853
|
+
const secondCalls = warnCallsContaining(`${LOCATION}:${JOB_ID}`);
|
|
854
|
+
|
|
855
|
+
// Assert
|
|
856
|
+
expect(firstCalls).toBe(1);
|
|
857
|
+
expect(secondCalls).toBe(2);
|
|
858
|
+
});
|
|
859
|
+
});
|