@ryanfw/prompt-orchestration-pipeline 1.3.3 → 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 (198) hide show
  1. package/docs/http-api.md +14 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +144 -41
  4. package/src/api/index.ts +15 -83
  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/paths.ts +11 -2
  17. package/src/config/pipeline-registry.ts +258 -0
  18. package/src/config/sse-events.ts +122 -0
  19. package/src/core/__tests__/agent-step.test.ts +115 -0
  20. package/src/core/__tests__/config.test.ts +517 -107
  21. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  22. package/src/core/__tests__/job-concurrency.test.ts +200 -0
  23. package/src/core/__tests__/job-submission.test.ts +396 -0
  24. package/src/core/__tests__/job-view.test.ts +485 -3
  25. package/src/core/__tests__/json-file.test.ts +111 -0
  26. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  27. package/src/core/__tests__/logger.test.ts +22 -0
  28. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  29. package/src/core/__tests__/orchestrator.test.ts +373 -2
  30. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  31. package/src/core/__tests__/redact.test.ts +153 -0
  32. package/src/core/__tests__/runner-liveness.test.ts +45 -0
  33. package/src/core/__tests__/seed-naming.test.ts +57 -0
  34. package/src/core/__tests__/single-derivation.test.ts +1 -1
  35. package/src/core/__tests__/task-runner.test.ts +594 -3
  36. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  37. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  38. package/src/core/agent-step.ts +4 -1
  39. package/src/core/agent-types.ts +5 -4
  40. package/src/core/config.ts +127 -234
  41. package/src/core/control.ts +3 -0
  42. package/src/core/job-concurrency.ts +50 -19
  43. package/src/core/job-submission.ts +133 -0
  44. package/src/core/job-view.ts +162 -18
  45. package/src/core/json-file.ts +45 -0
  46. package/src/core/lifecycle-policy.ts +23 -8
  47. package/src/core/logger.ts +0 -29
  48. package/src/core/orchestrator.ts +124 -70
  49. package/src/core/pipeline-runner.ts +85 -53
  50. package/src/core/redact.ts +40 -0
  51. package/src/core/runner-liveness.ts +8 -4
  52. package/src/core/seed-naming.ts +9 -0
  53. package/src/core/status-writer.ts +3 -28
  54. package/src/core/task-runner.ts +356 -319
  55. package/src/core/task-telemetry.ts +107 -0
  56. package/src/harness/__tests__/subprocess.test.ts +112 -1
  57. package/src/harness/subprocess.ts +55 -16
  58. package/src/llm/__tests__/index.test.ts +684 -33
  59. package/src/llm/index.ts +310 -67
  60. package/src/providers/__tests__/alibaba.test.ts +67 -6
  61. package/src/providers/__tests__/anthropic.test.ts +35 -14
  62. package/src/providers/__tests__/base.test.ts +62 -0
  63. package/src/providers/__tests__/claude-code.test.ts +99 -14
  64. package/src/providers/__tests__/deepseek.test.ts +16 -6
  65. package/src/providers/__tests__/gemini.test.ts +47 -25
  66. package/src/providers/__tests__/moonshot.test.ts +27 -0
  67. package/src/providers/__tests__/openai.test.ts +262 -74
  68. package/src/providers/__tests__/opencode.test.ts +77 -0
  69. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  70. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  71. package/src/providers/__tests__/types.test.ts +85 -0
  72. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  73. package/src/providers/__tests__/zhipu.test.ts +27 -14
  74. package/src/providers/alibaba.ts +20 -39
  75. package/src/providers/anthropic.ts +23 -11
  76. package/src/providers/base.ts +19 -0
  77. package/src/providers/claude-code.ts +27 -18
  78. package/src/providers/deepseek.ts +9 -28
  79. package/src/providers/gemini.ts +20 -58
  80. package/src/providers/moonshot.ts +15 -11
  81. package/src/providers/openai.ts +79 -61
  82. package/src/providers/opencode.ts +16 -33
  83. package/src/providers/stream-accumulator.ts +27 -21
  84. package/src/providers/types.ts +29 -4
  85. package/src/providers/zhipu.ts +15 -44
  86. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  87. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  88. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  89. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  90. package/src/task-analysis/__tests__/types.test.ts +63 -130
  91. package/src/task-analysis/analyzer.ts +91 -0
  92. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  93. package/src/task-analysis/index.ts +2 -36
  94. package/src/task-analysis/repository.ts +45 -0
  95. package/src/task-analysis/types.ts +42 -58
  96. package/src/ui/client/__tests__/api.test.ts +143 -1
  97. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  98. package/src/ui/client/__tests__/job-adapter.test.ts +125 -2
  99. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  100. package/src/ui/client/__tests__/types.test.ts +66 -3
  101. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  102. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  103. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  104. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  105. package/src/ui/client/adapters/job-adapter.ts +31 -16
  106. package/src/ui/client/api.ts +38 -15
  107. package/src/ui/client/bootstrap.ts +19 -14
  108. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  109. package/src/ui/client/hooks/useJobList.ts +26 -31
  110. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  111. package/src/ui/client/load-state.ts +20 -0
  112. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  113. package/src/ui/client/reducers/job-events.ts +137 -0
  114. package/src/ui/client/types.ts +14 -20
  115. package/src/ui/components/DAGGrid.tsx +6 -1
  116. package/src/ui/components/JobDetail.tsx +12 -4
  117. package/src/ui/components/JobTable.tsx +13 -2
  118. package/src/ui/components/StageTimeline.tsx +8 -20
  119. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  120. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  121. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  122. package/src/ui/components/__tests__/JobTable.test.tsx +83 -0
  123. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  124. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  125. package/src/ui/components/types.ts +33 -15
  126. package/src/ui/dist/assets/{index-L6cvsCAx.js → index-DN3-zvtP.js} +4299 -251
  127. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  128. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  129. package/src/ui/dist/index.html +2 -2
  130. package/src/ui/pages/PipelineDetail.tsx +60 -4
  131. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  132. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  133. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  134. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  135. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  136. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  137. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  138. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  139. package/src/ui/server/__tests__/index.test.ts +63 -0
  140. package/src/ui/server/__tests__/job-control-endpoints.test.ts +455 -1
  141. package/src/ui/server/__tests__/job-endpoints.test.ts +43 -1
  142. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  143. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  144. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  145. package/src/ui/server/__tests__/router.test.ts +104 -0
  146. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  147. package/src/ui/server/__tests__/sse-enhancer.test.ts +39 -0
  148. package/src/ui/server/config-bridge-node.ts +8 -26
  149. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  150. package/src/ui/server/embedded-assets.ts +13 -2
  151. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  152. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  153. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  154. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  155. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  156. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  157. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  158. package/src/ui/server/endpoints/job-control-endpoints.ts +122 -73
  159. package/src/ui/server/endpoints/job-endpoints.ts +1 -0
  160. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  161. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  162. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  163. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  164. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  165. package/src/ui/server/index.ts +19 -14
  166. package/src/ui/server/job-reader.ts +14 -2
  167. package/src/ui/server/router.ts +33 -10
  168. package/src/ui/server/sse-broadcast.ts +14 -3
  169. package/src/ui/server/sse-enhancer.ts +12 -2
  170. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  171. package/src/ui/state/__tests__/snapshot.test.ts +70 -15
  172. package/src/ui/state/__tests__/types.test.ts +6 -0
  173. package/src/ui/state/snapshot.ts +1 -1
  174. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +42 -0
  175. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +45 -3
  176. package/src/ui/state/transformers/list-transformer.ts +6 -0
  177. package/src/ui/state/types.ts +6 -1
  178. package/src/utils/__tests__/path-containment.test.ts +160 -0
  179. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  180. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  181. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  182. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  183. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  184. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  185. package/src/task-analysis/__tests__/index.test.ts +0 -143
  186. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  187. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  188. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  189. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  190. package/src/task-analysis/extractors/artifacts.ts +0 -143
  191. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  192. package/src/task-analysis/extractors/stages.ts +0 -45
  193. package/src/task-analysis/parser.ts +0 -20
  194. package/src/task-analysis/utils/ast.ts +0 -45
  195. package/src/ui/dist/assets/index-L6cvsCAx.js.map +0 -1
  196. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  197. package/src/ui/embedded-assets.js +0 -12
  198. 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
+ });
@@ -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
+ }