@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
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { mkdtemp, rm, mkdir, readdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
@@ -29,8 +29,11 @@ async function scaffoldWorkspace(tmpDir: string): Promise<void> {
29
29
  await Bun.write(
30
30
  join(tmpDir, "pipeline-config", "registry.json"),
31
31
  JSON.stringify({
32
+ version: 1,
32
33
  pipelines: {
33
34
  "test-pipeline": {
35
+ name: "Test Pipeline",
36
+ description: "test pipeline",
34
37
  configDir: join(tmpDir, "pipeline-config", "test-pipeline"),
35
38
  tasksDir: join(tmpDir, "pipeline-config", "test-pipeline", "tasks"),
36
39
  },
@@ -60,7 +63,7 @@ describe("submitJobWithValidation", () => {
60
63
 
61
64
  it("writes seed file to pending and returns success", async () => {
62
65
  const result = await submitJobWithValidation({
63
- dataDir: tmpDir,
66
+ root: tmpDir,
64
67
  seedObject: { pipeline: "test-pipeline" },
65
68
  });
66
69
 
@@ -79,7 +82,7 @@ describe("submitJobWithValidation", () => {
79
82
 
80
83
  it("returns seed name as jobName when provided", async () => {
81
84
  const result = await submitJobWithValidation({
82
- dataDir: tmpDir,
85
+ root: tmpDir,
83
86
  seedObject: { pipeline: "test-pipeline", name: "my-job" },
84
87
  });
85
88
 
@@ -87,74 +90,88 @@ describe("submitJobWithValidation", () => {
87
90
  expect((result as SubmitSuccessResult).jobName).toBe("my-job");
88
91
  });
89
92
 
90
- it("rejects non-object seed with message containing 'JSON object'", async () => {
93
+ it("rejects non-object seed with exact message", async () => {
91
94
  const result = await submitJobWithValidation({
92
- dataDir: tmpDir,
95
+ root: tmpDir,
93
96
  seedObject: "not an object",
94
97
  });
95
98
 
96
99
  expect(result.success).toBe(false);
97
- expect((result as SubmitFailureResult).message).toContain("JSON object");
100
+ expect((result as SubmitFailureResult).message).toBe("seed must be a JSON object");
98
101
  });
99
102
 
100
- it("rejects array seed with message containing 'JSON object'", async () => {
103
+ it("rejects array seed with exact message", async () => {
101
104
  const result = await submitJobWithValidation({
102
- dataDir: tmpDir,
105
+ root: tmpDir,
103
106
  seedObject: [1, 2, 3],
104
107
  });
105
108
 
106
109
  expect(result.success).toBe(false);
107
- expect((result as SubmitFailureResult).message).toContain("JSON object");
110
+ expect((result as SubmitFailureResult).message).toBe("seed must be a JSON object");
108
111
  });
109
112
 
110
- it("rejects null seed with message containing 'JSON object'", async () => {
113
+ it("rejects null seed with exact message", async () => {
111
114
  const result = await submitJobWithValidation({
112
- dataDir: tmpDir,
115
+ root: tmpDir,
113
116
  seedObject: null,
114
117
  });
115
118
 
116
119
  expect(result.success).toBe(false);
117
- expect((result as SubmitFailureResult).message).toContain("JSON object");
120
+ expect((result as SubmitFailureResult).message).toBe("seed must be a JSON object");
118
121
  });
119
122
 
120
- it("rejects seed missing pipeline field with message containing 'pipeline'", async () => {
123
+ it("rejects seed missing pipeline field with exact message", async () => {
121
124
  const result = await submitJobWithValidation({
122
- dataDir: tmpDir,
125
+ root: tmpDir,
123
126
  seedObject: { name: "my-job" },
124
127
  });
125
128
 
126
129
  expect(result.success).toBe(false);
127
- expect((result as SubmitFailureResult).message).toContain("pipeline");
130
+ expect((result as SubmitFailureResult).message).toBe("seed.pipeline must be a non-empty string");
128
131
  });
129
132
 
130
133
  it("rejects seed with empty pipeline string", async () => {
131
134
  const result = await submitJobWithValidation({
132
- dataDir: tmpDir,
135
+ root: tmpDir,
133
136
  seedObject: { pipeline: "" },
134
137
  });
135
138
 
136
139
  expect(result.success).toBe(false);
137
- expect((result as SubmitFailureResult).message).toContain("pipeline");
140
+ expect((result as SubmitFailureResult).message).toBe("seed.pipeline must be a non-empty string");
138
141
  });
139
142
 
140
- it("rejects non-existent pipeline slug with message containing 'not found'", async () => {
143
+ it("rejects non-string pipeline value", async () => {
141
144
  const result = await submitJobWithValidation({
142
- dataDir: tmpDir,
145
+ root: tmpDir,
146
+ seedObject: { pipeline: 123 },
147
+ });
148
+
149
+ expect(result.success).toBe(false);
150
+ expect((result as SubmitFailureResult).message).toBe("seed.pipeline must be a non-empty string");
151
+ });
152
+
153
+ it("rejects non-existent pipeline slug with exact message", async () => {
154
+ const result = await submitJobWithValidation({
155
+ root: tmpDir,
143
156
  seedObject: { pipeline: "does-not-exist" },
144
157
  });
145
158
 
146
159
  expect(result.success).toBe(false);
147
- expect((result as SubmitFailureResult).message).toContain("not found");
160
+ expect((result as SubmitFailureResult).message).toBe(
161
+ "Pipeline 'does-not-exist' not found in registry",
162
+ );
148
163
  });
149
164
 
150
165
  it("rejects seed with empty name string", async () => {
151
166
  const result = await submitJobWithValidation({
152
- dataDir: tmpDir,
167
+ root: tmpDir,
153
168
  seedObject: { pipeline: "test-pipeline", name: "" },
154
169
  });
155
170
 
156
171
  expect(result.success).toBe(false);
157
- expect((result as SubmitFailureResult).message).toContain("name");
172
+ expect((result as SubmitFailureResult).message).toBe(
173
+ "seed.name must be a non-empty string if provided",
174
+ );
158
175
  });
159
176
  });
160
177
 
@@ -196,7 +213,7 @@ describe("PipelineOrchestrator.getStatus", () => {
196
213
  }),
197
214
  );
198
215
 
199
- const orch = new PipelineOrchestrator({ autoStart: false });
216
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
200
217
  const record = await orch.getStatus(jobId);
201
218
 
202
219
  expect(record.jobId).toBe(jobId);
@@ -214,17 +231,18 @@ describe("PipelineOrchestrator.getStatus", () => {
214
231
  id: jobId,
215
232
  name: "done-job",
216
233
  pipeline: "test-pipeline",
217
- state: "complete",
234
+ state: "done",
218
235
  createdAt: "2026-01-01T00:00:00.000Z",
219
236
  tasks: {},
220
237
  }),
221
238
  );
222
239
 
223
- const orch = new PipelineOrchestrator({ autoStart: false });
240
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
224
241
  const record = await orch.getStatus(jobId);
225
242
 
226
243
  expect(record.jobId).toBe(jobId);
227
244
  expect(record.jobName).toBe("done-job");
245
+ expect(record.state).toBe("complete");
228
246
  });
229
247
 
230
248
  it("finds job by name scan fallback", async () => {
@@ -243,7 +261,7 @@ describe("PipelineOrchestrator.getStatus", () => {
243
261
  }),
244
262
  );
245
263
 
246
- const orch = new PipelineOrchestrator({ autoStart: false });
264
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
247
265
  const record = await orch.getStatus("named-job");
248
266
 
249
267
  expect(record.jobId).toBe(jobId);
@@ -257,7 +275,7 @@ describe("PipelineOrchestrator.getStatus", () => {
257
275
  JSON.stringify({ pipeline: "test-pipeline", name: "pending-job" }),
258
276
  );
259
277
 
260
- const orch = new PipelineOrchestrator({ autoStart: false });
278
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
261
279
  const record = await orch.getStatus(jobId);
262
280
 
263
281
  expect(record.jobId).toBe(jobId);
@@ -267,11 +285,78 @@ describe("PipelineOrchestrator.getStatus", () => {
267
285
  expect(record.createdAt).toBeTruthy();
268
286
  });
269
287
 
288
+ it("returns pending record for non-UUID seed filename parsed by parseSeedFilename", async () => {
289
+ const jobId = "my-job";
290
+ await Bun.write(
291
+ join(tmpDir, "pipeline-data", "pending", `${jobId}-seed.json`),
292
+ JSON.stringify({ pipeline: "test-pipeline", name: "my-job" }),
293
+ );
294
+
295
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
296
+ const record = await orch.getStatus(jobId);
297
+
298
+ expect(record.jobId).toBe(jobId);
299
+ expect(record.jobName).toBe("my-job");
300
+ expect(record.state).toBe("pending");
301
+ expect(record.pipeline).toBe("test-pipeline");
302
+ });
303
+
270
304
  it("throws for nonexistent job with message containing 'not found'", async () => {
271
- const orch = new PipelineOrchestrator({ autoStart: false });
305
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
272
306
 
273
307
  await expect(orch.getStatus("nonexistent-id")).rejects.toThrow("not found");
274
308
  });
309
+
310
+ it("returns unreadable placeholder for corrupt current job instead of throwing", async () => {
311
+ const jobId = "33333333-4444-5555-6666-777777777777";
312
+ const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
313
+ await mkdir(jobDir, { recursive: true });
314
+ await Bun.write(join(jobDir, "tasks-status.json"), "{{{not valid json");
315
+
316
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
317
+ const record = await orch.getStatus(jobId);
318
+
319
+ expect(record.jobId).toBe(jobId);
320
+ expect(record.state).toBe("failed");
321
+ expect(record.readable).toBe(false);
322
+ expect(record.error).toBeDefined();
323
+ const error = record.error as { code: string; message: string; path: string };
324
+ expect(error.code).toBe("STATUS_CORRUPT");
325
+ expect(error.path).toBe(join(jobDir, "tasks-status.json"));
326
+ expect(error.message).toContain("Repair or delete");
327
+ expect(error.message).toContain(jobId);
328
+ });
329
+
330
+ it("skips unexpected current read errors and falls back to complete lookup", async () => {
331
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
332
+ const jobId = "33333333-4444-5555-6666-888888888888";
333
+ const currentDir = join(tmpDir, "pipeline-data", "current", jobId);
334
+ await mkdir(currentDir, { recursive: true });
335
+ await mkdir(join(currentDir, "tasks-status.json"));
336
+
337
+ const completeDir = join(tmpDir, "pipeline-data", "complete", jobId);
338
+ await mkdir(completeDir, { recursive: true });
339
+ await Bun.write(
340
+ join(completeDir, "tasks-status.json"),
341
+ JSON.stringify({
342
+ id: jobId,
343
+ name: "complete-fallback",
344
+ pipeline: "test-pipeline",
345
+ state: "done",
346
+ createdAt: "2026-01-01T00:00:00.000Z",
347
+ tasks: {},
348
+ }),
349
+ );
350
+
351
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
352
+ const record = await orch.getStatus(jobId);
353
+
354
+ expect(record.jobId).toBe(jobId);
355
+ expect(record.jobName).toBe("complete-fallback");
356
+ expect(record.state).toBe("complete");
357
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining(jobId));
358
+ warn.mockRestore();
359
+ });
275
360
  });
276
361
 
277
362
  // ─── PipelineOrchestrator.listJobs ───────────────────────────────────────────
@@ -330,13 +415,13 @@ describe("PipelineOrchestrator.listJobs", () => {
330
415
  id: completeId,
331
416
  name: "complete-one",
332
417
  pipeline: "test-pipeline",
333
- state: "complete",
418
+ state: "done",
334
419
  createdAt: "2026-01-01T00:00:00.000Z",
335
420
  tasks: {},
336
421
  }),
337
422
  );
338
423
 
339
- const orch = new PipelineOrchestrator({ autoStart: false });
424
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
340
425
  const jobs = await orch.listJobs();
341
426
 
342
427
  expect(jobs.length).toBe(3);
@@ -354,7 +439,7 @@ describe("PipelineOrchestrator.listJobs", () => {
354
439
  });
355
440
 
356
441
  it("returns empty array when all directories are empty", async () => {
357
- const orch = new PipelineOrchestrator({ autoStart: false });
442
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
358
443
  const jobs = await orch.listJobs();
359
444
 
360
445
  expect(jobs).toEqual([]);
@@ -364,9 +449,335 @@ describe("PipelineOrchestrator.listJobs", () => {
364
449
  // Remove all pipeline-data directories
365
450
  await rm(join(tmpDir, "pipeline-data"), { recursive: true, force: true });
366
451
 
367
- const orch = new PipelineOrchestrator({ autoStart: false });
452
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
453
+ const jobs = await orch.listJobs();
454
+
455
+ expect(jobs).toEqual([]);
456
+ });
457
+
458
+ it("surfaces corrupt current job as unreadable placeholder alongside valid and pending jobs", async () => {
459
+ const pendingId = "bbbb-1111-2222-3333-0001";
460
+ await Bun.write(
461
+ join(tmpDir, "pipeline-data", "pending", `${pendingId}-seed.json`),
462
+ JSON.stringify({ pipeline: "test-pipeline", name: "pending-one" }),
463
+ );
464
+
465
+ const validCurrentId = "bbbb-1111-2222-3333-0002";
466
+ const validCurrentDir = join(tmpDir, "pipeline-data", "current", validCurrentId);
467
+ await mkdir(validCurrentDir, { recursive: true });
468
+ await Bun.write(
469
+ join(validCurrentDir, "tasks-status.json"),
470
+ JSON.stringify({
471
+ id: validCurrentId,
472
+ name: "valid-current",
473
+ pipeline: "test-pipeline",
474
+ state: "running",
475
+ createdAt: "2026-01-01T00:00:00.000Z",
476
+ tasks: {},
477
+ }),
478
+ );
479
+
480
+ const corruptCurrentId = "bbbb-1111-2222-3333-0003";
481
+ const corruptCurrentDir = join(tmpDir, "pipeline-data", "current", corruptCurrentId);
482
+ await mkdir(corruptCurrentDir, { recursive: true });
483
+ await Bun.write(join(corruptCurrentDir, "tasks-status.json"), "{{{not valid json");
484
+
485
+ const completeId = "bbbb-1111-2222-3333-0004";
486
+ const completeDir = join(tmpDir, "pipeline-data", "complete", completeId);
487
+ await mkdir(completeDir, { recursive: true });
488
+ await Bun.write(
489
+ join(completeDir, "tasks-status.json"),
490
+ JSON.stringify({
491
+ id: completeId,
492
+ name: "complete-one",
493
+ pipeline: "test-pipeline",
494
+ state: "done",
495
+ createdAt: "2026-01-01T00:00:00.000Z",
496
+ tasks: {},
497
+ }),
498
+ );
499
+
500
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
501
+ const jobs = await orch.listJobs();
502
+
503
+ expect(jobs.length).toBe(4);
504
+
505
+ const ids = jobs.map((j) => j.jobId);
506
+ expect(ids).toContain(pendingId);
507
+ expect(ids).toContain(validCurrentId);
508
+ expect(ids).toContain(corruptCurrentId);
509
+ expect(ids).toContain(completeId);
510
+
511
+ const corrupt = jobs.find((j) => j.jobId === corruptCurrentId)!;
512
+ expect(corrupt.state).toBe("failed");
513
+ expect(corrupt.readable).toBe(false);
514
+ const corruptError = corrupt.error as { code: string; message: string; path: string };
515
+ expect(corruptError.code).toBe("STATUS_CORRUPT");
516
+ expect(corruptError.path).toBe(join(corruptCurrentDir, "tasks-status.json"));
517
+ expect(corruptError.message).toContain("Repair or delete");
518
+ expect(corruptError.message).toContain(corruptCurrentId);
519
+
520
+ const valid = jobs.find((j) => j.jobId === validCurrentId)!;
521
+ expect(valid.readable).toBeUndefined();
522
+ expect(valid.error).toBeUndefined();
523
+ expect(valid.jobName).toBe("valid-current");
524
+
525
+ const pending = jobs.find((j) => j.jobId === pendingId)!;
526
+ expect(pending.state).toBe("pending");
527
+ expect(pending.readable).toBeUndefined();
528
+ });
529
+
530
+ it("skips current job whose status file is missing", async () => {
531
+ const missingId = "cccc-1111-2222-3333-0001";
532
+ const missingDir = join(tmpDir, "pipeline-data", "current", missingId);
533
+ await mkdir(missingDir, { recursive: true });
534
+
535
+ const validId = "cccc-1111-2222-3333-0002";
536
+ const validDir = join(tmpDir, "pipeline-data", "current", validId);
537
+ await mkdir(validDir, { recursive: true });
538
+ await Bun.write(
539
+ join(validDir, "tasks-status.json"),
540
+ JSON.stringify({
541
+ id: validId,
542
+ name: "valid-current",
543
+ pipeline: "test-pipeline",
544
+ state: "running",
545
+ createdAt: "2026-01-01T00:00:00.000Z",
546
+ tasks: {},
547
+ }),
548
+ );
549
+
550
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
551
+ const jobs = await orch.listJobs();
552
+
553
+ const ids = jobs.map((j) => j.jobId);
554
+ expect(ids).toContain(validId);
555
+ expect(ids).not.toContain(missingId);
556
+ });
557
+
558
+ it("skips current job with unexpected status read error without throwing", async () => {
559
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
560
+ const unreadableId = "dddd-1111-2222-3333-0001";
561
+ const unreadableDir = join(tmpDir, "pipeline-data", "current", unreadableId);
562
+ await mkdir(unreadableDir, { recursive: true });
563
+ await mkdir(join(unreadableDir, "tasks-status.json"));
564
+
565
+ const validId = "dddd-1111-2222-3333-0002";
566
+ const validDir = join(tmpDir, "pipeline-data", "current", validId);
567
+ await mkdir(validDir, { recursive: true });
568
+ await Bun.write(
569
+ join(validDir, "tasks-status.json"),
570
+ JSON.stringify({
571
+ id: validId,
572
+ name: "valid-current",
573
+ pipeline: "test-pipeline",
574
+ state: "running",
575
+ createdAt: "2026-01-01T00:00:00.000Z",
576
+ tasks: {},
577
+ }),
578
+ );
579
+
580
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
581
+ const jobs = await orch.listJobs();
582
+
583
+ const ids = jobs.map((j) => j.jobId);
584
+ expect(ids).toContain(validId);
585
+ expect(ids).not.toContain(unreadableId);
586
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining(unreadableId));
587
+ warn.mockRestore();
588
+ });
589
+
590
+ it("getStatus and listJobs return identical state for the same fixture", async () => {
591
+ const jobId = "cccc-5555-6666-7777-0001";
592
+ const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
593
+ await mkdir(jobDir, { recursive: true });
594
+ await Bun.write(
595
+ join(jobDir, "tasks-status.json"),
596
+ JSON.stringify({
597
+ id: jobId,
598
+ name: "cross-surface",
599
+ pipeline: "test-pipeline",
600
+ state: "running",
601
+ createdAt: "2026-01-01T00:00:00.000Z",
602
+ tasks: {
603
+ a: { state: "running" },
604
+ b: { state: "pending" },
605
+ },
606
+ }),
607
+ );
608
+
609
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
610
+ const getStatusRecord = await orch.getStatus(jobId);
611
+ const listJobsRecords = await orch.listJobs();
612
+ const listJobsRecord = listJobsRecords.find((j) => j.jobId === jobId)!;
613
+
614
+ expect(getStatusRecord.state).toBe("running");
615
+ expect(listJobsRecord.state).toBe("running");
616
+ expect(getStatusRecord.state).toBe(listJobsRecord.state);
617
+ });
618
+
619
+ it("getStatus returns canonical 'complete' for persisted 'done' state", async () => {
620
+ const jobId = "cccc-5555-6666-7777-0002";
621
+ const jobDir = join(tmpDir, "pipeline-data", "complete", jobId);
622
+ await mkdir(jobDir, { recursive: true });
623
+ await Bun.write(
624
+ join(jobDir, "tasks-status.json"),
625
+ JSON.stringify({
626
+ id: jobId,
627
+ name: "done-canonical",
628
+ pipeline: "test-pipeline",
629
+ state: "done",
630
+ createdAt: "2026-01-01T00:00:00.000Z",
631
+ tasks: { a: { state: "done" } },
632
+ }),
633
+ );
634
+
635
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
636
+ const record = await orch.getStatus(jobId);
637
+
638
+ expect(record.state).toBe("complete");
639
+ });
640
+
641
+ it("listJobs returns canonical 'complete' for persisted 'done' state without force", async () => {
642
+ const jobId = "cccc-5555-6666-7777-0003";
643
+ const jobDir = join(tmpDir, "pipeline-data", "complete", jobId);
644
+ await mkdir(jobDir, { recursive: true });
645
+ await Bun.write(
646
+ join(jobDir, "tasks-status.json"),
647
+ JSON.stringify({
648
+ id: jobId,
649
+ name: "done-list",
650
+ pipeline: "test-pipeline",
651
+ state: "done",
652
+ createdAt: "2026-01-01T00:00:00.000Z",
653
+ tasks: { a: { state: "done" } },
654
+ }),
655
+ );
656
+
657
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
368
658
  const jobs = await orch.listJobs();
659
+ const record = jobs.find((j) => j.jobId === jobId)!;
660
+
661
+ expect(record.state).toBe("complete");
662
+ });
663
+
664
+ it("getStatus maps persisted 'error' legacy status to canonical 'failed'", async () => {
665
+ const jobId = "cccc-5555-6666-7777-0004";
666
+ const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
667
+ await mkdir(jobDir, { recursive: true });
668
+ await Bun.write(
669
+ join(jobDir, "tasks-status.json"),
670
+ JSON.stringify({
671
+ id: jobId,
672
+ name: "legacy-error",
673
+ pipeline: "test-pipeline",
674
+ state: "unknown-state",
675
+ status: "error",
676
+ createdAt: "2026-01-01T00:00:00.000Z",
677
+ tasks: { a: { state: "failed" } },
678
+ }),
679
+ );
680
+
681
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
682
+ const record = await orch.getStatus(jobId);
683
+
684
+ expect(record.state).toBe("failed");
685
+ });
369
686
 
687
+ it("listJobs preserves JobView extras (costs)", async () => {
688
+ const jobId = "cccc-5555-6666-7777-0005";
689
+ const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
690
+ await mkdir(jobDir, { recursive: true });
691
+ await Bun.write(
692
+ join(jobDir, "tasks-status.json"),
693
+ JSON.stringify({
694
+ id: jobId,
695
+ name: "with-extras",
696
+ pipeline: "test-pipeline",
697
+ state: "running",
698
+ progress: 42,
699
+ createdAt: "2026-01-01T00:00:00.000Z",
700
+ costs: { usd: 1.23 },
701
+ tasks: { a: { state: "running" } },
702
+ }),
703
+ );
704
+
705
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
706
+ const jobs = await orch.listJobs();
707
+ const record = jobs.find((j) => j.jobId === jobId)!;
708
+
709
+ expect(record.state).toBe("running");
710
+ expect(record.progress).toBe(0);
711
+ expect(record.costs).toEqual({ usd: 1.23 });
712
+ });
713
+ });
714
+
715
+ // ─── AC-4: API strict root resolution ─────────────────────────────────────────
716
+
717
+ describe("API strict root resolution (AC-4)", () => {
718
+ let tmpDir: string;
719
+ let savedPoRoot: string | undefined;
720
+
721
+ beforeEach(async () => {
722
+ tmpDir = await mkdtemp(join(tmpdir(), "pop-api-strict-test-"));
723
+ await scaffoldWorkspace(tmpDir);
724
+ savedPoRoot = process.env["PO_ROOT"];
725
+ delete process.env["PO_ROOT"];
726
+ });
727
+
728
+ afterEach(async () => {
729
+ if (savedPoRoot === undefined) {
730
+ delete process.env["PO_ROOT"];
731
+ } else {
732
+ process.env["PO_ROOT"] = savedPoRoot;
733
+ }
734
+ await rm(tmpDir, { recursive: true, force: true });
735
+ });
736
+
737
+ it("PipelineOrchestrator throws when neither root nor PO_ROOT is set", () => {
738
+ expect(() => new PipelineOrchestrator({ autoStart: false })).toThrow(
739
+ "Workspace root is required",
740
+ );
741
+ });
742
+
743
+ it("PipelineOrchestrator succeeds when root is set explicitly", async () => {
744
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
745
+ const jobs = await orch.listJobs();
746
+ expect(jobs).toEqual([]);
747
+ });
748
+
749
+ it("PipelineOrchestrator succeeds when PO_ROOT is set and root is absent", async () => {
750
+ process.env["PO_ROOT"] = tmpDir;
751
+ const orch = new PipelineOrchestrator({ autoStart: false });
752
+ const jobs = await orch.listJobs();
370
753
  expect(jobs).toEqual([]);
371
754
  });
755
+
756
+ it("submitJobWithValidation throws when neither root nor PO_ROOT is set", async () => {
757
+ await expect(
758
+ submitJobWithValidation({ seedObject: { pipeline: "test-pipeline" } }),
759
+ ).rejects.toThrow("Workspace root is required");
760
+ });
761
+
762
+ it("submitJobWithValidation succeeds when root is set explicitly", async () => {
763
+ const result = await submitJobWithValidation({
764
+ root: tmpDir,
765
+ seedObject: { pipeline: "test-pipeline" },
766
+ });
767
+ expect(result.success).toBe(true);
768
+ const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
769
+ const seedFile = pendingFiles.find((f) => f.endsWith("-seed.json"));
770
+ expect(seedFile).toBeDefined();
771
+ });
772
+
773
+ it("submitJobWithValidation succeeds when PO_ROOT is set and root is absent", async () => {
774
+ process.env["PO_ROOT"] = tmpDir;
775
+ const result = await submitJobWithValidation({
776
+ seedObject: { pipeline: "test-pipeline" },
777
+ });
778
+ expect(result.success).toBe(true);
779
+ const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
780
+ const seedFile = pendingFiles.find((f) => f.endsWith("-seed.json"));
781
+ expect(seedFile).toBeDefined();
782
+ });
372
783
  });