@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.
Files changed (46) hide show
  1. package/docs/http-api.md +66 -0
  2. package/package.json +1 -1
  3. package/src/api/__tests__/index.test.ts +311 -3
  4. package/src/api/index.ts +94 -45
  5. package/src/config/__tests__/statuses.test.ts +41 -0
  6. package/src/config/statuses.ts +20 -1
  7. package/src/core/__tests__/config.test.ts +31 -1
  8. package/src/core/__tests__/job-concurrency.test.ts +60 -0
  9. package/src/core/__tests__/job-view.test.ts +859 -0
  10. package/src/core/__tests__/orchestrator.test.ts +242 -0
  11. package/src/core/__tests__/runner-liveness.test.ts +865 -0
  12. package/src/core/__tests__/single-derivation.test.ts +159 -0
  13. package/src/core/config.ts +19 -0
  14. package/src/core/job-concurrency.ts +6 -1
  15. package/src/core/job-view.ts +329 -0
  16. package/src/core/orchestrator.ts +78 -6
  17. package/src/core/runner-liveness.ts +276 -0
  18. package/src/core/status-writer.ts +24 -5
  19. package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
  20. package/src/ui/client/adapters/job-adapter.ts +10 -0
  21. package/src/ui/client/types.ts +2 -0
  22. package/src/ui/components/JobTable.tsx +29 -12
  23. package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
  24. package/src/ui/components/types.ts +2 -0
  25. package/src/ui/dist/assets/{index--RH3sAt3.js → index-L6cvsCAx.js} +25 -12
  26. package/src/ui/dist/assets/{index--RH3sAt3.js.map → index-L6cvsCAx.js.map} +1 -1
  27. package/src/ui/dist/index.html +1 -1
  28. package/src/ui/embedded-assets.js +6 -6
  29. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  30. package/src/ui/server/__tests__/job-control-endpoints.test.ts +57 -1
  31. package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
  32. package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
  33. package/src/ui/server/config-bridge.ts +3 -4
  34. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  35. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  36. package/src/ui/server/endpoints/job-control-endpoints.ts +7 -68
  37. package/src/ui/server/endpoints/job-endpoints.ts +6 -0
  38. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  39. package/src/ui/state/__tests__/snapshot.test.ts +51 -0
  40. package/src/ui/state/__tests__/types.test.ts +98 -5
  41. package/src/ui/state/snapshot.ts +1 -1
  42. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
  43. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
  44. package/src/ui/state/transformers/list-transformer.ts +7 -3
  45. package/src/ui/state/transformers/status-transformer.ts +36 -170
  46. 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
+ });