@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,470 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { APIJob } from "../../ui/state/types.ts";
3
+ import {
4
+ DOCUMENTED_SSE_EVENTS,
5
+ emitSseEvent,
6
+ parseSseEvent,
7
+ SseEvent,
8
+ type JobWire,
9
+ type SseEventType,
10
+ } from "../sse-events.ts";
11
+
12
+ type _AssertJobWireAssignableToAPIJob = JobWire extends APIJob ? true : false;
13
+ const _assertJobWireAssignable: _AssertJobWireAssignableToAPIJob = true;
14
+
15
+ describe("SseEvent", () => {
16
+ describe("parseSseEvent round-trips each variant", () => {
17
+ it("parses state:change with required jobId and path", () => {
18
+ // Arrange
19
+ const data = { jobId: "job-1", path: "pipeline-data/current/job-1/tasks-status.json" };
20
+
21
+ // Act
22
+ const event = parseSseEvent("state:change", data);
23
+
24
+ // Assert
25
+ expect(event).not.toBeNull();
26
+ expect(event?.type).toBe("state:change");
27
+ expect(event?.data).toEqual(data);
28
+ });
29
+
30
+ it("parses state:summary with changeCount", () => {
31
+ // Arrange
32
+ const data = { changeCount: 7 };
33
+
34
+ // Act
35
+ const event = parseSseEvent("state:summary", data);
36
+
37
+ // Assert
38
+ expect(event).not.toBeNull();
39
+ expect(event?.type).toBe("state:summary");
40
+ expect(event?.data).toEqual(data);
41
+ });
42
+
43
+ it("parses job:created with a complete JobWire payload", () => {
44
+ // Arrange
45
+ const data = {
46
+ jobId: "job-abc",
47
+ title: "My Job",
48
+ status: "running",
49
+ progress: 42,
50
+ createdAt: "2026-06-19T10:00:00.000Z",
51
+ updatedAt: "2026-06-19T10:05:00.000Z",
52
+ location: "current",
53
+ tasks: { research: { state: "done" } },
54
+ costsSummary: {
55
+ totalTokens: 100,
56
+ totalInputTokens: 60,
57
+ totalOutputTokens: 40,
58
+ totalCost: 0.5,
59
+ totalInputCost: 0.3,
60
+ totalOutputCost: 0.2,
61
+ estimatedCost: 0,
62
+ hasEstimated: false,
63
+ unavailableCost: 0,
64
+ },
65
+ };
66
+
67
+ // Act
68
+ const event = parseSseEvent("job:created", data);
69
+
70
+ // Assert
71
+ expect(event).not.toBeNull();
72
+ expect(event?.type).toBe("job:created");
73
+ expect(event?.data).toEqual(data);
74
+ });
75
+
76
+ it("parses job:updated with a complete JobWire payload", () => {
77
+ // Arrange
78
+ const data = {
79
+ jobId: "job-abc",
80
+ title: "My Job",
81
+ status: "complete",
82
+ progress: 100,
83
+ createdAt: "2026-06-19T10:00:00.000Z",
84
+ updatedAt: "2026-06-19T10:30:00.000Z",
85
+ location: "current",
86
+ tasks: {},
87
+ costsSummary: {
88
+ totalTokens: 200,
89
+ totalInputTokens: 120,
90
+ totalOutputTokens: 80,
91
+ totalCost: 1.0,
92
+ totalInputCost: 0.6,
93
+ totalOutputCost: 0.4,
94
+ estimatedCost: 0,
95
+ hasEstimated: false,
96
+ unavailableCost: 0,
97
+ },
98
+ };
99
+
100
+ // Act
101
+ const event = parseSseEvent("job:updated", data);
102
+
103
+ // Assert
104
+ expect(event).not.toBeNull();
105
+ expect(event?.type).toBe("job:updated");
106
+ expect(event?.data).toEqual(data);
107
+ });
108
+
109
+ it("parses job:removed with required jobId", () => {
110
+ // Arrange
111
+ const data = { jobId: "job-removed" };
112
+
113
+ // Act
114
+ const event = parseSseEvent("job:removed", data);
115
+
116
+ // Assert
117
+ expect(event).not.toBeNull();
118
+ expect(event?.type).toBe("job:removed");
119
+ expect(event?.data).toEqual(data);
120
+ });
121
+
122
+ it("parses heartbeat with ok and timestamp", () => {
123
+ // Arrange
124
+ const data = { ok: true, timestamp: "2026-06-19T11:00:00.000Z" };
125
+
126
+ // Act
127
+ const event = parseSseEvent("heartbeat", data);
128
+
129
+ // Assert
130
+ expect(event).not.toBeNull();
131
+ expect(event?.type).toBe("heartbeat");
132
+ expect(event?.data).toEqual(data);
133
+ });
134
+
135
+ it("parses seed:uploaded with an empty payload", () => {
136
+ // Arrange
137
+ const data = {};
138
+
139
+ // Act
140
+ const event = parseSseEvent("seed:uploaded", data);
141
+
142
+ // Assert
143
+ expect(event).not.toBeNull();
144
+ expect(event?.type).toBe("seed:uploaded");
145
+ expect(event?.data).toEqual({});
146
+ });
147
+ });
148
+
149
+ describe("parseSseEvent rejects unknown or invalid input", () => {
150
+ it("returns null for an unknown type string", () => {
151
+ // Arrange / Act
152
+ const event = parseSseEvent("task:updated", { jobId: "job-1" });
153
+
154
+ // Assert
155
+ expect(event).toBeNull();
156
+ });
157
+
158
+ it("returns null for the empty string", () => {
159
+ // Arrange / Act
160
+ const event = parseSseEvent("", { jobId: "job-1" });
161
+
162
+ // Assert
163
+ expect(event).toBeNull();
164
+ });
165
+
166
+ it("returns null for a non-string type", () => {
167
+ // Arrange / Act
168
+ const event = parseSseEvent(undefined as unknown as string, { jobId: "job-1" });
169
+
170
+ // Assert
171
+ expect(event).toBeNull();
172
+ });
173
+
174
+ it("returns null when state:change is missing jobId", () => {
175
+ // Arrange
176
+ const data = { path: "pipeline-data/current/job-1/tasks-status.json" };
177
+
178
+ // Act
179
+ const event = parseSseEvent("state:change", data);
180
+
181
+ // Assert
182
+ expect(event).toBeNull();
183
+ });
184
+
185
+ it("returns null when job:created is missing jobId", () => {
186
+ // Arrange
187
+ const data = {
188
+ title: "x",
189
+ status: "pending",
190
+ progress: 0,
191
+ createdAt: null,
192
+ updatedAt: null,
193
+ location: null,
194
+ tasks: {},
195
+ costsSummary: {
196
+ totalTokens: 0,
197
+ totalInputTokens: 0,
198
+ totalOutputTokens: 0,
199
+ totalCost: 0,
200
+ totalInputCost: 0,
201
+ totalOutputCost: 0,
202
+ },
203
+ };
204
+
205
+ // Act
206
+ const event = parseSseEvent("job:created", data);
207
+
208
+ // Assert
209
+ expect(event).toBeNull();
210
+ });
211
+
212
+ it("returns null when job:updated is missing jobId", () => {
213
+ // Arrange
214
+ const data = {
215
+ title: "x",
216
+ status: "running",
217
+ progress: 50,
218
+ createdAt: null,
219
+ updatedAt: null,
220
+ location: null,
221
+ tasks: {},
222
+ costsSummary: {
223
+ totalTokens: 0,
224
+ totalInputTokens: 0,
225
+ totalOutputTokens: 0,
226
+ totalCost: 0,
227
+ totalInputCost: 0,
228
+ totalOutputCost: 0,
229
+ },
230
+ };
231
+
232
+ // Act
233
+ const event = parseSseEvent("job:updated", data);
234
+
235
+ // Assert
236
+ expect(event).toBeNull();
237
+ });
238
+
239
+ it("returns null when job:removed is missing jobId", () => {
240
+ // Arrange
241
+ const data = {};
242
+
243
+ // Act
244
+ const event = parseSseEvent("job:removed", data);
245
+
246
+ // Assert
247
+ expect(event).toBeNull();
248
+ });
249
+
250
+ it("returns null when heartbeat has a non-boolean ok", () => {
251
+ // Arrange
252
+ const data = { ok: "yes", timestamp: "2026-06-19T11:00:00.000Z" };
253
+
254
+ // Act
255
+ const event = parseSseEvent("heartbeat", data);
256
+
257
+ // Assert
258
+ expect(event).toBeNull();
259
+ });
260
+
261
+ it("returns null when job:created has a mistyped status", () => {
262
+ // Arrange
263
+ const data = {
264
+ jobId: "job-1",
265
+ title: "x",
266
+ status: "done",
267
+ progress: 0,
268
+ createdAt: null,
269
+ updatedAt: null,
270
+ location: null,
271
+ tasks: {},
272
+ costsSummary: {
273
+ totalTokens: 0,
274
+ totalInputTokens: 0,
275
+ totalOutputTokens: 0,
276
+ totalCost: 0,
277
+ totalInputCost: 0,
278
+ totalOutputCost: 0,
279
+ },
280
+ };
281
+
282
+ // Act
283
+ const event = parseSseEvent("job:created", data);
284
+
285
+ // Assert
286
+ expect(event).toBeNull();
287
+ });
288
+ });
289
+
290
+ describe("forward-compat: extra unknown fields are preserved (passthrough)", () => {
291
+ it("state:change accepts and preserves unknown fields alongside jobId and path", () => {
292
+ // Arrange
293
+ const data = {
294
+ jobId: "job-1",
295
+ path: "pipeline-data/current/job-1/tasks-status.json",
296
+ lifecycle: "current",
297
+ unknownField: 42,
298
+ nested: { foo: "bar" },
299
+ };
300
+
301
+ // Act
302
+ const event = parseSseEvent("state:change", data);
303
+
304
+ // Assert
305
+ expect(event).not.toBeNull();
306
+ expect(event?.data).toEqual(data);
307
+ });
308
+
309
+ it("job:updated accepts and preserves unknown fields beyond the schema", () => {
310
+ // Arrange
311
+ const data = {
312
+ jobId: "job-1",
313
+ title: "x",
314
+ status: "running",
315
+ progress: 10,
316
+ createdAt: null,
317
+ updatedAt: null,
318
+ location: null,
319
+ tasks: {},
320
+ costsSummary: {
321
+ totalTokens: 0,
322
+ totalInputTokens: 0,
323
+ totalOutputTokens: 0,
324
+ totalCost: 0,
325
+ totalInputCost: 0,
326
+ totalOutputCost: 0,
327
+ },
328
+ futureField: "ok",
329
+ };
330
+
331
+ // Act
332
+ const event = parseSseEvent("job:updated", data);
333
+
334
+ // Assert
335
+ expect(event).not.toBeNull();
336
+ expect((event?.data as Record<string, unknown>).futureField).toBe("ok");
337
+ });
338
+
339
+ it("heartbeat accepts extra fields beyond ok and timestamp", () => {
340
+ // Arrange
341
+ const data = {
342
+ ok: true,
343
+ timestamp: "2026-06-19T11:00:00.000Z",
344
+ serverVersion: "1.2.3",
345
+ };
346
+
347
+ // Act
348
+ const event = parseSseEvent("heartbeat", data);
349
+
350
+ // Assert
351
+ expect(event).not.toBeNull();
352
+ expect((event?.data as Record<string, unknown>).serverVersion).toBe("1.2.3");
353
+ });
354
+
355
+ it("seed:uploaded accepts any payload shape", () => {
356
+ // Arrange
357
+ const data = { filename: "seed.json", size: 1234 };
358
+
359
+ // Act
360
+ const event = parseSseEvent("seed:uploaded", data);
361
+
362
+ // Assert
363
+ expect(event).not.toBeNull();
364
+ expect(event?.data).toEqual(data);
365
+ });
366
+ });
367
+ });
368
+
369
+ describe("emitSseEvent", () => {
370
+ it("broadcasts a validated event with type and data", () => {
371
+ // Arrange
372
+ const broadcasts: Array<{ type: string; data: unknown }> = [];
373
+ const registry = {
374
+ broadcast(type: string, data: unknown) {
375
+ broadcasts.push({ type, data });
376
+ },
377
+ };
378
+ const event = {
379
+ type: "heartbeat" as const,
380
+ data: { ok: true, timestamp: "2026-06-19T11:00:00.000Z" },
381
+ };
382
+
383
+ // Act
384
+ emitSseEvent(registry, event);
385
+
386
+ // Assert
387
+ expect(broadcasts).toHaveLength(1);
388
+ expect(broadcasts[0]?.type).toBe("heartbeat");
389
+ expect(broadcasts[0]?.data).toEqual(event.data);
390
+ });
391
+
392
+ it("throws and does not broadcast when the event fails re-validation", () => {
393
+ // Arrange
394
+ const broadcasts: Array<{ type: string; data: unknown }> = [];
395
+ const registry = {
396
+ broadcast(type: string, data: unknown) {
397
+ broadcasts.push({ type, data });
398
+ },
399
+ };
400
+ const badEvent = {
401
+ type: "heartbeat" as const,
402
+ data: { ok: "not-a-boolean", timestamp: "2026-06-19T11:00:00.000Z" },
403
+ } as unknown as Parameters<typeof emitSseEvent>[1];
404
+
405
+ // Act + Assert
406
+ expect(() => emitSseEvent(registry, badEvent)).toThrow(/heartbeat/);
407
+ expect(broadcasts).toHaveLength(0);
408
+ });
409
+ });
410
+
411
+ describe("SseEvent discriminated union", () => {
412
+ it("SseEvent.parse accepts a state:change payload", () => {
413
+ // Arrange
414
+ const payload = {
415
+ type: "state:change",
416
+ data: { jobId: "j", path: "/p" },
417
+ };
418
+
419
+ // Act
420
+ const result = SseEvent.safeParse(payload);
421
+
422
+ // Assert
423
+ expect(result.success).toBe(true);
424
+ });
425
+
426
+ it("SseEvent.parse rejects a payload whose data does not match the discriminator", () => {
427
+ // Arrange
428
+ const payload = {
429
+ type: "state:change",
430
+ data: { jobId: 123, path: "/p" },
431
+ };
432
+
433
+ // Act
434
+ const result = SseEvent.safeParse(payload);
435
+
436
+ // Assert
437
+ expect(result.success).toBe(false);
438
+ });
439
+ });
440
+
441
+ describe("DOCUMENTED_SSE_EVENTS", () => {
442
+ it("lists exactly the seven browser-facing event names", () => {
443
+ // Arrange / Act
444
+ const names = [...DOCUMENTED_SSE_EVENTS].sort();
445
+
446
+ // Assert
447
+ expect(names).toEqual([
448
+ "heartbeat",
449
+ "job:created",
450
+ "job:removed",
451
+ "job:updated",
452
+ "seed:uploaded",
453
+ "state:change",
454
+ "state:summary",
455
+ ]);
456
+ });
457
+
458
+ it("does not list task:updated (legacy dead event)", () => {
459
+ // Arrange / Act / Assert
460
+ expect(DOCUMENTED_SSE_EVENTS).not.toContain("task:updated");
461
+ });
462
+
463
+ it("SseEventType is assignable from each documented name", () => {
464
+ // Arrange
465
+ const sample: SseEventType = DOCUMENTED_SSE_EVENTS[0];
466
+
467
+ // Assert — this assignment compiles only if every name is a valid SseEventType
468
+ expect(typeof sample).toBe("string");
469
+ });
470
+ });
@@ -8,6 +8,7 @@ import {
8
8
  VALID_JOB_LOCATIONS,
9
9
  normalizeTaskState,
10
10
  normalizeJobStatus,
11
+ jobStateToStatus,
11
12
  deriveJobStatusFromTasks,
12
13
  } from "../statuses";
13
14
 
@@ -159,6 +160,10 @@ describe("normalizeJobStatus", () => {
159
160
  expect(normalizeJobStatus("completed")).toBe("complete");
160
161
  });
161
162
 
163
+ it("handles synonym: done → complete", () => {
164
+ expect(normalizeJobStatus("done")).toBe("complete");
165
+ });
166
+
162
167
  it("handles synonym: error → failed", () => {
163
168
  expect(normalizeJobStatus("error")).toBe("failed");
164
169
  });
@@ -193,6 +198,42 @@ describe("normalizeJobStatus", () => {
193
198
  });
194
199
  });
195
200
 
201
+ describe("jobStateToStatus", () => {
202
+ it("returns canonical for each recognized JobState", () => {
203
+ expect(jobStateToStatus("pending")).toBe("pending");
204
+ expect(jobStateToStatus("running")).toBe("running");
205
+ expect(jobStateToStatus("waiting")).toBe("waiting");
206
+ expect(jobStateToStatus("failed")).toBe("failed");
207
+ });
208
+
209
+ it("maps done (persisted JobState) to complete (canonical JobStatusValue)", () => {
210
+ expect(jobStateToStatus("done")).toBe("complete");
211
+ });
212
+
213
+ it("is case-insensitive and trims whitespace", () => {
214
+ expect(jobStateToStatus("DONE")).toBe("complete");
215
+ expect(jobStateToStatus(" failed ")).toBe("failed");
216
+ expect(jobStateToStatus("Waiting")).toBe("waiting");
217
+ });
218
+
219
+ it("returns null for non-string input", () => {
220
+ expect(jobStateToStatus(null)).toBeNull();
221
+ expect(jobStateToStatus(undefined)).toBeNull();
222
+ expect(jobStateToStatus(42)).toBeNull();
223
+ expect(jobStateToStatus({})).toBeNull();
224
+ });
225
+
226
+ it("returns null for unrecognized strings", () => {
227
+ expect(jobStateToStatus("bogus-state")).toBeNull();
228
+ });
229
+
230
+ it("does not accept JobStatus synonyms that are not JobState values", () => {
231
+ expect(jobStateToStatus("error")).toBeNull();
232
+ expect(jobStateToStatus("completed")).toBeNull();
233
+ expect(jobStateToStatus("complete")).toBeNull();
234
+ });
235
+ });
236
+
196
237
  describe("deriveJobStatusFromTasks", () => {
197
238
  it("returns failed when any task is failed (failed priority)", () => {
198
239
  expect(deriveJobStatusFromTasks([{ state: "failed" }, { state: "done" }])).toBe("failed");
@@ -1,5 +1,6 @@
1
- import { join } from "node:path";
2
- import type { JobLocationValue } from "./statuses";
1
+ import { join, resolve } from "node:path";
2
+
3
+ type JobLocationValue = "pending" | "current" | "complete" | "rejected";
3
4
 
4
5
  export interface PipelinePaths {
5
6
  readonly pending: string;
@@ -57,3 +58,11 @@ export function getJobPipelinePath(
57
58
  ): string {
58
59
  return join(getJobDirectoryPath(baseDir, jobId, location), "pipeline.json");
59
60
  }
61
+
62
+ export function resolveWorkspaceRoot(explicit?: string): string {
63
+ const raw = explicit ?? process.env["PO_ROOT"];
64
+ if (!raw) {
65
+ throw new Error("Workspace root is required: pass --root or set PO_ROOT");
66
+ }
67
+ return resolve(raw);
68
+ }