@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
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
 
7
7
  import { handleTaskFile, handleTaskFileList } from "../endpoints/file-endpoints";
8
- import { initPATHS, resetPATHS } from "../config-bridge-node";
8
+ import { resetPATHS } from "../config-bridge-node";
9
9
 
10
10
  const tempRoots: string[] = [];
11
11
 
@@ -26,7 +26,6 @@ describe("file endpoints", () => {
26
26
  it("lists task files from tasks-status metadata", async () => {
27
27
  const root = await makeTempRoot();
28
28
  process.env["PO_ROOT"] = root;
29
- initPATHS(root);
30
29
 
31
30
  const jobDir = path.join(root, "pipeline-data", "current", "job-1");
32
31
  await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
@@ -46,7 +45,7 @@ describe("file endpoints", () => {
46
45
  }),
47
46
  );
48
47
 
49
- const response = await handleTaskFileList(new Request("http://localhost/api/jobs/job-1/tasks/research/files?type=artifacts"), "job-1", "research");
48
+ const response = await handleTaskFileList(new Request("http://localhost/api/jobs/job-1/tasks/research/files?type=artifacts"), "job-1", "research", root);
50
49
 
51
50
  expect(response.status).toBe(200);
52
51
  await expect(response.json()).resolves.toEqual({ ok: true, data: ["research-output.json"] });
@@ -55,7 +54,6 @@ describe("file endpoints", () => {
55
54
  it("reads task-owned files from the shared job files directory", async () => {
56
55
  const root = await makeTempRoot();
57
56
  process.env["PO_ROOT"] = root;
58
- initPATHS(root);
59
57
 
60
58
  const jobDir = path.join(root, "pipeline-data", "current", "job-1");
61
59
  await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
@@ -80,6 +78,7 @@ describe("file endpoints", () => {
80
78
  new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=research-output.json"),
81
79
  "job-1",
82
80
  "research",
81
+ root,
83
82
  );
84
83
 
85
84
  expect(response.status).toBe(200);
@@ -93,7 +92,6 @@ describe("file endpoints", () => {
93
92
  it("rejects files not declared for the task", async () => {
94
93
  const root = await makeTempRoot();
95
94
  process.env["PO_ROOT"] = root;
96
- initPATHS(root);
97
95
 
98
96
  const jobDir = path.join(root, "pipeline-data", "current", "job-1");
99
97
  await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
@@ -118,6 +116,7 @@ describe("file endpoints", () => {
118
116
  new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=other.json"),
119
117
  "job-1",
120
118
  "research",
119
+ root,
121
120
  );
122
121
 
123
122
  expect(response.status).toBe(404);
@@ -127,4 +126,183 @@ describe("file endpoints", () => {
127
126
  message: "file not found for task: artifacts/other.json",
128
127
  });
129
128
  });
129
+
130
+ it("reads files from the threaded root, ignoring PO_ROOT (AC-5)", async () => {
131
+ const threadedRoot = await makeTempRoot();
132
+ const envRoot = await makeTempRoot();
133
+ process.env["PO_ROOT"] = envRoot;
134
+
135
+ const jobDir = path.join(threadedRoot, "pipeline-data", "current", "job-1");
136
+ await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
137
+ await writeFile(path.join(jobDir, "files", "artifacts", "threaded.json"), '{"source":"threaded"}\n');
138
+ await writeFile(
139
+ path.join(jobDir, "tasks-status.json"),
140
+ JSON.stringify({
141
+ id: "job-1",
142
+ tasks: {
143
+ research: {
144
+ files: {
145
+ artifacts: ["threaded.json"],
146
+ logs: [],
147
+ tmp: [],
148
+ },
149
+ },
150
+ },
151
+ }),
152
+ );
153
+
154
+ const response = await handleTaskFile(
155
+ new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=threaded.json"),
156
+ "job-1",
157
+ "research",
158
+ threadedRoot,
159
+ );
160
+
161
+ expect(response.status).toBe(200);
162
+ await expect(response.json()).resolves.toMatchObject({
163
+ ok: true,
164
+ data: '{"source":"threaded"}\n',
165
+ mime: "application/json",
166
+ });
167
+ });
168
+
169
+ it("rejects a traversal type query param with 400 (AC-5)", async () => {
170
+ const root = await makeTempRoot();
171
+ process.env["PO_ROOT"] = root;
172
+
173
+ const jobDir = path.join(root, "pipeline-data", "current", "job-1");
174
+ await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
175
+ await writeFile(
176
+ path.join(jobDir, "tasks-status.json"),
177
+ JSON.stringify({
178
+ id: "job-1",
179
+ tasks: { research: { files: { artifacts: ["x.json"], logs: [], tmp: [] } } },
180
+ }),
181
+ );
182
+
183
+ const response = await handleTaskFile(
184
+ new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=../..&filename=x.json"),
185
+ "job-1",
186
+ "research",
187
+ root,
188
+ );
189
+
190
+ expect(response.status).toBe(400);
191
+ await expect(response.json()).resolves.toEqual({ ok: false, code: "BAD_REQUEST", message: "invalid filename" });
192
+ });
193
+
194
+ it("rejects a traversal filename query param with 400 (AC-5)", async () => {
195
+ const root = await makeTempRoot();
196
+ process.env["PO_ROOT"] = root;
197
+
198
+ const jobDir = path.join(root, "pipeline-data", "current", "job-1");
199
+ await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
200
+ await writeFile(
201
+ path.join(jobDir, "tasks-status.json"),
202
+ JSON.stringify({
203
+ id: "job-1",
204
+ tasks: { research: { files: { artifacts: ["x.json"], logs: [], tmp: [] } } },
205
+ }),
206
+ );
207
+
208
+ const response = await handleTaskFile(
209
+ new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=../../secret"),
210
+ "job-1",
211
+ "research",
212
+ root,
213
+ );
214
+
215
+ expect(response.status).toBe(400);
216
+ await expect(response.json()).resolves.toEqual({ ok: false, code: "BAD_REQUEST", message: "invalid filename" });
217
+ });
218
+
219
+ it("returns 404 for a listed-but-absent task file (AC-5)", async () => {
220
+ const root = await makeTempRoot();
221
+ process.env["PO_ROOT"] = root;
222
+
223
+ const jobDir = path.join(root, "pipeline-data", "current", "job-1");
224
+ await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
225
+ await writeFile(
226
+ path.join(jobDir, "tasks-status.json"),
227
+ JSON.stringify({
228
+ id: "job-1",
229
+ tasks: { research: { files: { artifacts: ["ghost.json"], logs: [], tmp: [] } } },
230
+ }),
231
+ );
232
+
233
+ const response = await handleTaskFile(
234
+ new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=ghost.json"),
235
+ "job-1",
236
+ "research",
237
+ root,
238
+ );
239
+
240
+ expect(response.status).toBe(404);
241
+ await expect(response.json()).resolves.toEqual({
242
+ ok: false,
243
+ code: "NOT_FOUND",
244
+ message: "file not found for task: artifacts/ghost.json",
245
+ });
246
+ });
247
+
248
+ it("reads a nested filename listed in task metadata (AC-5)", async () => {
249
+ const root = await makeTempRoot();
250
+ process.env["PO_ROOT"] = root;
251
+
252
+ const jobDir = path.join(root, "pipeline-data", "current", "job-1");
253
+ await mkdir(path.join(jobDir, "files", "artifacts", "data"), { recursive: true });
254
+ await writeFile(path.join(jobDir, "files", "artifacts", "data", "foo.json"), '{"nested":true}\n');
255
+ await writeFile(
256
+ path.join(jobDir, "tasks-status.json"),
257
+ JSON.stringify({
258
+ id: "job-1",
259
+ tasks: { research: { files: { artifacts: ["data/foo.json"], logs: [], tmp: [] } } },
260
+ }),
261
+ );
262
+
263
+ const response = await handleTaskFile(
264
+ new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=data/foo.json"),
265
+ "job-1",
266
+ "research",
267
+ root,
268
+ );
269
+
270
+ expect(response.status).toBe(200);
271
+ await expect(response.json()).resolves.toMatchObject({
272
+ ok: true,
273
+ data: '{"nested":true}\n',
274
+ mime: "application/json",
275
+ });
276
+ });
277
+
278
+ it("does not double-decode already-decoded query values (AC-5)", async () => {
279
+ const root = await makeTempRoot();
280
+ process.env["PO_ROOT"] = root;
281
+
282
+ const jobDir = path.join(root, "pipeline-data", "current", "job-1");
283
+ await mkdir(path.join(jobDir, "files", "artifacts"), { recursive: true });
284
+ await writeFile(path.join(root, "secret.json"), "LEAKED");
285
+ await writeFile(
286
+ path.join(jobDir, "tasks-status.json"),
287
+ JSON.stringify({
288
+ id: "job-1",
289
+ tasks: { research: { files: { artifacts: ["real.json"], logs: [], tmp: [] } } },
290
+ }),
291
+ );
292
+
293
+ const response = await handleTaskFile(
294
+ new Request("http://localhost/api/jobs/job-1/tasks/research/file?type=artifacts&filename=%252e%252e"),
295
+ "job-1",
296
+ "research",
297
+ root,
298
+ );
299
+
300
+ expect(response.status).toBe(404);
301
+ await expect(response.json()).resolves.toEqual({
302
+ ok: false,
303
+ code: "NOT_FOUND",
304
+ message: "file not found for task: artifacts/%2e%2e",
305
+ });
306
+ expect(await Bun.file(path.join(root, "secret.json")).text()).toBe("LEAKED");
307
+ });
130
308
  });
@@ -168,6 +168,61 @@ describe("handleGateDecision", () => {
168
168
  expect(events[0]).toMatchObject({ type: "gate_decided", action: "reject", afterTask: "plan", note: "needs another pass" });
169
169
  });
170
170
 
171
+ it("rejecting a control_invalid gate marks the job failed while preserving task done + controlError (AC-10)", async () => {
172
+ const root = await makeTempRoot();
173
+ initPATHS(root);
174
+ const jobId = "reject-control-invalid-job";
175
+ const statusOverride = makeSnapshot({
176
+ gate: {
177
+ afterTask: "plan",
178
+ kind: "control_invalid",
179
+ message: "Control file invalid for plan: bad patch. Repair tasks/plan/control.json and approve to retry.",
180
+ requestedAt: "2026-04-01T10:00:00.000Z",
181
+ },
182
+ tasks: {
183
+ plan: {
184
+ state: "done",
185
+ executionTimeMs: 99,
186
+ refinementAttempts: 1,
187
+ controlApplied: false,
188
+ controlError: { message: "bad patch", violations: ["bad patch"] },
189
+ },
190
+ implement: { state: "pending" },
191
+ },
192
+ });
193
+ const jobDir = await setupJob(root, jobId, statusOverride);
194
+ const spawnSpy = vi.spyOn(Bun, "spawn");
195
+
196
+ const res = await handleGateDecision(
197
+ jobId,
198
+ gateRequest(jobId, { action: "reject", note: "control file is too broken" }),
199
+ root,
200
+ );
201
+ const body = await res.json() as Record<string, unknown>;
202
+
203
+ expect(res.status).toBe(202);
204
+ expect(body).toMatchObject({ ok: true, jobId, action: "reject", spawned: false });
205
+ expect(spawnSpy).not.toHaveBeenCalled();
206
+
207
+ const snapshot = await readJobStatus(jobDir);
208
+ expect(snapshot!.state).toBe("failed");
209
+ expect(snapshot!.gate).toBeNull();
210
+ expect(snapshot!["error"]).toEqual({ name: "GateRejected", message: "control file is too broken" });
211
+
212
+ const task = snapshot!.tasks["plan"] as {
213
+ state?: string;
214
+ executionTimeMs?: number;
215
+ refinementAttempts?: number;
216
+ controlApplied?: boolean;
217
+ controlError?: { message?: string; violations?: string[] };
218
+ };
219
+ expect(task.state).toBe("done");
220
+ expect(task.executionTimeMs).toBe(99);
221
+ expect(task.refinementAttempts).toBe(1);
222
+ expect(task.controlApplied).toBe(false);
223
+ expect(task.controlError).toEqual({ message: "bad patch", violations: ["bad patch"] });
224
+ });
225
+
171
226
  it("returns 409 when another gate decision already holds the job slot", async () => {
172
227
  const root = await makeTempRoot();
173
228
  initPATHS(root);
@@ -80,6 +80,28 @@ describe("server index", () => {
80
80
  await handle.close();
81
81
  }
82
82
  });
83
+
84
+ it("rejects startServer before binding when env config is invalid", async () => {
85
+ const root = await makeTempRoot();
86
+ const priorPoRoot = process.env["PO_ROOT"];
87
+ const priorMaxRunningJobs = process.env["PO_MAX_RUNNING_JOBS"];
88
+ const serveSpy = vi.spyOn(Bun, "serve");
89
+ try {
90
+ process.env["PO_ROOT"] = root;
91
+ process.env["PO_MAX_RUNNING_JOBS"] = "foo";
92
+ resetConfig();
93
+
94
+ await expect(startServer({ dataDir: root, port: 4113 })).rejects.toThrow(/PO_MAX_RUNNING_JOBS/);
95
+ expect(serveSpy).not.toHaveBeenCalled();
96
+ } finally {
97
+ serveSpy.mockRestore();
98
+ if (priorMaxRunningJobs === undefined) delete process.env["PO_MAX_RUNNING_JOBS"];
99
+ else process.env["PO_MAX_RUNNING_JOBS"] = priorMaxRunningJobs;
100
+ if (priorPoRoot === undefined) delete process.env["PO_ROOT"];
101
+ else process.env["PO_ROOT"] = priorPoRoot;
102
+ resetConfig();
103
+ }
104
+ });
83
105
  });
84
106
 
85
107
  describe("initializeWatcher concurrency paths", () => {
@@ -151,3 +173,44 @@ describe("initializeWatcher concurrency paths", () => {
151
173
  spy.mockRestore();
152
174
  });
153
175
  });
176
+
177
+ describe("startServer workspace-root resolution (AC-7)", () => {
178
+ it("resolves via explicit dataDir even when PO_ROOT points elsewhere", async () => {
179
+ const explicitRoot = await makeTempRoot();
180
+ const envRoot = await makeTempRoot();
181
+ const priorPoRoot = process.env["PO_ROOT"];
182
+
183
+ try {
184
+ process.env["PO_ROOT"] = envRoot;
185
+ await mkdir(path.join(explicitRoot, "pipeline-data", "current", "explicit-job"), { recursive: true });
186
+ await writeFile(
187
+ path.join(explicitRoot, "pipeline-data", "current", "explicit-job", "tasks-status.json"),
188
+ '{"id":"explicit-job","tasks":{}}',
189
+ );
190
+
191
+ const handle = await startServer({ dataDir: explicitRoot, port: 4114 });
192
+ try {
193
+ const response = await fetch(`${handle.url}/api/jobs/explicit-job`);
194
+ expect(response.status).toBe(200);
195
+ } finally {
196
+ await handle.close();
197
+ }
198
+ } finally {
199
+ if (priorPoRoot === undefined) delete process.env["PO_ROOT"];
200
+ else process.env["PO_ROOT"] = priorPoRoot;
201
+ }
202
+ });
203
+
204
+ it("throws the resolver error when PO_ROOT is unset and dataDir is empty", async () => {
205
+ const priorPoRoot = process.env["PO_ROOT"];
206
+ try {
207
+ delete process.env["PO_ROOT"];
208
+ await expect(
209
+ startServer({ dataDir: "" as string, port: 4115 }),
210
+ ).rejects.toThrow(/Workspace root is required/);
211
+ } finally {
212
+ if (priorPoRoot === undefined) delete process.env["PO_ROOT"];
213
+ else process.env["PO_ROOT"] = priorPoRoot;
214
+ }
215
+ });
216
+ });