@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
package/docs/http-api.md CHANGED
@@ -203,11 +203,21 @@ Clients should treat a gap longer than the keep-alive interval as a potential di
203
203
 
204
204
  | Event | Data Shape | Description |
205
205
  |-------|-----------|-------------|
206
- | `state:change` | `{ jobId, changeType, ... }` | A job's state has changed |
207
- | `state:summary` | `{ changeCount }` | Summary of accumulated state changes |
208
- | `job:created` | `JobSummary` | A new job was detected |
209
- | `job:updated` | `JobSummary` | An existing job was updated |
206
+ | `state:change` | `{ jobId, path }` | A job's state file changed; the `jobId` is derived from the changed path |
207
+ | `state:summary` | `{ changeCount }` | Summary of accumulated state changes when no per-job change is prioritized |
208
+ | `job:created` | `JobSummary` | A new job was detected (summary granularity; see Caveat below) |
209
+ | `job:updated` | `JobSummary` | An existing job was updated (summary granularity; see Caveat below) |
210
+ | `job:removed` | `{ jobId }` | A job was removed from a watched location |
210
211
  | `heartbeat` | `{ ok, timestamp }` | Application-level heartbeat (distinct from the keep-alive comment) |
212
+ | `seed:uploaded` | `{}` | A seed was uploaded; clients should refetch the job list |
213
+
214
+ The keep-alive comment (`: keep-alive`) sent on a fixed interval is **not** an application event;
215
+ clients must not treat it as one. Unknown event types and unknown fields within a known event
216
+ are tolerated by the contract; clients should ignore them. `task:updated`, `status:changed`,
217
+ bare `state`, and `message` are **not** part of the browser-facing contract and are not
218
+ emitted on `/api/events`. `job:created` and `job:updated` carry summary-granularity
219
+ `JobSummary` data; deep per-task detail (costs, files, per-stage state) continues to arrive
220
+ via the existing `state:change` → refetch path.
211
221
 
212
222
  ### Job Filtering
213
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
5
5
  "type": "module",
6
6
  "main": "src/ui/server/index.ts",
@@ -42,9 +42,6 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "dependencies": {
45
- "@babel/parser": "^7.28.5",
46
- "@babel/traverse": "^7.28.5",
47
- "@babel/types": "^7.28.5",
48
45
  "@radix-ui/react-progress": "^1.1.7",
49
46
  "@radix-ui/react-tabs": "^1.1.13",
50
47
  "@radix-ui/react-toast": "^1.2.15",
@@ -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);
@@ -220,7 +237,7 @@ describe("PipelineOrchestrator.getStatus", () => {
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);
@@ -244,7 +261,7 @@ describe("PipelineOrchestrator.getStatus", () => {
244
261
  }),
245
262
  );
246
263
 
247
- const orch = new PipelineOrchestrator({ autoStart: false });
264
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
248
265
  const record = await orch.getStatus("named-job");
249
266
 
250
267
  expect(record.jobId).toBe(jobId);
@@ -258,7 +275,7 @@ describe("PipelineOrchestrator.getStatus", () => {
258
275
  JSON.stringify({ pipeline: "test-pipeline", name: "pending-job" }),
259
276
  );
260
277
 
261
- const orch = new PipelineOrchestrator({ autoStart: false });
278
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
262
279
  const record = await orch.getStatus(jobId);
263
280
 
264
281
  expect(record.jobId).toBe(jobId);
@@ -268,8 +285,24 @@ describe("PipelineOrchestrator.getStatus", () => {
268
285
  expect(record.createdAt).toBeTruthy();
269
286
  });
270
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
+
271
304
  it("throws for nonexistent job with message containing 'not found'", async () => {
272
- const orch = new PipelineOrchestrator({ autoStart: false });
305
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
273
306
 
274
307
  await expect(orch.getStatus("nonexistent-id")).rejects.toThrow("not found");
275
308
  });
@@ -280,7 +313,7 @@ describe("PipelineOrchestrator.getStatus", () => {
280
313
  await mkdir(jobDir, { recursive: true });
281
314
  await Bun.write(join(jobDir, "tasks-status.json"), "{{{not valid json");
282
315
 
283
- const orch = new PipelineOrchestrator({ autoStart: false });
316
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
284
317
  const record = await orch.getStatus(jobId);
285
318
 
286
319
  expect(record.jobId).toBe(jobId);
@@ -315,7 +348,7 @@ describe("PipelineOrchestrator.getStatus", () => {
315
348
  }),
316
349
  );
317
350
 
318
- const orch = new PipelineOrchestrator({ autoStart: false });
351
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
319
352
  const record = await orch.getStatus(jobId);
320
353
 
321
354
  expect(record.jobId).toBe(jobId);
@@ -388,7 +421,7 @@ describe("PipelineOrchestrator.listJobs", () => {
388
421
  }),
389
422
  );
390
423
 
391
- const orch = new PipelineOrchestrator({ autoStart: false });
424
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
392
425
  const jobs = await orch.listJobs();
393
426
 
394
427
  expect(jobs.length).toBe(3);
@@ -406,7 +439,7 @@ describe("PipelineOrchestrator.listJobs", () => {
406
439
  });
407
440
 
408
441
  it("returns empty array when all directories are empty", async () => {
409
- const orch = new PipelineOrchestrator({ autoStart: false });
442
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
410
443
  const jobs = await orch.listJobs();
411
444
 
412
445
  expect(jobs).toEqual([]);
@@ -416,7 +449,7 @@ describe("PipelineOrchestrator.listJobs", () => {
416
449
  // Remove all pipeline-data directories
417
450
  await rm(join(tmpDir, "pipeline-data"), { recursive: true, force: true });
418
451
 
419
- const orch = new PipelineOrchestrator({ autoStart: false });
452
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
420
453
  const jobs = await orch.listJobs();
421
454
 
422
455
  expect(jobs).toEqual([]);
@@ -464,7 +497,7 @@ describe("PipelineOrchestrator.listJobs", () => {
464
497
  }),
465
498
  );
466
499
 
467
- const orch = new PipelineOrchestrator({ autoStart: false });
500
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
468
501
  const jobs = await orch.listJobs();
469
502
 
470
503
  expect(jobs.length).toBe(4);
@@ -514,7 +547,7 @@ describe("PipelineOrchestrator.listJobs", () => {
514
547
  }),
515
548
  );
516
549
 
517
- const orch = new PipelineOrchestrator({ autoStart: false });
550
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
518
551
  const jobs = await orch.listJobs();
519
552
 
520
553
  const ids = jobs.map((j) => j.jobId);
@@ -544,7 +577,7 @@ describe("PipelineOrchestrator.listJobs", () => {
544
577
  }),
545
578
  );
546
579
 
547
- const orch = new PipelineOrchestrator({ autoStart: false });
580
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
548
581
  const jobs = await orch.listJobs();
549
582
 
550
583
  const ids = jobs.map((j) => j.jobId);
@@ -573,7 +606,7 @@ describe("PipelineOrchestrator.listJobs", () => {
573
606
  }),
574
607
  );
575
608
 
576
- const orch = new PipelineOrchestrator({ autoStart: false });
609
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
577
610
  const getStatusRecord = await orch.getStatus(jobId);
578
611
  const listJobsRecords = await orch.listJobs();
579
612
  const listJobsRecord = listJobsRecords.find((j) => j.jobId === jobId)!;
@@ -599,7 +632,7 @@ describe("PipelineOrchestrator.listJobs", () => {
599
632
  }),
600
633
  );
601
634
 
602
- const orch = new PipelineOrchestrator({ autoStart: false });
635
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
603
636
  const record = await orch.getStatus(jobId);
604
637
 
605
638
  expect(record.state).toBe("complete");
@@ -621,7 +654,7 @@ describe("PipelineOrchestrator.listJobs", () => {
621
654
  }),
622
655
  );
623
656
 
624
- const orch = new PipelineOrchestrator({ autoStart: false });
657
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
625
658
  const jobs = await orch.listJobs();
626
659
  const record = jobs.find((j) => j.jobId === jobId)!;
627
660
 
@@ -645,13 +678,13 @@ describe("PipelineOrchestrator.listJobs", () => {
645
678
  }),
646
679
  );
647
680
 
648
- const orch = new PipelineOrchestrator({ autoStart: false });
681
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
649
682
  const record = await orch.getStatus(jobId);
650
683
 
651
684
  expect(record.state).toBe("failed");
652
685
  });
653
686
 
654
- it("listJobs preserves JobView extras (progress, costs)", async () => {
687
+ it("listJobs preserves JobView extras (costs)", async () => {
655
688
  const jobId = "cccc-5555-6666-7777-0005";
656
689
  const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
657
690
  await mkdir(jobDir, { recursive: true });
@@ -669,12 +702,82 @@ describe("PipelineOrchestrator.listJobs", () => {
669
702
  }),
670
703
  );
671
704
 
672
- const orch = new PipelineOrchestrator({ autoStart: false });
705
+ const orch = new PipelineOrchestrator({ autoStart: false, root: tmpDir });
673
706
  const jobs = await orch.listJobs();
674
707
  const record = jobs.find((j) => j.jobId === jobId)!;
675
708
 
676
709
  expect(record.state).toBe("running");
677
- expect(record.progress).toBe(42);
710
+ expect(record.progress).toBe(0);
678
711
  expect(record.costs).toEqual({ usd: 1.23 });
679
712
  });
680
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();
753
+ expect(jobs).toEqual([]);
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
+ });
783
+ });
package/src/api/index.ts CHANGED
@@ -1,31 +1,19 @@
1
1
  import path from "node:path";
2
- import { mkdir, readdir, rename, stat } from "node:fs/promises";
3
- import { resolvePipelinePaths, getPendingSeedPath } from "../config/paths";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { resolvePipelinePaths, resolveWorkspaceRoot } from "../config/paths";
4
4
  import type { PipelinePaths } from "../config/paths";
5
- import { getPipelineConfig } from "../core/config";
5
+ import { submitSeed } from "../core/job-submission.ts";
6
+ import type { SubmitResult } from "../core/job-submission.ts";
6
7
  import { normalizeJobView } from "../core/job-view";
7
8
  import type { JobView } from "../core/job-view";
8
- import { SEED_PATTERN } from "../core/orchestrator";
9
+ import { parseSeedFilename } from "../core/seed-naming.ts";
9
10
  import { readJobStatusResult, STATUS_FILENAME, type StatusReadResult } from "../core/status-writer";
10
11
 
11
- /** Result of a successful job submission. */
12
- export interface SubmitSuccessResult {
13
- success: true;
14
- jobId: string;
15
- jobName: string;
16
- }
17
-
18
- /** Result of a failed job submission. */
19
- export interface SubmitFailureResult {
20
- success: false;
21
- message: string;
22
- }
23
-
24
- export type SubmitResult = SubmitSuccessResult | SubmitFailureResult;
12
+ export type { SubmitResult, SubmitSuccessResult, SubmitFailureResult } from "../core/job-submission.ts";
25
13
 
26
14
  /** Options for submitJobWithValidation. */
27
15
  export interface SubmitJobOptions {
28
- dataDir: string;
16
+ root?: string;
29
17
  seedObject: unknown;
30
18
  }
31
19
 
@@ -47,28 +35,11 @@ export interface JobStatusRecord {
47
35
  /** Orchestrator construction options. */
48
36
  export interface OrchestratorOptions {
49
37
  autoStart: boolean;
38
+ root?: string;
50
39
  }
51
40
 
52
41
  // ─── Private Helpers ──────────────────────────────────────────────────────────
53
42
 
54
- async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
55
- const tmp = `${filePath}.${Date.now()}.tmp`;
56
- await Bun.write(tmp, JSON.stringify(data, null, 2));
57
- await rename(tmp, filePath);
58
- }
59
-
60
- function assertSeedObject(value: unknown): asserts value is Record<string, unknown> {
61
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
62
- throw new Error("seed must be a JSON object");
63
- }
64
- }
65
-
66
- function assertPipelineSlug(value: unknown): asserts value is string {
67
- if (typeof value !== "string" || value.length === 0) {
68
- throw new Error("seed.pipeline must be a non-empty string");
69
- }
70
- }
71
-
72
43
  async function safeReaddir(dirPath: string): Promise<string[]> {
73
44
  try {
74
45
  return await readdir(dirPath);
@@ -147,44 +118,8 @@ async function readStatusForApi(jobDir: string, label: string): Promise<StatusRe
147
118
  export async function submitJobWithValidation(
148
119
  opts: SubmitJobOptions,
149
120
  ): Promise<SubmitResult> {
150
- const rootDir = path.resolve(opts.dataDir);
151
-
152
- try {
153
- assertSeedObject(opts.seedObject);
154
- assertPipelineSlug(opts.seedObject["pipeline"]);
155
- } catch (err) {
156
- return { success: false, message: (err as Error).message };
157
- }
158
-
159
- const seed = opts.seedObject as Record<string, unknown>;
160
-
161
- if (seed["name"] !== undefined) {
162
- if (typeof seed["name"] !== "string" || seed["name"].length === 0) {
163
- return { success: false, message: "seed.name must be a non-empty string if provided" };
164
- }
165
- }
166
-
167
- try {
168
- getPipelineConfig(seed["pipeline"] as string, rootDir);
169
- } catch (err) {
170
- return { success: false, message: (err as Error).message };
171
- }
172
-
173
- const jobId = crypto.randomUUID();
174
- const jobName = (typeof seed["name"] === "string" && seed["name"].length > 0)
175
- ? seed["name"]
176
- : jobId;
177
- const pendingPath = getPendingSeedPath(rootDir, jobId);
178
-
179
- await mkdir(path.dirname(pendingPath), { recursive: true });
180
-
181
- try {
182
- await atomicWriteJson(pendingPath, opts.seedObject);
183
- } catch (err) {
184
- throw new Error(`failed to write seed file for job ${jobId}: ${(err as Error).message}`);
185
- }
186
-
187
- return { success: true, jobId, jobName };
121
+ const rootDir = resolveWorkspaceRoot(opts.root);
122
+ return submitSeed({ root: rootDir, seedObject: opts.seedObject });
188
123
  }
189
124
 
190
125
  export class PipelineOrchestrator {
@@ -194,7 +129,7 @@ export class PipelineOrchestrator {
194
129
 
195
130
  constructor(opts: OrchestratorOptions) {
196
131
  this.autoStart = opts.autoStart;
197
- this.root = path.resolve(process.env["PO_ROOT"] ?? process.cwd());
132
+ this.root = resolveWorkspaceRoot(opts.root);
198
133
  this.paths = resolvePipelinePaths(this.root);
199
134
  }
200
135
 
@@ -232,10 +167,8 @@ export class PipelineOrchestrator {
232
167
  // 3. Pending fallback
233
168
  const pendingFiles = await safeReaddir(this.paths.pending);
234
169
  for (const file of pendingFiles) {
235
- const match = file.match(SEED_PATTERN);
236
- if (!match) continue;
237
- const id = match[1]!;
238
- if (id !== jobName) continue;
170
+ const id = parseSeedFilename(file);
171
+ if (id === null || id !== jobName) continue;
239
172
 
240
173
  const filePath = path.join(this.paths.pending, file);
241
174
  const seed = await readJsonFile<Record<string, unknown>>(filePath);
@@ -258,9 +191,8 @@ export class PipelineOrchestrator {
258
191
  // Pending jobs
259
192
  const pendingFiles = await safeReaddir(this.paths.pending);
260
193
  for (const file of pendingFiles) {
261
- const match = file.match(SEED_PATTERN);
262
- if (!match) continue;
263
- const id = match[1]!;
194
+ const id = parseSeedFilename(file);
195
+ if (id === null) continue;
264
196
  const filePath = path.join(this.paths.pending, file);
265
197
  try {
266
198
  const seed = await readJsonFile<Record<string, unknown>>(filePath);
@@ -1,7 +1,43 @@
1
- import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
1
+ import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from "bun:test";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { ChatResponse, ProviderAvailability } from "../../providers/types.ts";
6
+
7
+ // Mock the gateway boundary so the analyzer produces a deterministic, valid
8
+ // result without a real LLM call. (See task-analysis/__tests__/analyzer.test.ts.)
9
+ const availability: ProviderAvailability = {
10
+ openai: false,
11
+ deepseek: true,
12
+ anthropic: false,
13
+ gemini: false,
14
+ zai: false,
15
+ zhipu: false,
16
+ claudecode: false,
17
+ moonshot: false,
18
+ alibaba: false,
19
+ opencode: false,
20
+ mock: false,
21
+ };
22
+
23
+ const VALID_ANALYSIS = {
24
+ summary: "Ingests raw data and writes a processed artifact.",
25
+ stages: [{ name: "ingestion", purpose: "Reads raw input and normalizes it." }],
26
+ artifacts: {
27
+ reads: [{ fileName: "seed.json", role: "raw input" }],
28
+ writes: [{ fileName: "processed.json", role: "normalized output" }],
29
+ },
30
+ models: [{ provider: "deepseek", method: "chat", stage: "ingestion" }],
31
+ };
32
+
33
+ mock.module("../../llm/index.ts", () => ({
34
+ chat: (): Promise<ChatResponse> =>
35
+ Promise.resolve({
36
+ content: VALID_ANALYSIS,
37
+ usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, source: "reported" },
38
+ }),
39
+ getAvailableProviders: () => availability,
40
+ }));
5
41
 
6
42
  describe("analyzeTaskFile", () => {
7
43
  let tmpDir: string;
@@ -22,11 +58,11 @@ describe("analyzeTaskFile", () => {
22
58
  stdoutSpy.mockRestore();
23
59
  });
24
60
 
25
- it("outputs JSON result for a valid task file", async () => {
61
+ it("prints a status-tagged analysis as JSON for a sample file", async () => {
26
62
  const taskFile = join(tmpDir, "task.ts");
27
63
  await writeFile(
28
64
  taskFile,
29
- 'export async function ingestion({ io, llm }) { await io.readArtifact("seed.json"); await llm.openai.complete({ prompt: "json" }); }',
65
+ 'export async function ingestion({ io, llm }) { await io.readArtifact("seed.json"); }',
30
66
  );
31
67
 
32
68
  const { analyzeTaskFile } = await import("../analyze-task.ts");
@@ -35,10 +71,7 @@ describe("analyzeTaskFile", () => {
35
71
  expect(stdoutSpy).toHaveBeenCalledTimes(1);
36
72
  const written = (stdoutSpy.mock.calls[0] as [string])[0];
37
73
  const parsed = JSON.parse(written);
38
- expect(parsed.taskFilePath).toBe(taskFile);
39
- expect(parsed.stages).toEqual([{ name: "ingestion", order: 1, isAsync: true }]);
40
- expect(parsed.artifacts.reads).toEqual([{ fileName: "seed.json", stage: "ingestion", required: true }]);
41
- expect(parsed.models).toEqual([{ provider: "openai", method: "complete", stage: "ingestion" }]);
74
+ expect(parsed.analysisStatus).toBe("ok");
42
75
  });
43
76
 
44
77
  it("calls process.exit(1) for a non-existent file", async () => {