@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/src/cli/index.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
3
  import { mkdir, access } from "node:fs/promises";
4
- import { resolve, join } from "node:path";
4
+ import { join } from "node:path";
5
5
 
6
6
  import { STAGE_NAMES, getStagePurpose, KEBAB_CASE_REGEX } from "./constants.ts";
7
7
  import { buildReexecArgs, isCompiledBinary } from "./self-reexec.ts";
8
8
  import { updatePipelineJson } from "./update-pipeline-json.ts";
9
9
  import { analyzeTaskFile } from "./analyze-task.ts";
10
10
  import { submitJobWithValidation, PipelineOrchestrator } from "../api/index.ts";
11
- import type { Registry } from "./types.ts";
11
+ import { resolveWorkspaceRoot } from "../config/paths.ts";
12
+ import {
13
+ writeRegistry,
14
+ registerPipeline,
15
+ REGISTRY_VERSION,
16
+ } from "../config/pipeline-registry.ts";
12
17
  import pkg from "../../package.json";
13
18
 
14
19
  // ─── init ─────────────────────────────────────────────────────────────────────
@@ -21,10 +26,7 @@ export async function handleInit(root: string): Promise<void> {
21
26
  await mkdir(join(root, "pipeline-data", sub), { recursive: true });
22
27
  await Bun.write(join(root, "pipeline-data", sub, ".gitkeep"), "");
23
28
  }
24
- await Bun.write(
25
- join(root, "registry.json"),
26
- JSON.stringify({ pipelines: {} }, null, 2) + "\n"
27
- );
29
+ await writeRegistry(root, { version: REGISTRY_VERSION, pipelines: {} });
28
30
  }
29
31
 
30
32
  // ─── start ────────────────────────────────────────────────────────────────────
@@ -43,18 +45,49 @@ export function buildUiCorsEnv(opts: StartOptions): Record<string, string> {
43
45
  return env;
44
46
  }
45
47
 
48
+ /**
49
+ * Build the child env objects passed to the spawned `_start-ui` and
50
+ * `_start-orchestrator` processes. The parent `handleStart` resolves the
51
+ * workspace root once via `resolveWorkspaceRoot` and transports the absolute
52
+ * value to the children via `PO_ROOT`; the children perform their own boot
53
+ * resolution from that transported value.
54
+ */
55
+ export function buildStartChildEnv(
56
+ absoluteRoot: string,
57
+ port: string,
58
+ opts?: StartOptions,
59
+ ): { uiEnv: Record<string, string>; orchEnv: Record<string, string> } {
60
+ const uiEnv: Record<string, string> = Object.fromEntries(
61
+ Object.entries({ ...process.env, NODE_ENV: "production", PO_ROOT: absoluteRoot, PORT: port }).filter(
62
+ (entry): entry is [string, string] => entry[1] !== undefined,
63
+ ),
64
+ );
65
+ delete uiEnv["PO_UI_PORT"];
66
+
67
+ const corsEnv = opts ? buildUiCorsEnv(opts) : {};
68
+ Object.assign(uiEnv, corsEnv);
69
+
70
+ const orchEnv: Record<string, string> = Object.fromEntries(
71
+ Object.entries({ ...process.env, NODE_ENV: "production", PO_ROOT: absoluteRoot }).filter(
72
+ (entry): entry is [string, string] => entry[1] !== undefined,
73
+ ),
74
+ );
75
+
76
+ return { uiEnv, orchEnv };
77
+ }
78
+
46
79
  export async function handleStart(
47
80
  root: string | undefined,
48
81
  port: string,
49
82
  opts?: StartOptions,
50
83
  ): Promise<void> {
51
- const rawRoot = root ?? process.env["PO_ROOT"];
52
- if (!rawRoot) {
53
- console.error("Error: --root or PO_ROOT environment variable is required");
84
+ let absoluteRoot: string;
85
+ try {
86
+ absoluteRoot = resolveWorkspaceRoot(root);
87
+ } catch (err) {
88
+ console.error(err instanceof Error ? err.message : String(err));
54
89
  process.exit(1);
55
90
  }
56
-
57
- const absoluteRoot = resolve(rawRoot);
58
91
  process.env["PO_ROOT"] = absoluteRoot;
59
92
 
60
93
  if (!isCompiledBinary()) {
@@ -69,21 +102,7 @@ export async function handleStart(
69
102
  }
70
103
  }
71
104
 
72
- const uiEnv: Record<string, string> = Object.fromEntries(
73
- Object.entries({ ...process.env, NODE_ENV: "production", PO_ROOT: absoluteRoot, PORT: port }).filter(
74
- (entry): entry is [string, string] => entry[1] !== undefined
75
- )
76
- );
77
- delete uiEnv["PO_UI_PORT"];
78
-
79
- const corsEnv = opts ? buildUiCorsEnv(opts) : {};
80
- Object.assign(uiEnv, corsEnv);
81
-
82
- const orchEnv: Record<string, string> = Object.fromEntries(
83
- Object.entries({ ...process.env, NODE_ENV: "production", PO_ROOT: absoluteRoot }).filter(
84
- (entry): entry is [string, string] => entry[1] !== undefined
85
- )
86
- );
105
+ const { uiEnv, orchEnv } = buildStartChildEnv(absoluteRoot, port, opts);
87
106
 
88
107
  const uiReexec = buildReexecArgs(["_start-ui"]);
89
108
  const orchReexec = buildReexecArgs(["_start-orchestrator"]);
@@ -182,7 +201,10 @@ export async function handleStart(
182
201
 
183
202
  // ─── submit ───────────────────────────────────────────────────────────────────
184
203
 
185
- export async function handleSubmit(seedFile: string): Promise<void> {
204
+ export async function handleSubmit(
205
+ seedFile: string,
206
+ opts: { root?: string } = {},
207
+ ): Promise<void> {
186
208
  let seedObject: unknown;
187
209
  try {
188
210
  const text = await Bun.file(seedFile).text();
@@ -192,8 +214,9 @@ export async function handleSubmit(seedFile: string): Promise<void> {
192
214
  process.exit(1);
193
215
  }
194
216
 
217
+ const root = opts.root ?? process.env["PO_ROOT"] ?? process.cwd();
195
218
  try {
196
- const result = await submitJobWithValidation({ dataDir: process.cwd(), seedObject });
219
+ const result = await submitJobWithValidation({ root, seedObject });
197
220
  if (result.success) {
198
221
  console.log(`Job submitted: ${result.jobId} (${result.jobName})`);
199
222
  } else {
@@ -208,8 +231,12 @@ export async function handleSubmit(seedFile: string): Promise<void> {
208
231
 
209
232
  // ─── status ───────────────────────────────────────────────────────────────────
210
233
 
211
- export async function handleStatus(jobName: string | undefined): Promise<void> {
212
- const orchestrator = new PipelineOrchestrator({ autoStart: false });
234
+ export async function handleStatus(
235
+ jobName: string | undefined,
236
+ opts: { root?: string } = {},
237
+ ): Promise<void> {
238
+ const root = opts.root ?? process.env["PO_ROOT"] ?? process.cwd();
239
+ const orchestrator = new PipelineOrchestrator({ autoStart: false, root });
213
240
  if (jobName) {
214
241
  const result = await orchestrator.getStatus(jobName);
215
242
  console.log(JSON.stringify(result, null, 2));
@@ -245,25 +272,7 @@ export async function handleAddPipeline(slug: string, root: string): Promise<voi
245
272
  );
246
273
  await Bun.write(join(tasksDir, "index.ts"), "export default {};\n");
247
274
 
248
- const registryPath = join(root, "registry.json");
249
- let registry: Registry = { pipelines: {} };
250
- try {
251
- const text = await Bun.file(registryPath).text();
252
- registry = JSON.parse(text) as Registry;
253
- } catch {
254
- // fallback to empty registry
255
- }
256
-
257
- const pipelinePath = join(pipelineDir, "pipeline.json");
258
- const taskRegistryPath = join(tasksDir, "index.ts");
259
- registry.pipelines[slug] = {
260
- name: slug,
261
- description: "New pipeline",
262
- pipelinePath,
263
- taskRegistryPath,
264
- };
265
-
266
- await Bun.write(registryPath, JSON.stringify(registry, null, 2) + "\n");
275
+ await registerPipeline(root, slug, { name: slug, description: "New pipeline" });
267
276
  } catch (err) {
268
277
  console.error(`Error creating pipeline: ${err instanceof Error ? err.message : String(err)}`);
269
278
  process.exit(1);
@@ -393,21 +402,21 @@ export async function handleAnalyze(taskPath: string): Promise<void> {
393
402
 
394
403
  // ─── Hidden: _start-ui ───────────────────────────────────────────────────────
395
404
 
396
- async function handleStartUi(): Promise<void> {
405
+ export async function handleStartUi(): Promise<void> {
406
+ // PO_ROOT is the inter-process transport set by `handleStart`; the boot
407
+ // function (startServer) owns the runtime resolution.
397
408
  const { startServer } = await import("../ui/server/index.ts");
398
409
  await startServer({
399
- dataDir: process.env["PO_ROOT"] ?? process.cwd(),
410
+ dataDir: process.env["PO_ROOT"],
400
411
  port: parseInt(process.env["PORT"] ?? "4000", 10),
401
412
  });
402
413
  }
403
414
 
404
415
  // ─── Hidden: _start-orchestrator ─────────────────────────────────────────────
405
416
 
406
- async function handleStartOrchestrator(): Promise<void> {
407
- if (!process.env["PO_ROOT"]) {
408
- console.error("Error: PO_ROOT environment variable is required");
409
- process.exit(1);
410
- }
417
+ export async function handleStartOrchestrator(): Promise<void> {
418
+ // PO_ROOT is the inter-process transport set by `handleStart`; the boot
419
+ // function (startOrchestrator) owns the runtime resolution.
411
420
  const { startOrchestrator } = await import("../core/orchestrator.ts");
412
421
  const handle = await startOrchestrator({ dataDir: process.env["PO_ROOT"] });
413
422
  process.on("SIGINT", () => {
@@ -420,7 +429,8 @@ async function handleStartOrchestrator(): Promise<void> {
420
429
 
421
430
  // ─── Hidden: _run-job ─────────────────────────────────────────────────────────
422
431
 
423
- async function handleRunJob(jobId: string): Promise<void> {
432
+ export async function handleRunJob(jobId: string): Promise<void> {
433
+ // Boot resolution happens inside runPipelineJob; this handler only forwards.
424
434
  const { runPipelineJob } = await import("../core/pipeline-runner.ts");
425
435
  await runPipelineJob(jobId);
426
436
  }
@@ -456,15 +466,17 @@ program
456
466
  program
457
467
  .command("submit <seed-file>")
458
468
  .description("Submit a job from a seed JSON file")
459
- .action(async (seedFile: string) => {
460
- await handleSubmit(seedFile);
469
+ .option("--root <path>", "Workspace root")
470
+ .action(async (seedFile: string, opts: { root?: string }) => {
471
+ await handleSubmit(seedFile, opts);
461
472
  });
462
473
 
463
474
  program
464
475
  .command("status [job-name]")
465
476
  .description("Query job status")
466
- .action(async (jobName: string | undefined) => {
467
- await handleStatus(jobName);
477
+ .option("--root <path>", "Workspace root")
478
+ .action(async (jobName: string | undefined, opts: { root?: string }) => {
479
+ await handleStatus(jobName, opts);
468
480
  });
469
481
 
470
482
  program
package/src/cli/types.ts CHANGED
@@ -1,16 +1,3 @@
1
- /** Central index of all pipelines in the workspace. */
2
- export interface Registry {
3
- pipelines: Record<string, PipelineRegistryEntry>;
4
- }
5
-
6
- /** A single pipeline's entry in the registry. */
7
- export interface PipelineRegistryEntry {
8
- name: string;
9
- description: string;
10
- pipelinePath: string;
11
- taskRegistryPath: string;
12
- }
13
-
14
1
  /** A pipeline's configuration file. */
15
2
  export interface PipelineConfig {
16
3
  name: string;
@@ -1,3 +1,4 @@
1
+ import { readJsonFile, writeJsonFile } from "../core/json-file.ts";
1
2
  import type { PipelineConfig } from "./types.ts";
2
3
 
3
4
  export async function updatePipelineJson(
@@ -7,19 +8,7 @@ export async function updatePipelineJson(
7
8
  ): Promise<void> {
8
9
  const filePath = `${root}/pipeline-config/${pipelineSlug}/pipeline.json`;
9
10
 
10
- let config: PipelineConfig;
11
- try {
12
- const text = await Bun.file(filePath).text();
13
- const parsed = JSON.parse(text) as PipelineConfig;
14
- config = parsed;
15
- } catch {
16
- config = {
17
- name: pipelineSlug,
18
- version: "1.0.0",
19
- description: "New pipeline",
20
- tasks: [],
21
- };
22
- }
11
+ const config = await readJsonFile<PipelineConfig>(filePath);
23
12
 
24
13
  if (!Array.isArray(config.tasks)) {
25
14
  config.tasks = [];
@@ -29,5 +18,5 @@ export async function updatePipelineJson(
29
18
  config.tasks.push(taskSlug);
30
19
  }
31
20
 
32
- await Bun.write(filePath, JSON.stringify(config, null, 2) + "\n");
21
+ await writeJsonFile(filePath, config);
33
22
  }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import Ajv from "ajv";
5
+
6
+ const schemaPath = join(process.cwd(), "config.schema.json");
7
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8")) as {
8
+ properties: { llm: { properties: { defaultProvider: { enum: string[] } } } };
9
+ };
10
+
11
+ const ajv = new Ajv({ allErrors: true, strict: false });
12
+ const validate = ajv.compile(schema);
13
+
14
+ function llmConfig(defaultProvider: string): unknown {
15
+ return { llm: { defaultProvider } };
16
+ }
17
+
18
+ describe("config.schema.json defaultProvider enum", () => {
19
+ it("accepts canonical provider ids", () => {
20
+ expect(validate(llmConfig("openai"))).toBe(true);
21
+ expect(validate(llmConfig("zai"))).toBe(true);
22
+ expect(validate(llmConfig("claude-code"))).toBe(true);
23
+ expect(validate(llmConfig("moonshot"))).toBe(true);
24
+ });
25
+
26
+ it("rejects legacy provider ids", () => {
27
+ expect(validate(llmConfig("zhipu"))).toBe(false);
28
+ expect(validate(llmConfig("claudecode"))).toBe(false);
29
+ });
30
+
31
+ it("rejects unknown provider ids", () => {
32
+ expect(validate(llmConfig("neverheard"))).toBe(false);
33
+ });
34
+
35
+ it("enum matches the canonical ProviderName set (no legacy spellings)", () => {
36
+ const enumValues = schema.properties.llm.properties.defaultProvider.enum;
37
+ expect(enumValues).not.toContain("zhipu");
38
+ expect(enumValues).not.toContain("claudecode");
39
+ expect(enumValues).toContain("zai");
40
+ expect(enumValues).toContain("claude-code");
41
+ });
42
+ });
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join, relative, resolve, sep } from "node:path";
4
+
5
+ const FORBIDDEN_LITERALS: ReadonlySet<string> = new Set([
6
+ "zhipu",
7
+ "claudecode",
8
+ ]);
9
+
10
+ const STRING_LITERAL_RE = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g;
11
+
12
+ const ALLOWED_FILES: ReadonlySet<string> = new Set([
13
+ "src/config/models.ts",
14
+ "src/config/__tests__/models.test.ts",
15
+ "src/config/__tests__/config-schema.test.ts",
16
+ "src/config/__tests__/legacy-literal-guard.test.ts",
17
+ "src/llm/__tests__/legacy-normalization.test.ts",
18
+ "src/llm/__tests__/index.test.ts",
19
+ "src/providers/__tests__/types.test.ts",
20
+ "src/task-analysis/__tests__/analyzer.test.ts",
21
+ "src/task-analysis/__tests__/enrichers-schema-deducer.test.ts",
22
+ ]);
23
+
24
+ export interface LegacyLiteralViolation {
25
+ path: string;
26
+ literal: string;
27
+ }
28
+
29
+ export interface SourceFile {
30
+ path: string;
31
+ content: string;
32
+ }
33
+
34
+ export function extractStringLiterals(source: string): string[] {
35
+ const out: string[] = [];
36
+ for (const match of source.matchAll(STRING_LITERAL_RE)) {
37
+ const raw = match[0] as string;
38
+ out.push(raw.slice(1, -1));
39
+ }
40
+ return out;
41
+ }
42
+
43
+ export function scanFiles(
44
+ files: SourceFile[],
45
+ allowlist: ReadonlySet<string>,
46
+ ): LegacyLiteralViolation[] {
47
+ const violations: LegacyLiteralViolation[] = [];
48
+ for (const file of files) {
49
+ if (allowlist.has(file.path)) continue;
50
+ for (const literal of extractStringLiterals(file.content)) {
51
+ if (FORBIDDEN_LITERALS.has(literal)) {
52
+ violations.push({ path: file.path, literal });
53
+ }
54
+ }
55
+ }
56
+ return violations;
57
+ }
58
+
59
+ export function listSourceTsFiles(scanRoot: string, base: string): SourceFile[] {
60
+ const rootAbs = resolve(scanRoot);
61
+ const baseAbs = resolve(base);
62
+ const out: SourceFile[] = [];
63
+
64
+ function walk(dir: string): void {
65
+ for (const entry of readdirSync(dir)) {
66
+ const full = join(dir, entry);
67
+ const st = statSync(full);
68
+ if (st.isDirectory()) {
69
+ walk(full);
70
+ } else if (st.isFile() && /\.(ts|tsx)$/.test(entry)) {
71
+ const rel = relative(baseAbs, full).split(sep).join("/");
72
+ out.push({ path: rel, content: readFileSync(full, "utf-8") });
73
+ }
74
+ }
75
+ }
76
+
77
+ walk(rootAbs);
78
+ return out;
79
+ }
80
+
81
+ describe("extractStringLiterals", () => {
82
+ it("captures a double-quoted forbidden literal", () => {
83
+ expect(extractStringLiterals('const p = "zhipu";')).toContain("zhipu");
84
+ });
85
+
86
+ it("ignores the bare legacy word in a comment", () => {
87
+ expect(extractStringLiterals("// legacy zhipu id\nconst x = 1;")).toEqual([]);
88
+ });
89
+ });
90
+
91
+ describe("scanFiles — forbidden literal detection", () => {
92
+ it('flags "zhipu" outside the allowlist', () => {
93
+ const violations = scanFiles(
94
+ [{ path: "src/x.ts", content: 'const p = "zhipu";' }],
95
+ new Set(),
96
+ );
97
+ expect(violations).toEqual([{ path: "src/x.ts", literal: "zhipu" }]);
98
+ });
99
+
100
+ it('flags "claudecode" outside the allowlist', () => {
101
+ const violations = scanFiles(
102
+ [{ path: "src/y.ts", content: "const q = \"claudecode\";" }],
103
+ new Set(),
104
+ );
105
+ expect(violations).toEqual([{ path: "src/y.ts", literal: "claudecode" }]);
106
+ });
107
+
108
+ it("skips files listed in the allowlist", () => {
109
+ const violations = scanFiles(
110
+ [{ path: "src/config/models.ts", content: 'legacyAliases: ["zhipu"]' }],
111
+ new Set(["src/config/models.ts"]),
112
+ );
113
+ expect(violations).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe("scanFiles — legitimate substrings are not flagged", () => {
118
+ it("ignores the ZHIPU_API_KEY env-var name", () => {
119
+ const violations = scanFiles(
120
+ [{ path: "src/env.ts", content: 'const k = "ZHIPU_API_KEY";' }],
121
+ new Set(),
122
+ );
123
+ expect(violations).toEqual([]);
124
+ });
125
+
126
+ it("ignores an import path that ends in zhipu.ts", () => {
127
+ const violations = scanFiles(
128
+ [
129
+ {
130
+ path: "src/llm/index.ts",
131
+ content: 'import { zaiChat } from "../providers/zhipu.ts";',
132
+ },
133
+ ],
134
+ new Set(),
135
+ );
136
+ expect(violations).toEqual([]);
137
+ });
138
+
139
+ it("ignores the legacy word inside a comment", () => {
140
+ const violations = scanFiles(
141
+ [{ path: "src/cmt.ts", content: "// legacy zhipu id\nconst x = 1;" }],
142
+ new Set(),
143
+ );
144
+ expect(violations).toEqual([]);
145
+ });
146
+ });
147
+
148
+ describe("legacy-literal source guard (AC-3)", () => {
149
+ it("src/ has no forbidden legacy literals outside the allowlist", () => {
150
+ const cwd = process.cwd();
151
+ const files = listSourceTsFiles(join(cwd, "src"), cwd);
152
+ const violations = scanFiles(files, ALLOWED_FILES);
153
+
154
+ expect(violations).toEqual([]);
155
+ });
156
+ });
@@ -6,17 +6,20 @@ import {
6
6
  DEFAULT_MODEL_BY_PROVIDER,
7
7
  FUNCTION_NAME_BY_ALIAS,
8
8
  PROVIDER_FUNCTIONS,
9
+ PROVIDERS,
9
10
  aliasToFunctionName,
10
11
  getProviderFromAlias,
11
12
  getModelFromAlias,
12
13
  getModelConfig,
13
14
  buildProviderFunctionsIndex,
15
+ normalizeLegacyProvider,
14
16
  validateModelRegistry,
15
17
  } from "../models";
16
- import type { ModelConfigEntry } from "../models";
18
+ import type { ModelConfigEntry, ProviderName, ProviderMeta } from "../models";
17
19
 
18
20
  const MODEL_COUNT = 51;
19
21
  const PROVIDER_COUNT = 9;
22
+ const REGISTRY_PROVIDER_COUNT = 10;
20
23
 
21
24
  describe("ModelAlias", () => {
22
25
  it(`has exactly ${MODEL_COUNT} entries`, () => {
@@ -378,3 +381,138 @@ describe("validateModelRegistry — invariant failures", () => {
378
381
  expect(() => validateModelRegistry(config, set)).not.toThrow();
379
382
  });
380
383
  });
384
+
385
+ describe("normalizeLegacyProvider", () => {
386
+ it('maps legacy "zhipu" to canonical "zai"', () => {
387
+ expect(normalizeLegacyProvider("zhipu")).toBe("zai");
388
+ });
389
+
390
+ it('maps legacy "claudecode" to canonical "claude-code"', () => {
391
+ expect(normalizeLegacyProvider("claudecode")).toBe("claude-code");
392
+ });
393
+
394
+ it('returns canonical "zai" for itself', () => {
395
+ expect(normalizeLegacyProvider("zai")).toBe("zai");
396
+ });
397
+
398
+ it('returns canonical "claude-code" for itself', () => {
399
+ expect(normalizeLegacyProvider("claude-code")).toBe("claude-code");
400
+ });
401
+
402
+ it('returns canonical "openai" for itself', () => {
403
+ expect(normalizeLegacyProvider("openai")).toBe("openai");
404
+ });
405
+
406
+ it("returns null for an unknown provider string", () => {
407
+ expect(normalizeLegacyProvider("unknown")).toBeNull();
408
+ });
409
+
410
+ it("returns null for the empty string", () => {
411
+ expect(normalizeLegacyProvider("")).toBeNull();
412
+ });
413
+
414
+ it("returns null for Object.prototype property names", () => {
415
+ expect(normalizeLegacyProvider("constructor")).toBeNull();
416
+ expect(normalizeLegacyProvider("toString")).toBeNull();
417
+ expect(normalizeLegacyProvider("valueOf")).toBeNull();
418
+ expect(normalizeLegacyProvider("hasOwnProperty")).toBeNull();
419
+ expect(normalizeLegacyProvider("__proto__")).toBeNull();
420
+ });
421
+ });
422
+
423
+ describe("PROVIDERS registry invariants", () => {
424
+ const CANONICAL_PROVIDER_IDS: ProviderName[] = [
425
+ "openai",
426
+ "anthropic",
427
+ "deepseek",
428
+ "gemini",
429
+ "zai",
430
+ "claude-code",
431
+ "moonshot",
432
+ "alibaba",
433
+ "opencode",
434
+ "mock",
435
+ ];
436
+
437
+ it(`has exactly ${REGISTRY_PROVIDER_COUNT} rows, one per canonical ProviderName`, () => {
438
+ expect(Object.keys(PROVIDERS).length).toBe(REGISTRY_PROVIDER_COUNT);
439
+ for (const id of CANONICAL_PROVIDER_IDS) {
440
+ expect(id in PROVIDERS).toBe(true);
441
+ }
442
+ });
443
+
444
+ it("table is frozen", () => {
445
+ expect(Object.isFrozen(PROVIDERS)).toBe(true);
446
+ });
447
+
448
+ it("legacyAliases are unique across the registry (no alias maps to two providers)", () => {
449
+ const seen = new Map<string, ProviderName>();
450
+ for (const [canonical, meta] of Object.entries(PROVIDERS) as [ProviderName, ProviderMeta][]) {
451
+ for (const alias of meta.legacyAliases) {
452
+ const prior = seen.get(alias);
453
+ if (prior !== undefined) {
454
+ throw new Error(
455
+ `legacy alias "${alias}" is claimed by both "${prior}" and "${canonical}"`,
456
+ );
457
+ }
458
+ seen.set(alias, canonical);
459
+ }
460
+ }
461
+ });
462
+
463
+ it("no legacy alias collides with a canonical id", () => {
464
+ const canonicalSet = new Set<string>(CANONICAL_PROVIDER_IDS);
465
+ for (const meta of Object.values(PROVIDERS)) {
466
+ for (const alias of meta.legacyAliases) {
467
+ expect(canonicalSet.has(alias)).toBe(false);
468
+ }
469
+ }
470
+ });
471
+
472
+ it("MODEL_CONFIG providers are all registered catalog providers (no orphans, no mock)", () => {
473
+ for (const entry of Object.values(MODEL_CONFIG)) {
474
+ expect(entry.provider in PROVIDERS).toBe(true);
475
+ expect(entry.provider).not.toBe("mock");
476
+ }
477
+ });
478
+
479
+ it("MODEL_CONFIG / DEFAULT_MODEL_BY_PROVIDER / PROVIDER_FUNCTIONS exclude only mock from the registry", () => {
480
+ const registryKeys = new Set<string>(Object.keys(PROVIDERS));
481
+ const catalogKeys = new Set<string>(Object.keys(PROVIDER_FUNCTIONS));
482
+ const defaultKeys = new Set<string>(
483
+ Object.keys(DEFAULT_MODEL_BY_PROVIDER) as string[],
484
+ );
485
+
486
+ for (const key of catalogKeys) expect(registryKeys.has(key)).toBe(true);
487
+ for (const key of defaultKeys) expect(registryKeys.has(key)).toBe(true);
488
+
489
+ const missingFromCatalog = [...registryKeys].filter(
490
+ (k) => !catalogKeys.has(k),
491
+ );
492
+ const missingFromDefaults = [...registryKeys].filter(
493
+ (k) => !defaultKeys.has(k),
494
+ );
495
+ expect(missingFromCatalog).toEqual(["mock"]);
496
+ expect(missingFromDefaults).toEqual(["mock"]);
497
+ });
498
+
499
+ it("canonical<->alias round-trip via normalizeLegacyProvider", () => {
500
+ for (const [canonical, meta] of Object.entries(PROVIDERS) as [ProviderName, ProviderMeta][]) {
501
+ expect(normalizeLegacyProvider(canonical)).toBe(canonical);
502
+ for (const alias of meta.legacyAliases) {
503
+ expect(normalizeLegacyProvider(alias)).toBe(canonical);
504
+ }
505
+ }
506
+ });
507
+
508
+ it("CLI providers declare sampling: []", () => {
509
+ expect(PROVIDERS["claude-code"]!.sampling).toEqual([]);
510
+ expect(PROVIDERS.opencode!.sampling).toEqual([]);
511
+ });
512
+
513
+ it("mock is runtime-only: empty envKeys/sampling, isCLI false", () => {
514
+ expect(PROVIDERS.mock!.envKeys).toEqual([]);
515
+ expect(PROVIDERS.mock!.sampling).toEqual([]);
516
+ expect(PROVIDERS.mock!.isCLI).toBe(false);
517
+ });
518
+ });