@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,568 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import type { JobWire, SseEvent } from "../../../../config/sse-events.ts";
4
+ import { adaptJobDetail } from "../../adapters/job-adapter.ts";
5
+ import { applyDetailEvent, applyJobEvent } from "../job-events.ts";
6
+ import type { NormalizedJobDetail, NormalizedJobSummary } from "../../types.ts";
7
+
8
+ function makeJob(jobId: string, fields: Partial<NormalizedJobSummary> = {}): NormalizedJobSummary {
9
+ return {
10
+ id: jobId,
11
+ jobId,
12
+ name: jobId,
13
+ status: "pending",
14
+ progress: 0,
15
+ taskCount: 0,
16
+ doneCount: 0,
17
+ location: "current",
18
+ tasks: {},
19
+ current: null,
20
+ costsSummary: {
21
+ totalTokens: 0,
22
+ totalInputTokens: 0,
23
+ totalOutputTokens: 0,
24
+ totalCost: 0,
25
+ totalInputCost: 0,
26
+ totalOutputCost: 0,
27
+ estimatedCost: 0,
28
+ hasEstimated: false,
29
+ unavailableCost: 0,
30
+ },
31
+ totalCost: 0,
32
+ totalTokens: 0,
33
+ __warnings: [],
34
+ displayCategory: "current",
35
+ ...fields,
36
+ };
37
+ }
38
+
39
+ function makeJobWire(fields: Partial<JobWire> & { jobId: string }): JobWire {
40
+ return {
41
+ jobId: fields.jobId,
42
+ title: fields.title ?? `Title for ${fields.jobId}`,
43
+ status: fields.status ?? "pending",
44
+ progress: fields.progress ?? 0,
45
+ createdAt: fields.createdAt ?? null,
46
+ updatedAt: fields.updatedAt ?? null,
47
+ location: fields.location ?? null,
48
+ tasks: fields.tasks ?? {},
49
+ costsSummary: fields.costsSummary ?? {
50
+ totalTokens: 0,
51
+ totalInputTokens: 0,
52
+ totalOutputTokens: 0,
53
+ totalCost: 0,
54
+ totalInputCost: 0,
55
+ totalOutputCost: 0,
56
+ estimatedCost: 0,
57
+ hasEstimated: false,
58
+ unavailableCost: 0,
59
+ },
60
+ };
61
+ }
62
+
63
+ describe("applyJobEvent", () => {
64
+ describe("job:created", () => {
65
+ it("appends to an empty list", () => {
66
+ // Arrange
67
+ const event: SseEvent = {
68
+ type: "job:created",
69
+ data: makeJobWire({ jobId: "job-1" }),
70
+ };
71
+
72
+ // Act
73
+ const next = applyJobEvent([], event);
74
+
75
+ // Assert
76
+ expect(next).toHaveLength(1);
77
+ expect(next[0]?.jobId).toBe("job-1");
78
+ });
79
+
80
+ it("appends to a non-empty list when the jobId is new", () => {
81
+ // Arrange
82
+ const jobs = [makeJob("job-1")];
83
+ const event: SseEvent = {
84
+ type: "job:created",
85
+ data: makeJobWire({ jobId: "job-2" }),
86
+ };
87
+
88
+ // Act
89
+ const next = applyJobEvent(jobs, event);
90
+
91
+ // Assert
92
+ expect(next).toHaveLength(2);
93
+ expect(next.map((job) => job.jobId).sort()).toEqual(["job-1", "job-2"]);
94
+ });
95
+ });
96
+
97
+ describe("job:updated", () => {
98
+ it("merges the incoming data into the existing entry", () => {
99
+ // Arrange
100
+ const jobs = [makeJob("job-1", { name: "old" })];
101
+ const event: SseEvent = {
102
+ type: "job:updated",
103
+ data: makeJobWire({ jobId: "job-1", title: "new" }),
104
+ };
105
+
106
+ // Act
107
+ const next = applyJobEvent(jobs, event);
108
+
109
+ // Assert
110
+ expect(next).toHaveLength(1);
111
+ expect(next[0]?.name).toBe("new");
112
+ });
113
+
114
+ it("returns the same array reference when the update is a no-op", () => {
115
+ // Arrange
116
+ const jobs = [makeJob("job-1", { name: "same" })];
117
+ const event: SseEvent = {
118
+ type: "job:updated",
119
+ data: makeJobWire({ jobId: "job-1", title: "same" }),
120
+ };
121
+
122
+ // Act
123
+ const next = applyJobEvent(jobs, event);
124
+
125
+ // Assert
126
+ expect(next).toBe(jobs);
127
+ });
128
+ });
129
+
130
+ describe("job:removed", () => {
131
+ it("drops the matching job", () => {
132
+ // Arrange
133
+ const jobs = [makeJob("job-1"), makeJob("job-2")];
134
+ const event: SseEvent = {
135
+ type: "job:removed",
136
+ data: { jobId: "job-1" },
137
+ };
138
+
139
+ // Act
140
+ const next = applyJobEvent(jobs, event);
141
+
142
+ // Assert
143
+ expect(next).toHaveLength(1);
144
+ expect(next[0]?.jobId).toBe("job-2");
145
+ });
146
+
147
+ it("returns the same array reference when the jobId is absent", () => {
148
+ // Arrange
149
+ const jobs = [makeJob("job-1")];
150
+ const event: SseEvent = {
151
+ type: "job:removed",
152
+ data: { jobId: "job-missing" },
153
+ };
154
+
155
+ // Act
156
+ const next = applyJobEvent(jobs, event);
157
+
158
+ // Assert
159
+ expect(next).toBe(jobs);
160
+ });
161
+ });
162
+
163
+ describe("sort order", () => {
164
+ it("applies a stable 3-key sort (status, createdAt, jobId)", () => {
165
+ // Arrange — three pending jobs with the same createdAt; tie-break must be jobId asc.
166
+ const jobs = [
167
+ makeJob("job-c", { status: "pending", createdAt: "2026-01-01T00:00:00.000Z" }),
168
+ makeJob("job-a", { status: "pending", createdAt: "2026-01-01T00:00:00.000Z" }),
169
+ makeJob("job-b", { status: "pending", createdAt: "2026-01-01T00:00:00.000Z" }),
170
+ ];
171
+ const event: SseEvent = {
172
+ type: "job:updated",
173
+ data: makeJobWire({
174
+ jobId: "job-x",
175
+ title: "x",
176
+ status: "pending",
177
+ createdAt: "2026-01-01T00:00:00.000Z",
178
+ }),
179
+ };
180
+
181
+ // Act
182
+ const next = applyJobEvent(jobs, event);
183
+
184
+ // Assert
185
+ expect(next.map((job) => job.jobId)).toEqual(["job-a", "job-b", "job-c", "job-x"]);
186
+ });
187
+ });
188
+
189
+ describe("idempotency", () => {
190
+ it("does not duplicate when the same job:created snapshot is applied twice", () => {
191
+ // Arrange
192
+ const event: SseEvent = {
193
+ type: "job:created",
194
+ data: makeJobWire({ jobId: "job-1" }),
195
+ };
196
+
197
+ // Act
198
+ const once = applyJobEvent([], event);
199
+ const twice = applyJobEvent(once, event);
200
+
201
+ // Assert
202
+ expect(twice).toHaveLength(1);
203
+ expect(twice[0]?.jobId).toBe("job-1");
204
+ expect(twice).toBe(once);
205
+ });
206
+
207
+ it("does not duplicate or mutate when the same job:updated snapshot is applied twice", () => {
208
+ // Arrange
209
+ const jobs = [makeJob("job-1", { name: "old" })];
210
+ const event: SseEvent = {
211
+ type: "job:updated",
212
+ data: makeJobWire({ jobId: "job-1", title: "new" }),
213
+ };
214
+ const jobsBefore = jobs.slice();
215
+
216
+ // Act
217
+ const once = applyJobEvent(jobs, event);
218
+ const twice = applyJobEvent(once, event);
219
+
220
+ // Assert
221
+ expect(twice).toHaveLength(1);
222
+ expect(twice[0]?.name).toBe("new");
223
+ expect(jobs).toEqual(jobsBefore);
224
+ });
225
+
226
+ it("commutes for events targeting different jobIds", () => {
227
+ // Arrange
228
+ const eventA: SseEvent = {
229
+ type: "job:created",
230
+ data: makeJobWire({ jobId: "job-a" }),
231
+ };
232
+ const eventB: SseEvent = {
233
+ type: "job:created",
234
+ data: makeJobWire({ jobId: "job-b" }),
235
+ };
236
+
237
+ // Act
238
+ const orderAB = applyJobEvent(applyJobEvent([], eventA), eventB);
239
+ const orderBA = applyJobEvent(applyJobEvent([], eventB), eventA);
240
+
241
+ // Assert — same content, same deterministic order (jobId ascending tie-break)
242
+ expect(orderAB.map((job) => job.jobId)).toEqual(["job-a", "job-b"]);
243
+ expect(orderBA.map((job) => job.jobId)).toEqual(["job-a", "job-b"]);
244
+ expect(orderAB).toEqual(orderBA);
245
+ });
246
+ });
247
+
248
+ describe("non-list event types", () => {
249
+ it("returns the same array reference for heartbeat, seed:uploaded, state:change, and state:summary", () => {
250
+ // Arrange
251
+ const jobs = [makeJob("job-1")];
252
+ const heartbeat: SseEvent = {
253
+ type: "heartbeat",
254
+ data: { ok: true, timestamp: "2026-06-21T00:00:00.000Z" },
255
+ };
256
+ const seedUploaded: SseEvent = { type: "seed:uploaded", data: {} };
257
+ const stateChange: SseEvent = {
258
+ type: "state:change",
259
+ data: { jobId: "job-1", path: "pipeline-data/current/job-1/tasks-status.json" },
260
+ };
261
+ const stateSummary: SseEvent = { type: "state:summary", data: { changeCount: 1 } };
262
+
263
+ // Act + Assert
264
+ expect(applyJobEvent(jobs, heartbeat)).toBe(jobs);
265
+ expect(applyJobEvent(jobs, seedUploaded)).toBe(jobs);
266
+ expect(applyJobEvent(jobs, stateChange)).toBe(jobs);
267
+ expect(applyJobEvent(jobs, stateSummary)).toBe(jobs);
268
+ });
269
+ });
270
+ });
271
+
272
+ function makeDetail(jobId: string): NormalizedJobDetail {
273
+ return {
274
+ id: jobId,
275
+ jobId,
276
+ name: jobId,
277
+ status: "pending",
278
+ progress: 0,
279
+ taskCount: 2,
280
+ doneCount: 0,
281
+ location: "current",
282
+ tasks: {
283
+ build: {
284
+ name: "build",
285
+ state: "pending",
286
+ startedAt: null,
287
+ endedAt: null,
288
+ files: { artifacts: [], logs: [], tmp: [] },
289
+ },
290
+ test: {
291
+ name: "test",
292
+ state: "pending",
293
+ startedAt: null,
294
+ endedAt: null,
295
+ files: { artifacts: [], logs: [], tmp: [] },
296
+ },
297
+ },
298
+ displayCategory: "current",
299
+ };
300
+ }
301
+
302
+ describe("applyDetailEvent", () => {
303
+ describe("job:updated", () => {
304
+ it("merges a matching event and updates the changed task state (AC-11)", () => {
305
+ // Arrange
306
+ const detail = makeDetail("job-1");
307
+ const event: SseEvent = {
308
+ type: "job:updated",
309
+ data: makeJobWire({
310
+ jobId: "job-1",
311
+ tasks: {
312
+ build: { state: "done" },
313
+ test: { state: "running" },
314
+ },
315
+ }),
316
+ };
317
+
318
+ // Act
319
+ const next = applyDetailEvent(detail, event);
320
+
321
+ // Assert
322
+ expect(next.tasks["build"]?.state).toBe("done");
323
+ expect(next.tasks["test"]?.state).toBe("running");
324
+ });
325
+
326
+ it("ignores a job:updated whose jobId does not match the detail", () => {
327
+ // Arrange
328
+ const detail = makeDetail("job-1");
329
+ const event: SseEvent = {
330
+ type: "job:updated",
331
+ data: makeJobWire({
332
+ jobId: "job-2",
333
+ tasks: { build: { state: "done" } },
334
+ }),
335
+ };
336
+
337
+ // Act
338
+ const next = applyDetailEvent(detail, event);
339
+
340
+ // Assert
341
+ expect(next).toBe(detail);
342
+ });
343
+ });
344
+
345
+ describe("task reference preservation (AC-9)", () => {
346
+ it("preserves the object reference for tasks whose normalized payload did not change and replaces the reference for tasks whose payload changed", () => {
347
+ // Arrange
348
+ const detail = makeDetail("job-1");
349
+ const event: SseEvent = {
350
+ type: "job:updated",
351
+ data: makeJobWire({
352
+ jobId: "job-1",
353
+ tasks: {
354
+ build: { state: "done" },
355
+ test: { state: "pending" },
356
+ },
357
+ }),
358
+ };
359
+
360
+ // Act
361
+ const next = applyDetailEvent(detail, event);
362
+
363
+ // Assert — changed task gets a new reference, unchanged task keeps its reference
364
+ expect(next.tasks["build"]).not.toBe(detail.tasks["build"]);
365
+ expect(next.tasks["build"]?.state).toBe("done");
366
+ expect(next.tasks["test"]).toBe(detail.tasks["test"]);
367
+ });
368
+ });
369
+
370
+ describe("idempotency (AC-8)", () => {
371
+ it("returns the same reference when the same snapshot is applied twice", () => {
372
+ // Arrange
373
+ const detail = makeDetail("job-1");
374
+ const event: SseEvent = {
375
+ type: "job:updated",
376
+ data: makeJobWire({
377
+ jobId: "job-1",
378
+ tasks: {
379
+ build: { state: "done" },
380
+ test: { state: "running" },
381
+ },
382
+ }),
383
+ };
384
+
385
+ // Act
386
+ const once = applyDetailEvent(detail, event);
387
+ const twice = applyDetailEvent(once, event);
388
+
389
+ // Assert
390
+ expect(twice).toBe(once);
391
+ });
392
+
393
+ it("preserves task references and leaves derived fields unchanged when the event payload already matches the current detail", () => {
394
+ // Arrange
395
+ const detail = makeDetail("job-1");
396
+ const event: SseEvent = {
397
+ type: "job:updated",
398
+ data: makeJobWire({
399
+ jobId: "job-1",
400
+ tasks: {
401
+ build: { state: "pending" },
402
+ test: { state: "pending" },
403
+ },
404
+ }),
405
+ };
406
+
407
+ // Act
408
+ const next = applyDetailEvent(detail, event);
409
+
410
+ // Assert — payload matches ⇒ unchanged task references and unchanged derived fields
411
+ expect(next.tasks["build"]).toBe(detail.tasks["build"]);
412
+ expect(next.tasks["test"]).toBe(detail.tasks["test"]);
413
+ expect(next.progress).toBe(detail.progress);
414
+ expect(next.taskCount).toBe(detail.taskCount);
415
+ expect(next.doneCount).toBe(detail.doneCount);
416
+ });
417
+ });
418
+
419
+ describe("convergence (AC-10)", () => {
420
+ it("reducing a job:updated built from a fixture matches normalizing that fixture via adaptJobDetail", () => {
421
+ // Arrange — fixture designed so adaptBaseJob.progress and deriveProgress.progress agree
422
+ const fixture: JobWire = {
423
+ jobId: "job-1",
424
+ title: "My Job",
425
+ status: "running",
426
+ progress: 50,
427
+ createdAt: "2026-01-01T00:00:00.000Z",
428
+ updatedAt: "2026-01-02T00:00:00.000Z",
429
+ location: "current",
430
+ tasks: {
431
+ build: { state: "done" },
432
+ test: { state: "running" },
433
+ },
434
+ costsSummary: {
435
+ totalTokens: 100,
436
+ totalInputTokens: 60,
437
+ totalOutputTokens: 40,
438
+ totalCost: 0,
439
+ totalInputCost: 0,
440
+ totalOutputCost: 0,
441
+ estimatedCost: 0,
442
+ hasEstimated: false,
443
+ unavailableCost: 0,
444
+ },
445
+ };
446
+ const event: SseEvent = { type: "job:updated", data: fixture };
447
+
448
+ // Act
449
+ const refetched = adaptJobDetail({ ...fixture });
450
+ const reduced = applyDetailEvent(makeDetail("job-1"), event);
451
+
452
+ // Assert — convergence on the fields the refetch path also produces
453
+ expect(reduced.jobId).toBe(refetched.jobId);
454
+ expect(reduced.status).toBe(refetched.status);
455
+ expect(reduced.progress).toBe(refetched.progress);
456
+ expect(reduced.taskCount).toBe(refetched.taskCount);
457
+ expect(reduced.doneCount).toBe(refetched.doneCount);
458
+ expect(reduced.completedCount).toBe(refetched.completedCount);
459
+ expect(Object.keys(reduced.tasks).sort()).toEqual(Object.keys(refetched.tasks).sort());
460
+ for (const name of Object.keys(refetched.tasks)) {
461
+ expect(reduced.tasks[name]).toEqual(refetched.tasks[name]);
462
+ }
463
+ });
464
+
465
+ it("derives progress from the event's configured count, not the stale captured one (AC-10)", () => {
466
+ // Arrange — the detail was captured with a 2-task configured count, but the incoming
467
+ // job:updated carries a 4-task pipelineConfig (the detail endpoint enriches config via
468
+ // loadPipelineJson, so the wire config can legitimately differ from the value captured
469
+ // at load). Progress must converge on the event's config, exactly as a refetch would.
470
+ const prev: NormalizedJobDetail = {
471
+ ...makeDetail("job-1"),
472
+ pipelineConfig: { tasks: ["build", "test"] },
473
+ taskCount: 2,
474
+ };
475
+ const wire: JobWire = {
476
+ ...makeJobWire({
477
+ jobId: "job-1",
478
+ tasks: {
479
+ build: { state: "done" },
480
+ test: { state: "running" },
481
+ },
482
+ }),
483
+ pipelineConfig: { tasks: ["build", "test", "lint", "deploy"] },
484
+ };
485
+ const event: SseEvent = { type: "job:updated", data: wire };
486
+
487
+ // Act
488
+ const reduced = applyDetailEvent(prev, event);
489
+ const refetched = adaptJobDetail({ ...wire });
490
+
491
+ // Assert — converges on the event's 4-task denominator: floor(1/4*100) = 25
492
+ expect(reduced.taskCount).toBe(4);
493
+ expect(reduced.progress).toBe(25);
494
+ expect(reduced.taskCount).toBe(refetched.taskCount);
495
+ expect(reduced.progress).toBe(refetched.progress);
496
+ expect(reduced.doneCount).toBe(refetched.doneCount);
497
+ expect(reduced.completedCount).toBe(refetched.completedCount);
498
+ });
499
+ });
500
+
501
+ describe("non-job:updated events are no-ops (AC-11)", () => {
502
+ it("returns the same detail reference for heartbeat, state:change, state:summary, seed:uploaded, job:removed, and job:created", () => {
503
+ // Arrange
504
+ const detail = makeDetail("job-1");
505
+ const heartbeat: SseEvent = {
506
+ type: "heartbeat",
507
+ data: { ok: true, timestamp: "2026-06-21T00:00:00.000Z" },
508
+ };
509
+ const stateChange: SseEvent = {
510
+ type: "state:change",
511
+ data: { jobId: "job-1", path: "pipeline-data/current/job-1/tasks-status.json" },
512
+ };
513
+ const stateSummary: SseEvent = { type: "state:summary", data: { changeCount: 1 } };
514
+ const seedUploaded: SseEvent = { type: "seed:uploaded", data: {} };
515
+ const jobRemoved: SseEvent = { type: "job:removed", data: { jobId: "job-1" } };
516
+ const jobCreated: SseEvent = {
517
+ type: "job:created",
518
+ data: makeJobWire({ jobId: "job-1" }),
519
+ };
520
+
521
+ // Act + Assert
522
+ expect(applyDetailEvent(detail, heartbeat)).toBe(detail);
523
+ expect(applyDetailEvent(detail, stateChange)).toBe(detail);
524
+ expect(applyDetailEvent(detail, stateSummary)).toBe(detail);
525
+ expect(applyDetailEvent(detail, seedUploaded)).toBe(detail);
526
+ expect(applyDetailEvent(detail, jobRemoved)).toBe(detail);
527
+ expect(applyDetailEvent(detail, jobCreated)).toBe(detail);
528
+ });
529
+ });
530
+
531
+ describe("deriveProgress is the only progress formula (AC-7)", () => {
532
+ it("computes progress against the configured task count, not the current task list length", () => {
533
+ // Arrange — configured pipeline has 6 tasks, only 2 are recorded; one is "done".
534
+ // adaptBaseJob.progress would be floor(1/6*100) = 16; deriveProgress returns 16 too here,
535
+ // so to differentiate we instead assert the larger configured denominator wins even when
536
+ // tasks.length is greater than configuredCount.
537
+ const detail: NormalizedJobDetail = {
538
+ ...makeDetail("job-1"),
539
+ pipelineConfig: { tasks: ["build", "test", "extra1", "extra2", "extra3", "extra4"] },
540
+ taskCount: 6,
541
+ doneCount: 0,
542
+ progress: 0,
543
+ };
544
+ const event: SseEvent = {
545
+ type: "job:updated",
546
+ data: makeJobWire({
547
+ jobId: "job-1",
548
+ tasks: {
549
+ build: { state: "done" },
550
+ test: { state: "pending" },
551
+ extra1: { state: "pending" },
552
+ extra2: { state: "pending" },
553
+ extra3: { state: "pending" },
554
+ extra4: { state: "pending" },
555
+ },
556
+ }),
557
+ };
558
+
559
+ // Act
560
+ const next = applyDetailEvent(detail, event);
561
+
562
+ // Assert — deriveProgress: max(6, 6) = 6 taskCount; completed = 1; progress = floor(1/6*100) = 16
563
+ expect(next.taskCount).toBe(6);
564
+ expect(next.doneCount).toBe(1);
565
+ expect(next.progress).toBe(16);
566
+ });
567
+ });
568
+ });
@@ -0,0 +1,137 @@
1
+ import { deriveProgress } from "../../../core/job-view.ts";
2
+ import type { SseEvent } from "../../../config/sse-events.ts";
3
+ import { adaptJobDetail, adaptJobSummary } from "../adapters/job-adapter.ts";
4
+ import type { NormalizedJobDetail, NormalizedJobSummary, NormalizedTask } from "../types.ts";
5
+
6
+ function sortNormalizedJobs(jobs: NormalizedJobSummary[]): NormalizedJobSummary[] {
7
+ const priority: Record<string, number> = {
8
+ running: 4,
9
+ waiting: 4,
10
+ failed: 3,
11
+ pending: 2,
12
+ complete: 1,
13
+ };
14
+
15
+ return jobs.slice().sort((left, right) => {
16
+ const statusOrder = (priority[right.status] ?? 0) - (priority[left.status] ?? 0);
17
+ if (statusOrder !== 0) return statusOrder;
18
+ const leftCreated = left.createdAt ?? "";
19
+ const rightCreated = right.createdAt ?? "";
20
+ const createdOrder = leftCreated.localeCompare(rightCreated);
21
+ if (createdOrder !== 0) return createdOrder;
22
+ return left.jobId.localeCompare(right.jobId);
23
+ });
24
+ }
25
+
26
+ function completedCountFromTasks(job: NormalizedJobSummary): number {
27
+ return deriveProgress(Object.values(job.tasks), null).completedCount;
28
+ }
29
+
30
+ function comparableJob(job: NormalizedJobSummary): Record<string, unknown> {
31
+ const comparable: Record<string, unknown> = { ...job };
32
+ for (const [key, value] of Object.entries(comparable)) {
33
+ if (value === undefined) delete comparable[key];
34
+ }
35
+ if (comparable["completedCount"] === completedCountFromTasks(job)) {
36
+ delete comparable["completedCount"];
37
+ }
38
+ return comparable;
39
+ }
40
+
41
+ function jobsEqual(left: NormalizedJobSummary[], right: NormalizedJobSummary[]): boolean {
42
+ return JSON.stringify(left.map(comparableJob)) === JSON.stringify(right.map(comparableJob));
43
+ }
44
+
45
+ function mergeJob(current: NormalizedJobSummary, incoming: NormalizedJobSummary): NormalizedJobSummary {
46
+ return {
47
+ ...current,
48
+ ...incoming,
49
+ id: incoming.id || current.id,
50
+ jobId: incoming.jobId || current.jobId,
51
+ name: incoming.name || current.name,
52
+ createdAt: incoming.createdAt ?? current.createdAt,
53
+ updatedAt: incoming.updatedAt ?? current.updatedAt,
54
+ pipeline: incoming.pipeline ?? current.pipeline,
55
+ pipelineLabel: incoming.pipelineLabel ?? current.pipelineLabel,
56
+ current: incoming.current ?? current.current,
57
+ currentStage: incoming.currentStage ?? current.currentStage,
58
+ };
59
+ }
60
+
61
+ export function applyJobEvent(
62
+ jobs: NormalizedJobSummary[],
63
+ event: SseEvent,
64
+ ): NormalizedJobSummary[] {
65
+ if (event.type === "job:removed") {
66
+ const key = event.data.jobId;
67
+ const next = jobs.filter((job) => job.jobId !== key);
68
+ return jobsEqual(next, jobs) ? jobs : sortNormalizedJobs(next);
69
+ }
70
+
71
+ if (event.type === "job:created" || event.type === "job:updated") {
72
+ const incoming = adaptJobSummary(event.data);
73
+ const key = incoming.jobId;
74
+ const existingIndex = jobs.findIndex((job) => job.jobId === key);
75
+ const next = existingIndex === -1
76
+ ? [...jobs, incoming]
77
+ : jobs.map((job, index) => index === existingIndex ? mergeJob(job, incoming) : job);
78
+ const sorted = sortNormalizedJobs(next);
79
+ return jobsEqual(sorted, jobs) ? jobs : sorted;
80
+ }
81
+
82
+ return jobs;
83
+ }
84
+
85
+ function getConfiguredTaskCount(detail: NormalizedJobDetail): number | null {
86
+ const config = detail.pipelineConfig;
87
+ if (config && Array.isArray(config["tasks"])) return config["tasks"].length;
88
+ return null;
89
+ }
90
+
91
+ function tasksEqual(left: NormalizedTask, right: NormalizedTask): boolean {
92
+ return JSON.stringify(left) === JSON.stringify(right);
93
+ }
94
+
95
+ function detailsDeepEqual(left: NormalizedJobDetail, right: NormalizedJobDetail): boolean {
96
+ return JSON.stringify(left) === JSON.stringify(right);
97
+ }
98
+
99
+ export function applyDetailEvent(
100
+ detail: NormalizedJobDetail,
101
+ event: SseEvent,
102
+ ): NormalizedJobDetail {
103
+ if (event.type !== "job:updated") return detail;
104
+ if (event.data.jobId !== detail.jobId) return detail;
105
+
106
+ const merged = { ...detail, ...event.data } as Record<string, unknown>;
107
+ const adapted = adaptJobDetail(merged);
108
+
109
+ const preservedTasks: Record<string, NormalizedTask> = {};
110
+ let tasksChanged = false;
111
+ for (const [name, nextTask] of Object.entries(adapted.tasks)) {
112
+ const prevTask = detail.tasks[name];
113
+ if (prevTask !== undefined && tasksEqual(prevTask, nextTask)) {
114
+ preservedTasks[name] = prevTask;
115
+ } else {
116
+ preservedTasks[name] = nextTask;
117
+ tasksChanged = true;
118
+ }
119
+ }
120
+
121
+ const derived = deriveProgress(Object.values(preservedTasks), getConfiguredTaskCount(adapted));
122
+
123
+ const candidate: NormalizedJobDetail = {
124
+ ...adapted,
125
+ tasks: preservedTasks,
126
+ taskCount: derived.taskCount,
127
+ doneCount: derived.doneCount,
128
+ completedCount: derived.completedCount,
129
+ progress: derived.progress,
130
+ };
131
+
132
+ if (!tasksChanged && detailsDeepEqual(candidate, detail)) {
133
+ return detail;
134
+ }
135
+
136
+ return candidate;
137
+ }