@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
@@ -1,7 +1,42 @@
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
+ moonshot: false,
16
+ alibaba: false,
17
+ opencode: false,
18
+ "claude-code": false,
19
+ mock: false,
20
+ };
21
+
22
+ const VALID_ANALYSIS = {
23
+ summary: "Ingests raw data and writes a processed artifact.",
24
+ stages: [{ name: "ingestion", purpose: "Reads raw input and normalizes it." }],
25
+ artifacts: {
26
+ reads: [{ fileName: "seed.json", role: "raw input" }],
27
+ writes: [{ fileName: "processed.json", role: "normalized output" }],
28
+ },
29
+ models: [{ provider: "deepseek", method: "chat", stage: "ingestion" }],
30
+ };
31
+
32
+ mock.module("../../llm/index.ts", () => ({
33
+ chat: (): Promise<ChatResponse> =>
34
+ Promise.resolve({
35
+ content: VALID_ANALYSIS,
36
+ usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, source: "reported" },
37
+ }),
38
+ getAvailableProviders: () => availability,
39
+ }));
5
40
 
6
41
  describe("analyzeTaskFile", () => {
7
42
  let tmpDir: string;
@@ -22,11 +57,11 @@ describe("analyzeTaskFile", () => {
22
57
  stdoutSpy.mockRestore();
23
58
  });
24
59
 
25
- it("outputs JSON result for a valid task file", async () => {
60
+ it("prints a status-tagged analysis as JSON for a sample file", async () => {
26
61
  const taskFile = join(tmpDir, "task.ts");
27
62
  await writeFile(
28
63
  taskFile,
29
- 'export async function ingestion({ io, llm }) { await io.readArtifact("seed.json"); await llm.openai.complete({ prompt: "json" }); }',
64
+ 'export async function ingestion({ io, llm }) { await io.readArtifact("seed.json"); }',
30
65
  );
31
66
 
32
67
  const { analyzeTaskFile } = await import("../analyze-task.ts");
@@ -35,10 +70,7 @@ describe("analyzeTaskFile", () => {
35
70
  expect(stdoutSpy).toHaveBeenCalledTimes(1);
36
71
  const written = (stdoutSpy.mock.calls[0] as [string])[0];
37
72
  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" }]);
73
+ expect(parsed.analysisStatus).toBe("ok");
42
74
  });
43
75
 
44
76
  it("calls process.exit(1) for a non-existent file", async () => {
@@ -1,14 +1,44 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
2
+ import { mock } from "bun:test";
2
3
  import { mkdtemp, rm, mkdir, readdir } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { tmpdir } from "node:os";
5
6
  import { access } from "node:fs/promises";
6
7
 
8
+ // Module mocks for the hidden-subcommand tests. These are evaluated when
9
+ // the dynamic imports inside the hidden handlers first run. The CLI file
10
+ // statically imports neither target, so the mocks are inert for the
11
+ // existing tests. Note: this file uses bun:test's mock.module (not
12
+ // vi.mock) because vitest's mock-hoist does not intercept dynamic
13
+ // imports under the bun runtime. scripts/test.sh isolates this file
14
+ // because of the mock.module literal token, which is the intended seam.
15
+ const mockStartServer = vi.fn();
16
+ const mockStartOrchestrator = vi.fn();
17
+ const mockRunPipelineJob = vi.fn();
18
+
19
+ mock.module("../../ui/server/index.ts", () => ({
20
+ startServer: mockStartServer,
21
+ }));
22
+
23
+ mock.module("../../core/orchestrator.ts", () => ({
24
+ startOrchestrator: mockStartOrchestrator,
25
+ }));
26
+
27
+ mock.module("../../core/pipeline-runner.ts", () => ({
28
+ runPipelineJob: mockRunPipelineJob,
29
+ }));
30
+
7
31
  import {
8
32
  handleInit,
9
33
  handleAddPipeline,
10
34
  handleAddPipelineTask,
11
35
  handleSubmit,
36
+ handleStart,
37
+ handleStartUi,
38
+ handleStartOrchestrator,
39
+ handleRunJob,
40
+ handleStatus,
41
+ buildStartChildEnv,
12
42
  parseTaskIndex,
13
43
  serializeTaskIndex,
14
44
  buildUiCorsEnv,
@@ -66,14 +96,8 @@ describe("handleInit", () => {
66
96
 
67
97
  it("writes registry.json with empty pipelines", async () => {
68
98
  await handleInit(tmpDir);
69
- const reg = await readJson(join(tmpDir, "registry.json"));
70
- expect(reg).toEqual({ pipelines: {} });
71
- });
72
-
73
- it("writes registry.json with trailing newline", async () => {
74
- await handleInit(tmpDir);
75
- const text = await Bun.file(join(tmpDir, "registry.json")).text();
76
- expect(text.endsWith("\n")).toBe(true);
99
+ const reg = await readJson(join(tmpDir, "pipeline-config", "registry.json"));
100
+ expect(reg).toEqual({ version: 1, pipelines: {} });
77
101
  });
78
102
 
79
103
  it("is idempotent — can be re-run without error", async () => {
@@ -130,21 +154,13 @@ describe("handleAddPipeline", () => {
130
154
 
131
155
  it("adds entry to registry.json", async () => {
132
156
  await handleAddPipeline("my-pipeline", tmpDir);
133
- const reg = await readJson(join(tmpDir, "registry.json")) as { pipelines: Record<string, unknown> };
157
+ const reg = await readJson(join(tmpDir, "pipeline-config", "registry.json")) as { pipelines: Record<string, unknown> };
134
158
  expect(reg.pipelines["my-pipeline"]).toBeDefined();
135
159
  const entry = reg.pipelines["my-pipeline"] as Record<string, unknown>;
136
160
  expect(entry["name"]).toBe("my-pipeline");
137
161
  expect(entry["description"]).toBe("New pipeline");
138
162
  });
139
163
 
140
- it("stores .ts paths in registry entry", async () => {
141
- await handleAddPipeline("my-pipeline", tmpDir);
142
- const reg = await readJson(join(tmpDir, "registry.json")) as { pipelines: Record<string, unknown> };
143
- const entry = reg.pipelines["my-pipeline"] as Record<string, string>;
144
- expect(entry["taskRegistryPath"]).toMatch(/index\.ts$/);
145
- expect(entry["pipelinePath"]).toMatch(/pipeline\.json$/);
146
- });
147
-
148
164
  it("exits with 1 on invalid slug", async () => {
149
165
  await expect(handleAddPipeline("INVALID", tmpDir)).rejects.toThrow("process.exit(1)");
150
166
  expect(exitSpy).toHaveBeenCalledWith(1);
@@ -329,8 +345,11 @@ describe("handleSubmit", () => {
329
345
  await Bun.write(
330
346
  join(tmpDir, "pipeline-config", "registry.json"),
331
347
  JSON.stringify({
348
+ version: 1,
332
349
  pipelines: {
333
350
  "test-pipeline": {
351
+ name: "Test Pipeline",
352
+ description: "test pipeline",
334
353
  configDir,
335
354
  tasksDir: join(configDir, "tasks"),
336
355
  },
@@ -360,6 +379,190 @@ describe("handleSubmit", () => {
360
379
  });
361
380
  });
362
381
 
382
+ // ─── submit --root precedence ─────────────────────────────────────────────────
383
+
384
+ describe("submit --root precedence", () => {
385
+ let rootFlag: string;
386
+ let rootEnv: string;
387
+ let rootCwd: string;
388
+ let exitSpy: MockInstance;
389
+ let cwdSpy: MockInstance;
390
+ const originalPoRoot = process.env["PO_ROOT"];
391
+
392
+ async function scaffoldWorkspace(dir: string): Promise<string> {
393
+ const configDir = join(dir, "pipeline-config", "test-pipeline");
394
+ await mkdir(configDir, { recursive: true });
395
+ await mkdir(join(dir, "pipeline-data", "pending"), { recursive: true });
396
+ await Bun.write(
397
+ join(dir, "pipeline-config", "registry.json"),
398
+ JSON.stringify({
399
+ version: 1,
400
+ pipelines: {
401
+ "test-pipeline": {
402
+ name: "Test Pipeline",
403
+ description: "test pipeline",
404
+ configDir,
405
+ tasksDir: join(configDir, "tasks"),
406
+ },
407
+ },
408
+ }),
409
+ );
410
+ await Bun.write(
411
+ join(configDir, "pipeline.json"),
412
+ JSON.stringify({ name: "test-pipeline", tasks: [] }),
413
+ );
414
+ const seedPath = join(dir, "seed.json");
415
+ await Bun.write(seedPath, JSON.stringify({ pipeline: "test-pipeline" }));
416
+ return seedPath;
417
+ }
418
+
419
+ async function pendingSeeds(dir: string): Promise<string[]> {
420
+ const entries = await readdir(join(dir, "pipeline-data", "pending"));
421
+ return entries.filter((f) => f.endsWith("-seed.json"));
422
+ }
423
+
424
+ beforeEach(async () => {
425
+ rootFlag = await mkdtemp(join(tmpdir(), "pop-submit-flag-"));
426
+ rootEnv = await mkdtemp(join(tmpdir(), "pop-submit-env-"));
427
+ rootCwd = await mkdtemp(join(tmpdir(), "pop-submit-cwd-"));
428
+ delete process.env["PO_ROOT"];
429
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(
430
+ (_code?: string | number | null | undefined) => {
431
+ throw new Error(`process.exit(${_code})`);
432
+ }
433
+ );
434
+ cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(rootCwd);
435
+ });
436
+
437
+ afterEach(async () => {
438
+ await rm(rootFlag, { recursive: true, force: true });
439
+ await rm(rootEnv, { recursive: true, force: true });
440
+ await rm(rootCwd, { recursive: true, force: true });
441
+ exitSpy.mockRestore();
442
+ cwdSpy.mockRestore();
443
+ if (originalPoRoot === undefined) {
444
+ delete process.env["PO_ROOT"];
445
+ } else {
446
+ process.env["PO_ROOT"] = originalPoRoot;
447
+ }
448
+ vi.clearAllMocks();
449
+ });
450
+
451
+ it("writes the pending seed under --root when --root, PO_ROOT, and cwd all differ (AC-2 precedence)", async () => {
452
+ const flagSeed = await scaffoldWorkspace(rootFlag);
453
+ await scaffoldWorkspace(rootEnv);
454
+ await scaffoldWorkspace(rootCwd);
455
+
456
+ process.env["PO_ROOT"] = rootEnv;
457
+
458
+ await handleSubmit(flagSeed, { root: rootFlag });
459
+
460
+ expect(exitSpy).not.toHaveBeenCalled();
461
+ expect(await pendingSeeds(rootFlag)).toHaveLength(1);
462
+ expect(await pendingSeeds(rootEnv)).toHaveLength(0);
463
+ expect(await pendingSeeds(rootCwd)).toHaveLength(0);
464
+ });
465
+
466
+ it("writes the pending seed under PO_ROOT when PO_ROOT and cwd differ and --root is absent (AC-3 headline regression)", async () => {
467
+ const cwdSeed = await scaffoldWorkspace(rootCwd);
468
+ await scaffoldWorkspace(rootEnv);
469
+
470
+ process.env["PO_ROOT"] = rootEnv;
471
+
472
+ await handleSubmit(cwdSeed);
473
+
474
+ expect(exitSpy).not.toHaveBeenCalled();
475
+ expect(await pendingSeeds(rootEnv)).toHaveLength(1);
476
+ expect(await pendingSeeds(rootCwd)).toHaveLength(0);
477
+ });
478
+
479
+ it("writes the pending seed under --root when --root is set without PO_ROOT (AC-3 second half)", async () => {
480
+ const flagSeed = await scaffoldWorkspace(rootFlag);
481
+ await scaffoldWorkspace(rootCwd);
482
+
483
+ await handleSubmit(flagSeed, { root: rootFlag });
484
+
485
+ expect(exitSpy).not.toHaveBeenCalled();
486
+ expect(await pendingSeeds(rootFlag)).toHaveLength(1);
487
+ expect(await pendingSeeds(rootCwd)).toHaveLength(0);
488
+ });
489
+ });
490
+
491
+ // ─── status --root precedence ─────────────────────────────────────────────────
492
+
493
+ describe("status --root precedence", () => {
494
+ let rootFlag: string;
495
+ let rootEnv: string;
496
+ let rootCwd: string;
497
+ let cwdSpy: MockInstance;
498
+ let tableSpy: MockInstance;
499
+ const originalPoRoot = process.env["PO_ROOT"];
500
+
501
+ async function scaffoldPendingSeed(dir: string, jobId: string): Promise<void> {
502
+ await mkdir(join(dir, "pipeline-data", "pending"), { recursive: true });
503
+ await Bun.write(
504
+ join(dir, "pipeline-data", "pending", `${jobId}-seed.json`),
505
+ JSON.stringify({ name: jobId, pipeline: "test-pipeline" }),
506
+ );
507
+ }
508
+
509
+ function listedJobIds(): string[] {
510
+ const rows = tableSpy.mock.calls[0]?.[0] as Array<{ jobId?: string }> | undefined;
511
+ return rows?.map((row) => row.jobId).filter((jobId): jobId is string => typeof jobId === "string") ?? [];
512
+ }
513
+
514
+ beforeEach(async () => {
515
+ rootFlag = await mkdtemp(join(tmpdir(), "pop-status-flag-"));
516
+ rootEnv = await mkdtemp(join(tmpdir(), "pop-status-env-"));
517
+ rootCwd = await mkdtemp(join(tmpdir(), "pop-status-cwd-"));
518
+ delete process.env["PO_ROOT"];
519
+ cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(rootCwd);
520
+ tableSpy = vi.spyOn(console, "table").mockImplementation(() => undefined);
521
+ await scaffoldPendingSeed(rootFlag, "flag-job");
522
+ await scaffoldPendingSeed(rootEnv, "env-job");
523
+ await scaffoldPendingSeed(rootCwd, "cwd-job");
524
+ });
525
+
526
+ afterEach(async () => {
527
+ await rm(rootFlag, { recursive: true, force: true });
528
+ await rm(rootEnv, { recursive: true, force: true });
529
+ await rm(rootCwd, { recursive: true, force: true });
530
+ cwdSpy.mockRestore();
531
+ tableSpy.mockRestore();
532
+ if (originalPoRoot === undefined) {
533
+ delete process.env["PO_ROOT"];
534
+ } else {
535
+ process.env["PO_ROOT"] = originalPoRoot;
536
+ }
537
+ vi.clearAllMocks();
538
+ });
539
+
540
+ it("lists jobs under --root when --root, PO_ROOT, and cwd all differ", async () => {
541
+ process.env["PO_ROOT"] = rootEnv;
542
+
543
+ await handleStatus(undefined, { root: rootFlag });
544
+
545
+ expect(listedJobIds()).toEqual(["flag-job"]);
546
+ expect(process.env["PO_ROOT"]).toBe(rootEnv);
547
+ });
548
+
549
+ it("lists jobs under PO_ROOT when --root is absent", async () => {
550
+ process.env["PO_ROOT"] = rootEnv;
551
+
552
+ await handleStatus(undefined);
553
+
554
+ expect(listedJobIds()).toEqual(["env-job"]);
555
+ expect(process.env["PO_ROOT"]).toBe(rootEnv);
556
+ });
557
+
558
+ it("lists jobs under cwd when --root and PO_ROOT are absent", async () => {
559
+ await handleStatus(undefined);
560
+
561
+ expect(listedJobIds()).toEqual(["cwd-job"]);
562
+ expect(process.env["PO_ROOT"]).toBeUndefined();
563
+ });
564
+ });
565
+
363
566
  // ─── parseTaskIndex ───────────────────────────────────────────────────────────
364
567
 
365
568
  describe("parseTaskIndex", () => {
@@ -422,6 +625,123 @@ describe("serializeTaskIndex", () => {
422
625
  });
423
626
  });
424
627
 
628
+ // ─── start --root handoff (AC-7) ────────────────────────────────────────────
629
+
630
+ describe("start --root handoff", () => {
631
+ const originalPoRoot = process.env["PO_ROOT"];
632
+
633
+ beforeEach(() => {
634
+ delete process.env["PO_ROOT"];
635
+ mockStartServer.mockReset();
636
+ mockStartOrchestrator.mockReset();
637
+ mockStartOrchestrator.mockResolvedValue({ stop: vi.fn() });
638
+ mockRunPipelineJob.mockReset();
639
+ });
640
+
641
+ afterEach(() => {
642
+ if (originalPoRoot === undefined) {
643
+ delete process.env["PO_ROOT"];
644
+ } else {
645
+ process.env["PO_ROOT"] = originalPoRoot;
646
+ }
647
+ process.removeAllListeners("SIGINT");
648
+ process.removeAllListeners("SIGTERM");
649
+ vi.restoreAllMocks();
650
+ vi.clearAllMocks();
651
+ });
652
+
653
+ describe("buildStartChildEnv", () => {
654
+ it("forwards absoluteRoot to PO_ROOT in both child envs and sets PORT/NODE_ENV in the UI env", () => {
655
+ const { uiEnv, orchEnv } = buildStartChildEnv("/A", "4000");
656
+
657
+ expect(uiEnv["PO_ROOT"]).toBe("/A");
658
+ expect(uiEnv["PORT"]).toBe("4000");
659
+ expect(uiEnv["NODE_ENV"]).toBe("production");
660
+ expect(orchEnv["PO_ROOT"]).toBe("/A");
661
+ expect(orchEnv["NODE_ENV"]).toBe("production");
662
+ expect(orchEnv["PORT"]).toBeUndefined();
663
+ });
664
+
665
+ it("layers CORS options onto the UI env alongside PO_ROOT/PORT", () => {
666
+ const { uiEnv } = buildStartChildEnv("/A", "4000", {
667
+ port: "4000",
668
+ corsOrigins: "https://app.example",
669
+ corsAllowNullOrigin: true,
670
+ });
671
+
672
+ expect(uiEnv["PO_ROOT"]).toBe("/A");
673
+ expect(uiEnv["PORT"]).toBe("4000");
674
+ expect(uiEnv["PO_CORS_ORIGINS"]).toBe("https://app.example");
675
+ expect(uiEnv["PO_CORS_ALLOW_NULL_ORIGIN"]).toBe("1");
676
+ });
677
+ });
678
+
679
+ describe("handleStart error path", () => {
680
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
681
+ let exitSpy: MockInstance<any>;
682
+
683
+ beforeEach(() => {
684
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(
685
+ (_code?: string | number | null | undefined) => {
686
+ throw new Error(`process.exit(${_code})`);
687
+ },
688
+ );
689
+ });
690
+
691
+ it("exits 1 with the resolver message when --root and PO_ROOT are both absent", async () => {
692
+ await expect(handleStart(undefined, "4000")).rejects.toThrow("process.exit(1)");
693
+ expect(exitSpy).toHaveBeenCalledWith(1);
694
+ });
695
+ });
696
+
697
+ describe("hidden subcommand boot delegation", () => {
698
+ it("handleStartUi calls startServer with dataDir: undefined when PO_ROOT is unset (no cwd fallback)", async () => {
699
+ await handleStartUi();
700
+
701
+ expect(mockStartServer).toHaveBeenCalledTimes(1);
702
+ expect(mockStartServer).toHaveBeenCalledWith({
703
+ dataDir: undefined,
704
+ port: 4000,
705
+ });
706
+ });
707
+
708
+ it("handleStartUi calls startServer with the transported PO_ROOT when set", async () => {
709
+ process.env["PO_ROOT"] = "/transported";
710
+
711
+ await handleStartUi();
712
+
713
+ expect(mockStartServer).toHaveBeenCalledTimes(1);
714
+ expect(mockStartServer).toHaveBeenCalledWith({
715
+ dataDir: "/transported",
716
+ port: 4000,
717
+ });
718
+ });
719
+
720
+ it("handleStartOrchestrator calls startOrchestrator with dataDir: undefined when PO_ROOT is unset (no manual guard)", async () => {
721
+ await handleStartOrchestrator();
722
+
723
+ expect(mockStartOrchestrator).toHaveBeenCalledTimes(1);
724
+ expect(mockStartOrchestrator).toHaveBeenCalledWith({ dataDir: undefined });
725
+ });
726
+
727
+ it("handleStartOrchestrator calls startOrchestrator with the transported PO_ROOT when set", async () => {
728
+ process.env["PO_ROOT"] = "/transported";
729
+
730
+ await handleStartOrchestrator();
731
+
732
+ expect(mockStartOrchestrator).toHaveBeenCalledTimes(1);
733
+ expect(mockStartOrchestrator).toHaveBeenCalledWith({ dataDir: "/transported" });
734
+ });
735
+
736
+ it("handleRunJob calls runPipelineJob(jobId) without reading env", async () => {
737
+ await handleRunJob("job-123");
738
+
739
+ expect(mockRunPipelineJob).toHaveBeenCalledTimes(1);
740
+ expect(mockRunPipelineJob).toHaveBeenCalledWith("job-123");
741
+ });
742
+ });
743
+ });
744
+
425
745
  // ─── buildUiCorsEnv ─────────────────────────────────────────────────────────
426
746
 
427
747
  describe("buildUiCorsEnv", () => {
@@ -1,29 +1,11 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import type {
3
- Registry,
4
- PipelineRegistryEntry,
5
3
  PipelineConfig,
6
4
  ReexecArgs,
7
5
  TaskIndex,
8
6
  } from "../types.js";
9
7
 
10
8
  describe("CLI types", () => {
11
- it("Registry with empty pipelines is valid", () => {
12
- const registry: Registry = { pipelines: {} };
13
- expect(registry.pipelines).toEqual({});
14
- });
15
-
16
- it("Registry with pipeline entries is valid", () => {
17
- const entry: PipelineRegistryEntry = {
18
- name: "my-pipeline",
19
- description: "A test pipeline",
20
- pipelinePath: "./pipeline-config/my-pipeline/pipeline.json",
21
- taskRegistryPath: "./pipeline-config/my-pipeline/tasks/index.ts",
22
- };
23
- const registry: Registry = { pipelines: { "my-pipeline": entry } };
24
- expect(registry.pipelines["my-pipeline"]).toBe(entry);
25
- });
26
-
27
9
  it("PipelineConfig with all required fields is valid", () => {
28
10
  const config: PipelineConfig = {
29
11
  name: "my-pipeline",
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdtemp, mkdir, rm } from "node:fs/promises";
2
+ import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { JsonFileError } from "../../core/json-file.ts";
5
6
  import { updatePipelineJson } from "../update-pipeline-json.ts";
6
7
 
7
8
  describe("updatePipelineJson", () => {
@@ -18,7 +19,7 @@ describe("updatePipelineJson", () => {
18
19
  await rm(tmpDir, { recursive: true, force: true });
19
20
  });
20
21
 
21
- it("appends a task slug to existing tasks", async () => {
22
+ it("appends a task slug, preserving name/version/description and existing tasks", async () => {
22
23
  const filePath = join(pipelineDir, "pipeline.json");
23
24
  await Bun.write(
24
25
  filePath,
@@ -32,6 +33,9 @@ describe("updatePipelineJson", () => {
32
33
  await updatePipelineJson(tmpDir, "test-pipeline", "b");
33
34
 
34
35
  const result = JSON.parse(await Bun.file(filePath).text());
36
+ expect(result.name).toBe("test");
37
+ expect(result.version).toBe("1.0.0");
38
+ expect(result.description).toBe("Test");
35
39
  expect(result.tasks).toEqual(["a", "b"]);
36
40
  });
37
41
 
@@ -53,15 +57,21 @@ describe("updatePipelineJson", () => {
53
57
  expect(result.tasks).toEqual(["a", "b"]);
54
58
  });
55
59
 
56
- it("creates a minimal config with the task when pipeline.json is missing", async () => {
60
+ it("throws and leaves the file unchanged when pipeline.json is malformed", async () => {
57
61
  const filePath = join(pipelineDir, "pipeline.json");
62
+ const original = "{ not valid json ";
63
+ await Bun.write(filePath, original);
58
64
 
59
- await updatePipelineJson(tmpDir, "test-pipeline", "b");
65
+ await expect(updatePipelineJson(tmpDir, "test-pipeline", "b")).rejects.toBeInstanceOf(
66
+ JsonFileError
67
+ );
60
68
 
61
- const result = JSON.parse(await Bun.file(filePath).text());
62
- expect(result.name).toBe("test-pipeline");
63
- expect(result.version).toBe("1.0.0");
64
- expect(result.description).toBe("New pipeline");
65
- expect(result.tasks).toEqual(["b"]);
69
+ expect(await readFile(filePath, "utf8")).toBe(original);
70
+ });
71
+
72
+ it("throws when pipeline.json is missing", async () => {
73
+ await expect(updatePipelineJson(tmpDir, "test-pipeline", "b")).rejects.toBeInstanceOf(
74
+ JsonFileError
75
+ );
66
76
  });
67
77
  });
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { analyzeTask } from "../task-analysis/index.ts";
2
+ import { analyzeTask } from "../task-analysis/analyzer.ts";
3
3
 
4
4
  export async function analyzeTaskFile(taskPath: string): Promise<void> {
5
5
  const absolutePath = path.resolve(taskPath);
@@ -17,17 +17,6 @@ export async function analyzeTaskFile(taskPath: string): Promise<void> {
17
17
  throw err;
18
18
  }
19
19
 
20
- try {
21
- const result = analyzeTask(code, absolutePath);
22
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
23
- } catch (err) {
24
- const isDev =
25
- process.env.NODE_ENV === "development" || process.env.DEBUG_TASK_ANALYSIS === "1";
26
- if (isDev && err instanceof Error && err.stack) {
27
- console.error(err.stack);
28
- } else {
29
- console.error(err instanceof Error ? err.message : String(err));
30
- }
31
- process.exit(1);
32
- }
20
+ const result = await analyzeTask(code);
21
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
33
22
  }