@ryanfw/prompt-orchestration-pipeline 1.3.3 → 1.3.5

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 (215) hide show
  1. package/docs/http-api.md +17 -4
  2. package/docs/pop-task-guide.md +8 -0
  3. package/package.json +5 -5
  4. package/src/api/__tests__/index.test.ts +144 -41
  5. package/src/api/index.ts +15 -83
  6. package/src/cli/__tests__/analyze-task.test.ts +39 -7
  7. package/src/cli/__tests__/index.test.ts +337 -17
  8. package/src/cli/__tests__/types.test.ts +0 -18
  9. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  10. package/src/cli/analyze-task.ts +3 -14
  11. package/src/cli/index.ts +73 -61
  12. package/src/cli/types.ts +0 -13
  13. package/src/cli/update-pipeline-json.ts +3 -14
  14. package/src/config/__tests__/config-schema.test.ts +42 -0
  15. package/src/config/__tests__/legacy-literal-guard.test.ts +156 -0
  16. package/src/config/__tests__/models.test.ts +139 -1
  17. package/src/config/__tests__/paths.test.ts +46 -1
  18. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  19. package/src/config/__tests__/sse-events.test.ts +470 -0
  20. package/src/config/models.ts +121 -19
  21. package/src/config/paths.ts +11 -2
  22. package/src/config/pipeline-registry.ts +258 -0
  23. package/src/config/sse-events.ts +122 -0
  24. package/src/core/__tests__/agent-step.test.ts +167 -0
  25. package/src/core/__tests__/config.test.ts +517 -107
  26. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  27. package/src/core/__tests__/job-concurrency.test.ts +200 -0
  28. package/src/core/__tests__/job-submission.test.ts +396 -0
  29. package/src/core/__tests__/job-view.test.ts +583 -3
  30. package/src/core/__tests__/json-file.test.ts +111 -0
  31. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  32. package/src/core/__tests__/logger.test.ts +22 -0
  33. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  34. package/src/core/__tests__/orchestrator.test.ts +373 -2
  35. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  36. package/src/core/__tests__/redact.test.ts +153 -0
  37. package/src/core/__tests__/runner-liveness.test.ts +45 -0
  38. package/src/core/__tests__/seed-naming.test.ts +57 -0
  39. package/src/core/__tests__/single-derivation.test.ts +1 -1
  40. package/src/core/__tests__/task-runner.test.ts +594 -3
  41. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  42. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  43. package/src/core/agent-step.ts +9 -2
  44. package/src/core/agent-types.ts +10 -4
  45. package/src/core/config.ts +127 -234
  46. package/src/core/control.ts +3 -0
  47. package/src/core/job-concurrency.ts +50 -19
  48. package/src/core/job-submission.ts +133 -0
  49. package/src/core/job-view.ts +183 -19
  50. package/src/core/json-file.ts +45 -0
  51. package/src/core/lifecycle-policy.ts +23 -8
  52. package/src/core/logger.ts +0 -29
  53. package/src/core/orchestrator.ts +124 -70
  54. package/src/core/pipeline-runner.ts +85 -53
  55. package/src/core/redact.ts +40 -0
  56. package/src/core/runner-liveness.ts +8 -4
  57. package/src/core/seed-naming.ts +9 -0
  58. package/src/core/status-writer.ts +3 -28
  59. package/src/core/task-runner.ts +356 -319
  60. package/src/core/task-telemetry.ts +107 -0
  61. package/src/harness/__tests__/subprocess.test.ts +112 -1
  62. package/src/harness/subprocess.ts +55 -16
  63. package/src/llm/__tests__/dispatch.test.ts +130 -0
  64. package/src/llm/__tests__/index.test.ts +693 -43
  65. package/src/llm/__tests__/legacy-normalization.test.ts +109 -0
  66. package/src/llm/index.ts +405 -165
  67. package/src/providers/__tests__/alibaba.test.ts +67 -6
  68. package/src/providers/__tests__/anthropic.test.ts +35 -14
  69. package/src/providers/__tests__/base.test.ts +62 -0
  70. package/src/providers/__tests__/claude-code.test.ts +108 -17
  71. package/src/providers/__tests__/deepseek.test.ts +16 -6
  72. package/src/providers/__tests__/gemini.test.ts +47 -25
  73. package/src/providers/__tests__/moonshot.test.ts +60 -3
  74. package/src/providers/__tests__/openai.test.ts +262 -74
  75. package/src/providers/__tests__/opencode.test.ts +77 -0
  76. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  77. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  78. package/src/providers/__tests__/types.test.ts +128 -0
  79. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  80. package/src/providers/__tests__/zhipu.test.ts +49 -40
  81. package/src/providers/alibaba.ts +20 -39
  82. package/src/providers/anthropic.ts +23 -11
  83. package/src/providers/base.ts +19 -0
  84. package/src/providers/claude-code.ts +27 -18
  85. package/src/providers/deepseek.ts +9 -28
  86. package/src/providers/gemini.ts +20 -58
  87. package/src/providers/moonshot.ts +22 -11
  88. package/src/providers/openai.ts +79 -61
  89. package/src/providers/opencode.ts +16 -33
  90. package/src/providers/stream-accumulator.ts +27 -21
  91. package/src/providers/types.ts +36 -9
  92. package/src/providers/zhipu.ts +15 -46
  93. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  94. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +69 -2
  95. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  96. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  97. package/src/task-analysis/__tests__/types.test.ts +63 -130
  98. package/src/task-analysis/analyzer.ts +85 -0
  99. package/src/task-analysis/enrichers/schema-deducer.ts +12 -2
  100. package/src/task-analysis/index.ts +2 -36
  101. package/src/task-analysis/repository.ts +45 -0
  102. package/src/task-analysis/types.ts +42 -58
  103. package/src/ui/client/__tests__/api.test.ts +143 -1
  104. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  105. package/src/ui/client/__tests__/job-adapter.test.ts +125 -2
  106. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  107. package/src/ui/client/__tests__/types.test.ts +66 -3
  108. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  109. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  110. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  111. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  112. package/src/ui/client/adapters/job-adapter.ts +31 -16
  113. package/src/ui/client/api.ts +38 -15
  114. package/src/ui/client/bootstrap.ts +19 -14
  115. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  116. package/src/ui/client/hooks/useJobList.ts +26 -31
  117. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  118. package/src/ui/client/load-state.ts +20 -0
  119. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  120. package/src/ui/client/reducers/job-events.ts +137 -0
  121. package/src/ui/client/types.ts +14 -20
  122. package/src/ui/components/DAGGrid.tsx +6 -1
  123. package/src/ui/components/JobDetail.tsx +12 -4
  124. package/src/ui/components/JobTable.tsx +13 -2
  125. package/src/ui/components/StageTimeline.tsx +8 -20
  126. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  127. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  128. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  129. package/src/ui/components/__tests__/JobTable.test.tsx +83 -0
  130. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  131. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  132. package/src/ui/components/types.ts +33 -15
  133. package/src/ui/dist/assets/{index-L6cvsCAx.js → index-lBbVeNZC.js} +4304 -253
  134. package/src/ui/dist/assets/index-lBbVeNZC.js.map +1 -0
  135. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  136. package/src/ui/dist/index.html +2 -2
  137. package/src/ui/pages/Code.tsx +5 -2
  138. package/src/ui/pages/PipelineDetail.tsx +60 -4
  139. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  140. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  141. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  142. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  143. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  144. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  145. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  146. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  147. package/src/ui/server/__tests__/http-api-contract.test.ts +838 -0
  148. package/src/ui/server/__tests__/index.test.ts +63 -0
  149. package/src/ui/server/__tests__/job-control-endpoints.test.ts +582 -21
  150. package/src/ui/server/__tests__/job-control-service.test.ts +282 -0
  151. package/src/ui/server/__tests__/job-endpoints.test.ts +43 -1
  152. package/src/ui/server/__tests__/job-process-registry.test.ts +151 -0
  153. package/src/ui/server/__tests__/job-repository.test.ts +142 -0
  154. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  155. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  156. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  157. package/src/ui/server/__tests__/router.test.ts +104 -0
  158. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  159. package/src/ui/server/__tests__/sse-enhancer.test.ts +39 -0
  160. package/src/ui/server/config-bridge-node.ts +8 -26
  161. package/src/ui/server/config-bridge.ts +9 -6
  162. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  163. package/src/ui/server/embedded-assets.ts +13 -2
  164. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  165. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  166. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  167. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  168. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  169. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  170. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  171. package/src/ui/server/endpoints/gate-endpoints.ts +24 -12
  172. package/src/ui/server/endpoints/job-control-endpoints.ts +19 -714
  173. package/src/ui/server/endpoints/job-endpoints.ts +1 -0
  174. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  175. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  176. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  177. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  178. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  179. package/src/ui/server/index.ts +19 -14
  180. package/src/ui/server/job-control-service.ts +520 -0
  181. package/src/ui/server/job-process-registry.ts +250 -0
  182. package/src/ui/server/job-reader.ts +14 -2
  183. package/src/ui/server/job-repository.ts +316 -0
  184. package/src/ui/server/router.ts +33 -10
  185. package/src/ui/server/sse-broadcast.ts +14 -3
  186. package/src/ui/server/sse-enhancer.ts +12 -2
  187. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  188. package/src/ui/state/__tests__/snapshot.test.ts +70 -15
  189. package/src/ui/state/__tests__/types.test.ts +6 -0
  190. package/src/ui/state/snapshot.ts +1 -1
  191. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +42 -0
  192. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +45 -3
  193. package/src/ui/state/transformers/list-transformer.ts +6 -0
  194. package/src/ui/state/types.ts +6 -1
  195. package/src/utils/__tests__/path-containment.test.ts +160 -0
  196. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  197. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  198. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  199. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  200. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  201. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  202. package/src/task-analysis/__tests__/index.test.ts +0 -143
  203. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  204. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  205. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  206. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  207. package/src/task-analysis/extractors/artifacts.ts +0 -143
  208. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  209. package/src/task-analysis/extractors/stages.ts +0 -45
  210. package/src/task-analysis/parser.ts +0 -20
  211. package/src/task-analysis/utils/ast.ts +0 -45
  212. package/src/ui/dist/assets/index-L6cvsCAx.js.map +0 -1
  213. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  214. package/src/ui/embedded-assets.js +0 -12
  215. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
package/docs/http-api.md CHANGED
@@ -81,7 +81,10 @@ or on error:
81
81
  | `forbidden_origin` | 403 | Cross-origin mutation rejected |
82
82
  | `status_unavailable` | 500 | Job status file unreadable |
83
83
  | `no_pending_gate` | 409 | Job has no pending gate decision |
84
+ | `INVALID_JSON` | 409 | Job status JSON is invalid |
85
+ | `STATUS_CORRUPT` | 409 | Job status file is corrupt |
84
86
  | `FS_ERROR` | 500 | Filesystem operation failed |
87
+ | `BAD_PATH` | 400 | Task analysis path is invalid |
85
88
  | `BAD_REQUEST` | 400 | Malformed request body |
86
89
  | `NOT_FOUND` | 404 | Route or resource not found |
87
90
 
@@ -203,11 +206,21 @@ Clients should treat a gap longer than the keep-alive interval as a potential di
203
206
 
204
207
  | Event | Data Shape | Description |
205
208
  |-------|-----------|-------------|
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 |
209
+ | `state:change` | `{ jobId, path }` | A job's state file changed; the `jobId` is derived from the changed path |
210
+ | `state:summary` | `{ changeCount }` | Summary of accumulated state changes when no per-job change is prioritized |
211
+ | `job:created` | `JobSummary` | A new job was detected (summary granularity; see Caveat below) |
212
+ | `job:updated` | `JobSummary` | An existing job was updated (summary granularity; see Caveat below) |
213
+ | `job:removed` | `{ jobId }` | A job was removed from a watched location |
210
214
  | `heartbeat` | `{ ok, timestamp }` | Application-level heartbeat (distinct from the keep-alive comment) |
215
+ | `seed:uploaded` | `{}` | A seed was uploaded; clients should refetch the job list |
216
+
217
+ The keep-alive comment (`: keep-alive`) sent on a fixed interval is **not** an application event;
218
+ clients must not treat it as one. Unknown event types and unknown fields within a known event
219
+ are tolerated by the contract; clients should ignore them. `task:updated`, `status:changed`,
220
+ bare `state`, and `message` are **not** part of the browser-facing contract and are not
221
+ emitted on `/api/events`. `job:created` and `job:updated` carry summary-granularity
222
+ `JobSummary` data; deep per-task detail (costs, files, per-stage state) continues to arrive
223
+ via the existing `state:change` → refetch path.
211
224
 
212
225
  ### Job Filtering
213
226
 
@@ -209,6 +209,7 @@ export const inference = async ({ runAgent, io, data, flags }) => {
209
209
  // timeoutMs?: number // optional wall-clock cap
210
210
  // idleTimeoutMs?: number // optional no-output cap
211
211
  // captureDiff?: boolean // capture a git diff as 'agent.patch'
212
+ // onEvent?: (event) => {} // observe adapter harness events during the run
212
213
  });
213
214
 
214
215
  if (!result.ok) {
@@ -226,6 +227,13 @@ By default (`io` is `true`) the agent shares the task's file I/O: it can call th
226
227
  task sees, and its `agent-result.md` is written automatically. Token usage and
227
228
  cost flow into the job status like any other LLM call.
228
229
 
230
+ Use `onEvent` when a task needs to observe live harness activity, such as
231
+ assistant messages, tool calls, usage events, or run completion. POP invokes the
232
+ callback after it has buffered the event and appended the redacted debug log, so
233
+ omitting the callback leaves behavior unchanged. The `event.raw` payload comes
234
+ directly from the underlying CLI adapter; treat it as unstable and avoid
235
+ persisting it unless you have considered sensitive values in the raw stream.
236
+
229
237
  **`runAgent` vs `llm`**: use `llm.<provider>.chat()` for a single request/response
230
238
  LLM call; use `runAgent()` when you need a tool-using CLI agent that reads and
231
239
  writes files over multiple turns.
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.5",
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",
@@ -32,7 +32,10 @@
32
32
  "ui": "NODE_ENV=development bun --watch src/ui/server/index.ts",
33
33
  "ui:dev": "vite",
34
34
  "ui:build": "vite build && bun run generate:embedded-assets",
35
- "prepack": "bun run ui:build",
35
+ "verify:package": "bun scripts/verify-package-assets.js",
36
+ "smoke:packed-consumer": "bun scripts/smoke-packed-consumer.js",
37
+ "release:check": "bun run ui:build && bun run verify:package && bun run smoke:packed-consumer",
38
+ "prepack": "bun run ui:build && bun run verify:package",
36
39
  "ui:prod": "bun src/ui/server/index.ts",
37
40
  "demo:ui": "NODE_ENV=production PO_ROOT=demo bun src/ui/server/index.ts",
38
41
  "demo:orchestrator": "PO_ROOT=demo NODE_ENV=production bun -e \"import('./src/core/orchestrator.ts').then(m => m.startOrchestrator({ dataDir: process.env.PO_ROOT || 'demo' })).catch(err => { console.error(err); process.exit(1) })\"",
@@ -42,9 +45,6 @@
42
45
  "typecheck": "tsc --noEmit"
43
46
  },
44
47
  "dependencies": {
45
- "@babel/parser": "^7.28.5",
46
- "@babel/traverse": "^7.28.5",
47
- "@babel/types": "^7.28.5",
48
48
  "@radix-ui/react-progress": "^1.1.7",
49
49
  "@radix-ui/react-tabs": "^1.1.13",
50
50
  "@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);