@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

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 (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -0,0 +1,1341 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { __resetWarnedFallbackKeys, deriveProgress, 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("ignores persisted progress and derives via deriveProgress (AC-7)", () => {
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(50);
512
+ });
513
+
514
+ test("derives progress with pipelineConfig task count when present", () => {
515
+ // Arrange / Act
516
+ const view: JobView = normalizeJobView(
517
+ {
518
+ state: "running",
519
+ progress: 99,
520
+ pipelineConfig: { tasks: ["a", "b", "c", "d"] },
521
+ tasks: {
522
+ a: { state: "done" },
523
+ b: { state: "running" },
524
+ },
525
+ },
526
+ JOB_ID,
527
+ LOCATION,
528
+ );
529
+
530
+ // Assert
531
+ expect(view.progress).toBe(25);
532
+ expect(view.pipelineConfig).toEqual({ tasks: ["a", "b", "c", "d"] });
533
+ });
534
+
535
+ test("derives progress from completed tasks when persisted progress is missing", () => {
536
+ // Arrange / Act
537
+ const view: JobView = normalizeJobView(
538
+ { state: "running", tasks: { a: { state: "done" }, b: { state: "skipped" }, c: { state: "pending" } } },
539
+ JOB_ID,
540
+ LOCATION,
541
+ );
542
+
543
+ // Assert
544
+ expect(view.progress).toBe(66);
545
+ });
546
+
547
+ test("derives progress when persisted progress is not finite", () => {
548
+ // Arrange / Act
549
+ const view: JobView = normalizeJobView(
550
+ { state: "running", progress: Number.NaN, tasks: { a: { state: "done" }, b: { state: "pending" } } },
551
+ JOB_ID,
552
+ LOCATION,
553
+ );
554
+
555
+ // Assert
556
+ expect(view.progress).toBe(50);
557
+ });
558
+
559
+ test("maps live snapshot lastUpdated to updatedAt", () => {
560
+ // Arrange / Act
561
+ const view: JobView = normalizeJobView(
562
+ { state: "running", lastUpdated: "2026-06-19T12:00:00.000Z" },
563
+ JOB_ID,
564
+ LOCATION,
565
+ );
566
+
567
+ // Assert
568
+ expect(view.updatedAt).toBe("2026-06-19T12:00:00.000Z");
569
+ expect(view.lastUpdated).toBe("2026-06-19T12:00:00.000Z");
570
+ });
571
+
572
+ test("prefers explicit updatedAt over lastUpdated", () => {
573
+ // Arrange / Act
574
+ const view: JobView = normalizeJobView(
575
+ {
576
+ state: "running",
577
+ updatedAt: "2026-06-19T12:05:00.000Z",
578
+ lastUpdated: "2026-06-19T12:00:00.000Z",
579
+ },
580
+ JOB_ID,
581
+ LOCATION,
582
+ );
583
+
584
+ // Assert
585
+ expect(view.updatedAt).toBe("2026-06-19T12:05:00.000Z");
586
+ expect(view.lastUpdated).toBe("2026-06-19T12:00:00.000Z");
587
+ });
588
+
589
+ test("preserves explicit top-level costs when present", () => {
590
+ // Arrange / Act
591
+ const view: JobView = normalizeJobView(
592
+ { state: "done", costs: { totalCost: 7, totalTokens: 12 } },
593
+ JOB_ID,
594
+ LOCATION,
595
+ );
596
+
597
+ // Assert
598
+ expect(view.costs).toEqual({ totalCost: 7, totalTokens: 12 });
599
+ });
600
+
601
+ test("aggregates costs from task tokenUsage when top-level costs are absent", () => {
602
+ // Arrange / Act
603
+ const view: JobView = normalizeJobView(
604
+ {
605
+ state: "done",
606
+ tasks: {
607
+ alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25]] },
608
+ beta: { state: "done", tokenUsage: [["anthropic:claude", 7, 3, 0.5], ["bad"]] },
609
+ },
610
+ },
611
+ JOB_ID,
612
+ LOCATION,
613
+ );
614
+
615
+ // Assert
616
+ expect(view.costs).toEqual({
617
+ totalCost: 0.75,
618
+ totalTokens: 25,
619
+ totalInputTokens: 17,
620
+ totalOutputTokens: 8,
621
+ estimatedCost: 0,
622
+ unavailableCost: 0,
623
+ hasEstimated: false,
624
+ alpha: {
625
+ inputTokens: 10,
626
+ outputTokens: 5,
627
+ inputCost: 0,
628
+ outputCost: 0,
629
+ totalCost: 0.25,
630
+ source: "reported",
631
+ },
632
+ beta: {
633
+ inputTokens: 7,
634
+ outputTokens: 3,
635
+ inputCost: 0,
636
+ outputCost: 0,
637
+ totalCost: 0.5,
638
+ source: "reported",
639
+ },
640
+ });
641
+ expect(view.tasks.alpha?.tokenUsage).toEqual([["openai:gpt-4o", 10, 5, 0.25]]);
642
+ expect(view.tasks.beta?.tokenUsage).toEqual([
643
+ ["anthropic:claude", 7, 3, 0.5],
644
+ ["bad"],
645
+ ]);
646
+ });
647
+
648
+ test("normalizes persisted string task errors to message objects", () => {
649
+ // Arrange / Act
650
+ const view: JobView = normalizeJobView(
651
+ {
652
+ state: "failed",
653
+ tasks: {
654
+ agent: { state: "failed", error: "Agent step failed" },
655
+ },
656
+ },
657
+ JOB_ID,
658
+ LOCATION,
659
+ );
660
+
661
+ // Assert
662
+ expect(view.tasks.agent?.error).toEqual({ message: "Agent step failed" });
663
+ });
664
+
665
+ test("preserves readable:false when present", () => {
666
+ // Arrange / Act
667
+ const view: JobView = normalizeJobView(
668
+ { state: "failed", readable: false },
669
+ JOB_ID,
670
+ LOCATION,
671
+ );
672
+
673
+ // Assert
674
+ expect(view.readable).toBe(false);
675
+ });
676
+
677
+ test("preserves readable:true when present", () => {
678
+ // Arrange / Act
679
+ const view: JobView = normalizeJobView(
680
+ { state: "done", readable: true },
681
+ JOB_ID,
682
+ LOCATION,
683
+ );
684
+
685
+ // Assert
686
+ expect(view.readable).toBe(true);
687
+ });
688
+
689
+ test("readable is undefined when absent (defaults to readable per #313)", () => {
690
+ // Arrange / Act
691
+ const view: JobView = normalizeJobView({ state: "done" }, JOB_ID, LOCATION);
692
+
693
+ // Assert
694
+ expect(view.readable).toBeUndefined();
695
+ });
696
+
697
+ test("preserves error: { code, message, path } when well-formed", () => {
698
+ // Arrange / Act
699
+ const view: JobView = normalizeJobView(
700
+ { readable: false, error: { code: "STATUS_CORRUPT", message: "bad json", path: "/tmp/x" } },
701
+ JOB_ID,
702
+ LOCATION,
703
+ );
704
+
705
+ // Assert
706
+ expect(view.error).toEqual({ code: "STATUS_CORRUPT", message: "bad json", path: "/tmp/x" });
707
+ });
708
+
709
+ test("preserves error without path", () => {
710
+ // Arrange / Act
711
+ const view: JobView = normalizeJobView(
712
+ { readable: false, error: { code: "STATUS_CORRUPT", message: "bad json" } },
713
+ JOB_ID,
714
+ LOCATION,
715
+ );
716
+
717
+ // Assert
718
+ expect(view.error).toEqual({ code: "STATUS_CORRUPT", message: "bad json" });
719
+ });
720
+
721
+ test("normalizes gate rejection errors from name/message", () => {
722
+ // Arrange / Act
723
+ const view: JobView = normalizeJobView(
724
+ { error: { name: "GateRejected", message: "needs another pass" } },
725
+ JOB_ID,
726
+ LOCATION,
727
+ );
728
+
729
+ // Assert
730
+ expect(view.error).toEqual({
731
+ code: "GateRejected",
732
+ message: "needs another pass",
733
+ name: "GateRejected",
734
+ });
735
+ });
736
+
737
+ test("normalizes runner failures from name/message/stack", () => {
738
+ // Arrange / Act
739
+ const view: JobView = normalizeJobView(
740
+ { error: { name: "Error", message: "boom", stack: "trace" } },
741
+ JOB_ID,
742
+ LOCATION,
743
+ );
744
+
745
+ // Assert
746
+ expect(view.error).toEqual({
747
+ code: "Error",
748
+ message: "boom",
749
+ name: "Error",
750
+ stack: "trace",
751
+ });
752
+ });
753
+
754
+ test("uses a fallback code for message-only errors", () => {
755
+ // Arrange / Act
756
+ const view: JobView = normalizeJobView(
757
+ { error: { code: 42, message: "bad" } },
758
+ JOB_ID,
759
+ LOCATION,
760
+ );
761
+
762
+ // Assert
763
+ expect(view.error).toEqual({ code: "JOB_ERROR", message: "bad" });
764
+ });
765
+
766
+ test("drops error when message is not a string", () => {
767
+ // Arrange / Act
768
+ const view: JobView = normalizeJobView(
769
+ { error: { code: "STATUS_CORRUPT", message: 42 } },
770
+ JOB_ID,
771
+ LOCATION,
772
+ );
773
+
774
+ // Assert
775
+ expect(view.error).toBeUndefined();
776
+ });
777
+ });
778
+
779
+ describe("task id synthesis (AC-8 server side)", () => {
780
+ test("array tasks with explicit name fields are keyed by name", () => {
781
+ // Arrange / Act
782
+ const view: JobView = normalizeJobView(
783
+ { state: "done", tasks: [{ name: "alpha" }, { name: "beta" }] },
784
+ JOB_ID,
785
+ LOCATION,
786
+ );
787
+
788
+ // Assert
789
+ expect(Object.keys(view.tasks)).toEqual(["alpha", "beta"]);
790
+ });
791
+
792
+ test("array tasks with no name fall back to zero-based 'task-0', 'task-1'", () => {
793
+ // Arrange / Act
794
+ const view: JobView = normalizeJobView(
795
+ { state: "done", tasks: [{}, {}] },
796
+ JOB_ID,
797
+ LOCATION,
798
+ );
799
+
800
+ // Assert
801
+ expect(Object.keys(view.tasks)).toEqual(["task-0", "task-1"]);
802
+ });
803
+
804
+ test("array tasks with no name preserve order in the fallback", () => {
805
+ // Arrange / Act
806
+ const view: JobView = normalizeJobView(
807
+ { state: "done", tasks: [{ state: "done" }, { state: "done" }, { state: "done" }] },
808
+ JOB_ID,
809
+ LOCATION,
810
+ );
811
+
812
+ // Assert
813
+ expect(Object.keys(view.tasks)).toEqual(["task-0", "task-1", "task-2"]);
814
+ });
815
+
816
+ test("mixed array (named + unnamed) uses name where present, 'task-N' otherwise", () => {
817
+ // Arrange / Act
818
+ const view: JobView = normalizeJobView(
819
+ { state: "done", tasks: [{ name: "alpha" }, {}] },
820
+ JOB_ID,
821
+ LOCATION,
822
+ );
823
+
824
+ // Assert
825
+ expect(Object.keys(view.tasks)).toEqual(["alpha", "task-1"]);
826
+ });
827
+
828
+ test("empty-string name falls back to 'task-${index}'", () => {
829
+ // Arrange / Act
830
+ const view: JobView = normalizeJobView(
831
+ { state: "done", tasks: [{ name: "" }] },
832
+ JOB_ID,
833
+ LOCATION,
834
+ );
835
+
836
+ // Assert
837
+ expect(Object.keys(view.tasks)).toEqual(["task-0"]);
838
+ });
839
+
840
+ test("object tasks are keyed by their existing keys (pipeline order preserved)", () => {
841
+ // Arrange / Act
842
+ const view: JobView = normalizeJobView(
843
+ { state: "done", tasks: { plan: { state: "done" }, review: { state: "done" } } },
844
+ JOB_ID,
845
+ LOCATION,
846
+ );
847
+
848
+ // Assert
849
+ expect(Object.keys(view.tasks)).toEqual(["plan", "review"]);
850
+ });
851
+ });
852
+
853
+ describe("robustness", () => {
854
+ test("non-object raw returns a JobView with empty tasks and pending status (no throw)", () => {
855
+ // Arrange / Act
856
+ const view: JobView = normalizeJobView("not an object", JOB_ID, LOCATION);
857
+
858
+ // Assert
859
+ expect(view.tasks).toEqual({});
860
+ expect(view.status).toBe("pending");
861
+ });
862
+
863
+ test("null raw returns a JobView with empty tasks and pending status (no throw)", () => {
864
+ // Arrange / Act
865
+ const view: JobView = normalizeJobView(null, JOB_ID, LOCATION);
866
+
867
+ // Assert
868
+ expect(view.tasks).toEqual({});
869
+ expect(view.status).toBe("pending");
870
+ });
871
+
872
+ test("undefined raw returns a JobView with empty tasks and pending status (no throw)", () => {
873
+ // Arrange / Act
874
+ const view: JobView = normalizeJobView(undefined, JOB_ID, LOCATION);
875
+
876
+ // Assert
877
+ expect(view.tasks).toEqual({});
878
+ expect(view.status).toBe("pending");
879
+ });
880
+ });
881
+
882
+ describe("gate kind preservation (AC-11)", () => {
883
+ test("preserves gate.kind === 'control_invalid' through normalizeJobView", () => {
884
+ // Arrange / Act
885
+ const view: JobView = normalizeJobView(
886
+ {
887
+ state: "waiting",
888
+ gate: {
889
+ afterTask: "review",
890
+ message: "Control file invalid for review: bad directive.",
891
+ requestedAt: "2026-06-19T12:00:00.000Z",
892
+ kind: "control_invalid",
893
+ },
894
+ tasks: { review: { state: "done" } },
895
+ },
896
+ JOB_ID,
897
+ LOCATION,
898
+ );
899
+
900
+ // Assert
901
+ expect(view.gate?.kind).toBe("control_invalid");
902
+ });
903
+
904
+ test("omits gate.kind when value is not one of the allowed strings", () => {
905
+ // Arrange / Act
906
+ const view: JobView = normalizeJobView(
907
+ {
908
+ state: "waiting",
909
+ gate: {
910
+ afterTask: "review",
911
+ message: "Control file invalid.",
912
+ requestedAt: "2026-06-19T12:00:00.000Z",
913
+ kind: 42,
914
+ },
915
+ tasks: { review: { state: "done" } },
916
+ },
917
+ JOB_ID,
918
+ LOCATION,
919
+ );
920
+
921
+ // Assert
922
+ expect(view.gate).toBeDefined();
923
+ expect(Object.hasOwn(view.gate!, "kind")).toBe(false);
924
+ });
925
+ });
926
+
927
+ describe("getCosts source propagation (AC-10)", () => {
928
+ test("mixed reported+estimated tuples separate measured from estimated totals", () => {
929
+ // Arrange / Act
930
+ const view: JobView = normalizeJobView(
931
+ {
932
+ state: "done",
933
+ tasks: {
934
+ alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "reported"]] },
935
+ beta: { state: "done", tokenUsage: [["anthropic:claude", 7, 3, 0.5, "estimated"]] },
936
+ },
937
+ },
938
+ JOB_ID,
939
+ LOCATION,
940
+ );
941
+
942
+ // Assert
943
+ expect(view.costs).toMatchObject({
944
+ totalCost: 0.25,
945
+ estimatedCost: 0.5,
946
+ unavailableCost: 0,
947
+ hasEstimated: true,
948
+ totalTokens: 25,
949
+ totalInputTokens: 17,
950
+ totalOutputTokens: 8,
951
+ });
952
+ expect(view.costs).toMatchObject({
953
+ alpha: { source: "reported", totalCost: 0.25, inputTokens: 10, outputTokens: 5 },
954
+ beta: { source: "estimated", totalCost: 0.5, inputTokens: 7, outputTokens: 3 },
955
+ });
956
+ });
957
+
958
+ test("all-reported tuples do not set an estimate marker", () => {
959
+ // Arrange / Act
960
+ const view: JobView = normalizeJobView(
961
+ {
962
+ state: "done",
963
+ tasks: {
964
+ alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "reported"]] },
965
+ },
966
+ },
967
+ JOB_ID,
968
+ LOCATION,
969
+ );
970
+
971
+ // Assert
972
+ expect(view.costs).toMatchObject({
973
+ totalCost: 0.25,
974
+ estimatedCost: 0,
975
+ unavailableCost: 0,
976
+ hasEstimated: false,
977
+ });
978
+ expect(view.costs).toMatchObject({ alpha: { source: "reported", totalCost: 0.25 } });
979
+ expect(Object.hasOwn(view.costs.alpha as object, "failed")).toBe(false);
980
+ });
981
+
982
+ test("reported usage with estimated cost stays out of measured totals", () => {
983
+ // Arrange / Act
984
+ const view: JobView = normalizeJobView(
985
+ {
986
+ state: "done",
987
+ tasks: {
988
+ alpha: {
989
+ state: "done",
990
+ tokenUsage: [["openai:unknown-model", 10, 5, 0, "reported", false, true]],
991
+ },
992
+ },
993
+ },
994
+ JOB_ID,
995
+ LOCATION,
996
+ );
997
+
998
+ // Assert
999
+ expect(view.costs).toMatchObject({
1000
+ totalCost: 0,
1001
+ estimatedCost: 0,
1002
+ unavailableCost: 0,
1003
+ hasEstimated: true,
1004
+ totalTokens: 15,
1005
+ alpha: {
1006
+ source: "reported",
1007
+ totalCost: 0,
1008
+ costEstimated: true,
1009
+ },
1010
+ });
1011
+ });
1012
+
1013
+ test("legacy 4-element tuples are treated as reported", () => {
1014
+ // Arrange / Act
1015
+ const view: JobView = normalizeJobView(
1016
+ {
1017
+ state: "done",
1018
+ tasks: {
1019
+ alpha: { state: "done", tokenUsage: [["openai:gpt-4o", 10, 5, 0.25]] },
1020
+ },
1021
+ },
1022
+ JOB_ID,
1023
+ LOCATION,
1024
+ );
1025
+
1026
+ // Assert
1027
+ expect(view.costs).toMatchObject({
1028
+ totalCost: 0.25,
1029
+ estimatedCost: 0,
1030
+ unavailableCost: 0,
1031
+ hasEstimated: false,
1032
+ });
1033
+ expect(view.costs).toMatchObject({ alpha: { source: "reported", totalCost: 0.25 } });
1034
+ });
1035
+
1036
+ test("unavailable source contributes to unavailableCost and sets hasEstimated", () => {
1037
+ // Arrange / Act
1038
+ const view: JobView = normalizeJobView(
1039
+ {
1040
+ state: "done",
1041
+ tasks: {
1042
+ alpha: {
1043
+ state: "done",
1044
+ tokenUsage: [["claudecode:default", 0, 0, 0.1, "unavailable"]],
1045
+ },
1046
+ },
1047
+ },
1048
+ JOB_ID,
1049
+ LOCATION,
1050
+ );
1051
+
1052
+ // Assert
1053
+ expect(view.costs).toMatchObject({
1054
+ totalCost: 0,
1055
+ estimatedCost: 0,
1056
+ unavailableCost: 0.1,
1057
+ hasEstimated: true,
1058
+ });
1059
+ expect(view.costs).toMatchObject({ alpha: { source: "unavailable", totalCost: 0.1 } });
1060
+ });
1061
+
1062
+ test("cost-only agent tuple surfaces unavailable cost, not a measured total (AC-6)", () => {
1063
+ // Arrange — the exact 7-element tuple the stage runAgent / pipeline agent
1064
+ // path emits for a token-less (costUsd-only) harness via recordAgentUsage.
1065
+ const view: JobView = normalizeJobView(
1066
+ {
1067
+ state: "done",
1068
+ tasks: {
1069
+ "agent-cost-task": {
1070
+ state: "done",
1071
+ tokenUsage: [["opencode:gpt-4o", 0, 0, 0.12, "unavailable", false, true]],
1072
+ },
1073
+ },
1074
+ },
1075
+ JOB_ID,
1076
+ LOCATION,
1077
+ );
1078
+
1079
+ // Assert — cost lands in unavailableCost (estimated), never the measured totalCost.
1080
+ expect(view.costs).toMatchObject({
1081
+ totalCost: 0,
1082
+ totalTokens: 0,
1083
+ estimatedCost: 0,
1084
+ unavailableCost: 0.12,
1085
+ hasEstimated: true,
1086
+ });
1087
+ expect(view.costs).toMatchObject({
1088
+ "agent-cost-task": { source: "unavailable", totalCost: 0.12, costEstimated: true },
1089
+ });
1090
+ });
1091
+
1092
+ test("zero source contributes no cost and does not set hasEstimated", () => {
1093
+ // Arrange / Act — mix zero with reported so the job has a non-empty costs
1094
+ // object and we can verify zero does not add to estimated/unavailable.
1095
+ const view: JobView = normalizeJobView(
1096
+ {
1097
+ state: "done",
1098
+ tasks: {
1099
+ alpha: {
1100
+ state: "done",
1101
+ tokenUsage: [
1102
+ ["mock:mock", 0, 0, 0, "zero"],
1103
+ ["openai:gpt-4o", 10, 5, 0.25, "reported"],
1104
+ ],
1105
+ },
1106
+ },
1107
+ },
1108
+ JOB_ID,
1109
+ LOCATION,
1110
+ );
1111
+
1112
+ // Assert — zero source is legitimate reported-zero; no estimate marker.
1113
+ expect(view.costs).toMatchObject({
1114
+ totalCost: 0.25,
1115
+ estimatedCost: 0,
1116
+ unavailableCost: 0,
1117
+ hasEstimated: false,
1118
+ });
1119
+ });
1120
+
1121
+ test("6-element failed tuple marks the task breakdown as failed", () => {
1122
+ // Arrange / Act
1123
+ const view: JobView = normalizeJobView(
1124
+ {
1125
+ state: "failed",
1126
+ tasks: {
1127
+ alpha: {
1128
+ state: "failed",
1129
+ tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "estimated", true]],
1130
+ },
1131
+ },
1132
+ },
1133
+ JOB_ID,
1134
+ LOCATION,
1135
+ );
1136
+
1137
+ // Assert
1138
+ expect(view.costs).toMatchObject({
1139
+ estimatedCost: 0.25,
1140
+ hasEstimated: true,
1141
+ alpha: { source: "estimated", failed: true, totalCost: 0.25 },
1142
+ });
1143
+ });
1144
+
1145
+ test("legacy 6-element failed tuple is not mistaken for a costEstimated slot", () => {
1146
+ // Arrange / Act
1147
+ const view: JobView = normalizeJobView(
1148
+ {
1149
+ state: "failed",
1150
+ tasks: {
1151
+ alpha: {
1152
+ state: "failed",
1153
+ tokenUsage: [["openai:gpt-4o", 10, 5, 0.25, "reported", true]],
1154
+ },
1155
+ },
1156
+ },
1157
+ JOB_ID,
1158
+ LOCATION,
1159
+ );
1160
+
1161
+ // Assert
1162
+ expect(view.costs).toMatchObject({
1163
+ totalCost: 0.25,
1164
+ estimatedCost: 0,
1165
+ hasEstimated: false,
1166
+ alpha: { source: "reported", failed: true, totalCost: 0.25 },
1167
+ });
1168
+ expect(Object.hasOwn(view.costs.alpha as object, "costEstimated")).toBe(false);
1169
+ });
1170
+
1171
+ test("a task with mixed sources takes the worst (most uncertain) source", () => {
1172
+ // Arrange / Act
1173
+ const view: JobView = normalizeJobView(
1174
+ {
1175
+ state: "done",
1176
+ tasks: {
1177
+ alpha: {
1178
+ state: "done",
1179
+ tokenUsage: [
1180
+ ["openai:gpt-4o", 10, 5, 0.25, "reported"],
1181
+ ["anthropic:claude", 7, 3, 0.5, "estimated"],
1182
+ ],
1183
+ },
1184
+ },
1185
+ },
1186
+ JOB_ID,
1187
+ LOCATION,
1188
+ );
1189
+
1190
+ // Assert
1191
+ expect(view.costs).toMatchObject({
1192
+ totalCost: 0.25,
1193
+ estimatedCost: 0.5,
1194
+ hasEstimated: true,
1195
+ alpha: { source: "estimated", totalCost: 0.75 },
1196
+ });
1197
+ });
1198
+ });
1199
+ });
1200
+
1201
+ describe("deriveProgress (AC-6)", () => {
1202
+ test("taskCount = configured when configured > actual", () => {
1203
+ // Arrange
1204
+ const tasks = [{ state: "done" }, { state: "pending" }];
1205
+
1206
+ // Act
1207
+ const result = deriveProgress(tasks, 5);
1208
+
1209
+ // Assert
1210
+ expect(result.taskCount).toBe(5);
1211
+ expect(result.doneCount).toBe(1);
1212
+ expect(result.completedCount).toBe(1);
1213
+ expect(result.progress).toBe(Math.floor((1 / 5) * 100));
1214
+ });
1215
+
1216
+ test("taskCount = actual when actual > configured", () => {
1217
+ // Arrange
1218
+ const tasks = [{ state: "done" }, { state: "done" }, { state: "skipped" }, { state: "pending" }];
1219
+
1220
+ // Act
1221
+ const result = deriveProgress(tasks, 1);
1222
+
1223
+ // Assert
1224
+ expect(result.taskCount).toBe(4);
1225
+ expect(result.doneCount).toBe(2);
1226
+ expect(result.completedCount).toBe(3);
1227
+ expect(result.progress).toBe(Math.floor((3 / 4) * 100));
1228
+ });
1229
+
1230
+ test("taskCount = actual when configuredCount is null", () => {
1231
+ // Arrange
1232
+ const tasks = [{ state: "done" }, { state: "skipped" }, { state: "pending" }];
1233
+
1234
+ // Act
1235
+ const result = deriveProgress(tasks, null);
1236
+
1237
+ // Assert
1238
+ expect(result.taskCount).toBe(3);
1239
+ expect(result.progress).toBe(Math.floor((2 / 3) * 100));
1240
+ });
1241
+
1242
+ test("zero tasks returns taskCount 0 and progress 0", () => {
1243
+ // Arrange / Act
1244
+ const result = deriveProgress([], null);
1245
+
1246
+ // Assert
1247
+ expect(result.taskCount).toBe(0);
1248
+ expect(result.doneCount).toBe(0);
1249
+ expect(result.completedCount).toBe(0);
1250
+ expect(result.progress).toBe(0);
1251
+ });
1252
+
1253
+ test("zero tasks + configuredCount 0 returns taskCount 0 and progress 0", () => {
1254
+ // Arrange / Act
1255
+ const result = deriveProgress([], 0);
1256
+
1257
+ // Assert
1258
+ expect(result.taskCount).toBe(0);
1259
+ expect(result.progress).toBe(0);
1260
+ });
1261
+
1262
+ test("progress clamps at 100 when configured < actual and all tasks are completed", () => {
1263
+ // Arrange
1264
+ const tasks = [{ state: "done" }, { state: "done" }];
1265
+
1266
+ // Act
1267
+ const result = deriveProgress(tasks, 1);
1268
+
1269
+ // Assert
1270
+ expect(result.taskCount).toBe(2);
1271
+ expect(result.completedCount).toBe(2);
1272
+ expect(result.progress).toBe(100);
1273
+ });
1274
+
1275
+ test("doneCount counts only 'done', not 'skipped'", () => {
1276
+ // Arrange
1277
+ const tasks = [{ state: "done" }, { state: "skipped" }, { state: "skipped" }];
1278
+
1279
+ // Act
1280
+ const result = deriveProgress(tasks, null);
1281
+
1282
+ // Assert
1283
+ expect(result.doneCount).toBe(1);
1284
+ expect(result.completedCount).toBe(3);
1285
+ });
1286
+
1287
+ test("completedCount counts both 'done' and 'skipped'", () => {
1288
+ // Arrange
1289
+ const tasks = [{ state: "done" }, { state: "skipped" }, { state: "skipped" }];
1290
+
1291
+ // Act
1292
+ const result = deriveProgress(tasks, null);
1293
+
1294
+ // Assert
1295
+ expect(result.completedCount).toBe(3);
1296
+ });
1297
+
1298
+ test("tasks with undefined state are not counted as completed", () => {
1299
+ // Arrange
1300
+ const tasks = [{}, { state: "done" }];
1301
+
1302
+ // Act
1303
+ const result = deriveProgress(tasks, null);
1304
+
1305
+ // Assert
1306
+ expect(result.doneCount).toBe(1);
1307
+ expect(result.completedCount).toBe(1);
1308
+ expect(result.progress).toBe(50);
1309
+ });
1310
+
1311
+ test("tasks with non-completion state ('running') are not counted", () => {
1312
+ // Arrange
1313
+ const tasks = [{ state: "running" }, { state: "done" }];
1314
+
1315
+ // Act
1316
+ const result = deriveProgress(tasks, null);
1317
+
1318
+ // Assert
1319
+ expect(result.doneCount).toBe(1);
1320
+ expect(result.completedCount).toBe(1);
1321
+ expect(result.progress).toBe(50);
1322
+ });
1323
+ });
1324
+
1325
+ describe("__resetWarnedFallbackKeys", () => {
1326
+ test("clears the warn cache so a subsequent call warns again", () => {
1327
+ // Arrange
1328
+ const raw = { tasks: { a: { state: "done" } } };
1329
+
1330
+ // Act
1331
+ normalizeJobView(raw, JOB_ID, LOCATION);
1332
+ const firstCalls = warnCallsContaining(`${LOCATION}:${JOB_ID}`);
1333
+ __resetWarnedFallbackKeys();
1334
+ normalizeJobView(raw, JOB_ID, LOCATION);
1335
+ const secondCalls = warnCallsContaining(`${LOCATION}:${JOB_ID}`);
1336
+
1337
+ // Assert
1338
+ expect(firstCalls).toBe(1);
1339
+ expect(secondCalls).toBe(2);
1340
+ });
1341
+ });