@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -1,56 +1,8 @@
1
1
  import { describe, test, expect, afterEach } from "bun:test";
2
- import { defaultConfig, resetConfig, loadConfig, getConfig, getConfigValue, getPipelineConfig } from "../config";
3
- import type { AppConfig } from "../config";
4
- import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
2
+ import { readFileSync, readdirSync, statSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
5
3
  import { tmpdir } from "node:os";
6
4
  import { join } from "node:path";
7
-
8
- // Tests for validateConfig behavior are covered indirectly through loadConfig tests.
9
- // Direct tests for edge cases:
10
-
11
- describe("config environment overrides", () => {
12
- const originalEnv = { ...process.env };
13
- afterEach(() => {
14
- process.env = { ...originalEnv };
15
- resetConfig();
16
- });
17
-
18
- test("PO_SHUTDOWN_TIMEOUT overrides orchestrator.shutdownTimeout", async () => {
19
- // Tested via loadConfig in step 7
20
- });
21
- });
22
-
23
- describe("loadConfig", () => {
24
- afterEach(() => {
25
- resetConfig();
26
- });
27
-
28
- test("throws when PO_ROOT is not set", async () => {
29
- const origRoot = process.env.PO_ROOT;
30
- delete process.env.PO_ROOT;
31
- try {
32
- await expect(loadConfig()).rejects.toThrow("PO_ROOT is required");
33
- } finally {
34
- if (origRoot) process.env.PO_ROOT = origRoot;
35
- }
36
- });
37
-
38
- test("loads config from file and merges with defaults", async () => {
39
- const dir = await mkdtemp(join(tmpdir(), "config-test-"));
40
- const configDir = join(dir, "pipeline-config", "test");
41
- const tasksDir = join(configDir, "tasks");
42
- await mkdir(tasksDir, { recursive: true });
43
- await writeFile(join(configDir, "pipeline.json"), JSON.stringify({ name: "test", tasks: ["t1"] }));
44
- await writeFile(join(dir, "pipeline-config", "registry.json"), JSON.stringify({
45
- pipelines: { test: { configDir, tasksDir } }
46
- }));
47
- process.env.PO_ROOT = dir;
48
- const config = await loadConfig();
49
- expect(config.paths.root).toBe(dir);
50
- expect(config.pipelines.test).toBeDefined();
51
- await rm(dir, { recursive: true });
52
- });
53
- });
5
+ import { defaultConfig, resetConfig, getConfig, getConfigValue, getPipelineConfig, getOrchestratorConfig } from "../config";
54
6
 
55
7
  describe("defaultConfig", () => {
56
8
  afterEach(() => {
@@ -62,7 +14,6 @@ describe("defaultConfig", () => {
62
14
  expect(defaultConfig.taskRunner).toBeDefined();
63
15
  expect(defaultConfig.llm).toBeDefined();
64
16
  expect(defaultConfig.ui).toBeDefined();
65
- expect(defaultConfig.paths).toBeDefined();
66
17
  expect(defaultConfig.validation).toBeDefined();
67
18
  expect(defaultConfig.logging).toBeDefined();
68
19
  });
@@ -82,54 +33,6 @@ describe("defaultConfig", () => {
82
33
  });
83
34
  });
84
35
 
85
- describe("validateConfig (via loadConfig)", () => {
86
- afterEach(() => {
87
- resetConfig();
88
- });
89
-
90
- test("throws when taskRunner.maxAttempts is 0", async () => {
91
- const dir = await mkdtemp(join(tmpdir(), "config-test-"));
92
- const configDir = join(dir, "pipeline-config", "test");
93
- const tasksDir = join(configDir, "tasks");
94
- await mkdir(tasksDir, { recursive: true });
95
- await writeFile(join(configDir, "pipeline.json"), JSON.stringify({ name: "test", tasks: ["t1"] }));
96
- await writeFile(join(dir, "pipeline-config", "registry.json"), JSON.stringify({
97
- pipelines: { test: { configDir, tasksDir } }
98
- }));
99
- const overrideFile = join(dir, "config.json");
100
- await writeFile(overrideFile, JSON.stringify({ taskRunner: { maxAttempts: 0 } }));
101
- const origRoot = process.env.PO_ROOT;
102
- process.env.PO_ROOT = dir;
103
- try {
104
- await expect(loadConfig({ configPath: overrideFile })).rejects.toThrow("taskRunner.maxAttempts must be an integer >= 1");
105
- } finally {
106
- if (origRoot) process.env.PO_ROOT = origRoot; else delete process.env.PO_ROOT;
107
- await rm(dir, { recursive: true });
108
- }
109
- });
110
-
111
- test("throws when taskRunner.maxAttempts is non-integer", async () => {
112
- const dir = await mkdtemp(join(tmpdir(), "config-test-"));
113
- const configDir = join(dir, "pipeline-config", "test");
114
- const tasksDir = join(configDir, "tasks");
115
- await mkdir(tasksDir, { recursive: true });
116
- await writeFile(join(configDir, "pipeline.json"), JSON.stringify({ name: "test", tasks: ["t1"] }));
117
- await writeFile(join(dir, "pipeline-config", "registry.json"), JSON.stringify({
118
- pipelines: { test: { configDir, tasksDir } }
119
- }));
120
- const overrideFile = join(dir, "config.json");
121
- await writeFile(overrideFile, JSON.stringify({ taskRunner: { maxAttempts: 2.5 } }));
122
- const origRoot = process.env.PO_ROOT;
123
- process.env.PO_ROOT = dir;
124
- try {
125
- await expect(loadConfig({ configPath: overrideFile })).rejects.toThrow("taskRunner.maxAttempts must be an integer >= 1");
126
- } finally {
127
- if (origRoot) process.env.PO_ROOT = origRoot; else delete process.env.PO_ROOT;
128
- await rm(dir, { recursive: true });
129
- }
130
- });
131
- });
132
-
133
36
  describe("PO_TASK_MAX_ATTEMPTS env override", () => {
134
37
  afterEach(() => {
135
38
  delete process.env.PO_TASK_MAX_ATTEMPTS;
@@ -139,7 +42,7 @@ describe("PO_TASK_MAX_ATTEMPTS env override", () => {
139
42
  test("getConfig reads PO_TASK_MAX_ATTEMPTS into taskRunner.maxAttempts", () => {
140
43
  process.env.PO_TASK_MAX_ATTEMPTS = "5";
141
44
  resetConfig();
142
- const config: AppConfig = getConfig();
45
+ const config = getConfig();
143
46
  expect(config.taskRunner.maxAttempts).toBe(5);
144
47
  });
145
48
 
@@ -165,32 +68,98 @@ describe("PO_MAX_RUNNING_JOBS env override", () => {
165
68
  test("getConfig reads PO_MAX_RUNNING_JOBS into orchestrator.maxConcurrentJobs", () => {
166
69
  process.env.PO_MAX_RUNNING_JOBS = "7";
167
70
  resetConfig();
168
- const config: AppConfig = getConfig();
71
+ const config = getConfig();
169
72
  expect(config.orchestrator.maxConcurrentJobs).toBe(7);
170
73
  });
171
74
 
172
75
  test("getConfig rejects PO_MAX_RUNNING_JOBS=0", () => {
173
76
  process.env.PO_MAX_RUNNING_JOBS = "0";
174
77
  resetConfig();
175
- expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
78
+ expect(() => getConfig()).toThrow(/PO_MAX_RUNNING_JOBS/);
176
79
  });
177
80
 
178
81
  test("getConfig rejects negative PO_MAX_RUNNING_JOBS", () => {
179
82
  process.env.PO_MAX_RUNNING_JOBS = "-1";
180
83
  resetConfig();
181
- expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
84
+ expect(() => getConfig()).toThrow(/PO_MAX_RUNNING_JOBS/);
182
85
  });
183
86
 
184
87
  test("getConfig rejects non-integer PO_MAX_RUNNING_JOBS", () => {
185
88
  process.env.PO_MAX_RUNNING_JOBS = "1.5";
186
89
  resetConfig();
187
- expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
90
+ expect(() => getConfig()).toThrow(/PO_MAX_RUNNING_JOBS/);
188
91
  });
189
92
 
190
93
  test("getConfig rejects non-numeric PO_MAX_RUNNING_JOBS", () => {
191
94
  process.env.PO_MAX_RUNNING_JOBS = "abc";
192
95
  resetConfig();
193
- expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
96
+ expect(() => getConfig()).toThrow(/PO_MAX_RUNNING_JOBS/);
97
+ });
98
+ });
99
+
100
+ describe("PO_STALE_RUNNING_GRACE_MS env override", () => {
101
+ afterEach(() => {
102
+ delete process.env.PO_STALE_RUNNING_GRACE_MS;
103
+ resetConfig();
104
+ });
105
+
106
+ test("getOrchestratorConfig defaults staleRunningGraceMs to 30000", () => {
107
+ resetConfig();
108
+ expect(getOrchestratorConfig().staleRunningGraceMs).toBe(30000);
109
+ });
110
+
111
+ test("getOrchestratorConfig reads PO_STALE_RUNNING_GRACE_MS into orchestrator.staleRunningGraceMs", () => {
112
+ process.env.PO_STALE_RUNNING_GRACE_MS = "60000";
113
+ resetConfig();
114
+ expect(getOrchestratorConfig().staleRunningGraceMs).toBe(60000);
115
+ });
116
+
117
+ test("getOrchestratorConfig rejects non-positive PO_STALE_RUNNING_GRACE_MS", () => {
118
+ process.env.PO_STALE_RUNNING_GRACE_MS = "-1";
119
+ resetConfig();
120
+ expect(() => getOrchestratorConfig()).toThrow(/PO_STALE_RUNNING_GRACE_MS/);
121
+ });
122
+
123
+ test("getOrchestratorConfig rejects non-integer PO_STALE_RUNNING_GRACE_MS", () => {
124
+ process.env.PO_STALE_RUNNING_GRACE_MS = "1.5";
125
+ resetConfig();
126
+ expect(() => getOrchestratorConfig()).toThrow(/PO_STALE_RUNNING_GRACE_MS/);
127
+ });
128
+ });
129
+
130
+ describe("PO_STALE_LEASE_TIMEOUT_MS env override", () => {
131
+ afterEach(() => {
132
+ delete process.env.PO_STALE_LEASE_TIMEOUT_MS;
133
+ resetConfig();
134
+ });
135
+
136
+ test("getOrchestratorConfig defaults staleLeaseTimeoutMs to 30000", () => {
137
+ resetConfig();
138
+ expect(getOrchestratorConfig().staleLeaseTimeoutMs).toBe(30000);
139
+ });
140
+
141
+ test("getOrchestratorConfig reads PO_STALE_LEASE_TIMEOUT_MS into orchestrator.staleLeaseTimeoutMs", () => {
142
+ process.env.PO_STALE_LEASE_TIMEOUT_MS = "60000";
143
+ resetConfig();
144
+ expect(getOrchestratorConfig().staleLeaseTimeoutMs).toBe(60000);
145
+ });
146
+
147
+ test("getOrchestratorConfig rejects PO_STALE_LEASE_TIMEOUT_MS=0", () => {
148
+ process.env.PO_STALE_LEASE_TIMEOUT_MS = "0";
149
+ resetConfig();
150
+ expect(() => getOrchestratorConfig()).toThrow(/PO_STALE_LEASE_TIMEOUT_MS/);
151
+ });
152
+
153
+ test("getOrchestratorConfig rejects negative PO_STALE_LEASE_TIMEOUT_MS", () => {
154
+ process.env.PO_STALE_LEASE_TIMEOUT_MS = "-1";
155
+ resetConfig();
156
+ expect(() => getOrchestratorConfig()).toThrow(/PO_STALE_LEASE_TIMEOUT_MS/);
157
+ });
158
+
159
+ test("getOrchestratorConfig rejects non-integer PO_STALE_LEASE_TIMEOUT_MS", () => {
160
+ process.env.PO_STALE_LEASE_TIMEOUT_MS = "1.5";
161
+ resetConfig();
162
+ expect(() => getOrchestratorConfig()).toThrow(/PO_STALE_LEASE_TIMEOUT_MS/);
194
163
  });
195
164
  });
196
165
 
@@ -206,6 +175,85 @@ describe("getConfig", () => {
206
175
  });
207
176
  });
208
177
 
178
+ describe("getConfig immutability", () => {
179
+ afterEach(() => {
180
+ resetConfig();
181
+ });
182
+
183
+ test("returns the same frozen reference on repeat calls", () => {
184
+ const a = getConfig();
185
+ const b = getConfig();
186
+ expect(a).toBe(b);
187
+ expect(Object.isFrozen(a)).toBe(true);
188
+ });
189
+
190
+ test("top-level mutation throws at runtime", () => {
191
+ const c = getConfig();
192
+ expect(() => {
193
+ (c as unknown as { ui: { port: number } }).ui.port = 99;
194
+ }).toThrow();
195
+ });
196
+
197
+ test("nested mutation throws at runtime", () => {
198
+ const c = getConfig();
199
+ expect(() => {
200
+ (c as unknown as { orchestrator: { shutdownTimeout: number } }).orchestrator.shutdownTimeout = 99;
201
+ }).toThrow();
202
+ });
203
+
204
+ test("deeper nested mutation throws at runtime", () => {
205
+ const c = getConfig();
206
+ expect(() => {
207
+ (c as unknown as { logging: { level: string } }).logging.level = "debug";
208
+ }).toThrow();
209
+ });
210
+
211
+ test("array element objects are frozen at runtime", () => {
212
+ const mutableDefault = defaultConfig as unknown as {
213
+ arrayProbe?: { items: Array<{ value: number }> };
214
+ };
215
+ mutableDefault.arrayProbe = { items: [{ value: 1 }] };
216
+ try {
217
+ resetConfig();
218
+ const c = getConfig() as unknown as { arrayProbe: { items: Array<{ value: number }> } };
219
+ const first = c.arrayProbe.items[0];
220
+ expect(first).toBeDefined();
221
+ expect(Object.isFrozen(first)).toBe(true);
222
+ expect(() => {
223
+ first!.value = 2;
224
+ }).toThrow();
225
+ } finally {
226
+ delete mutableDefault.arrayProbe;
227
+ resetConfig();
228
+ }
229
+ });
230
+ });
231
+
232
+ describe("getConfig validation", () => {
233
+ afterEach(() => {
234
+ resetConfig();
235
+ });
236
+
237
+ test("throws before caching when maxRefinementAttempts < 1", () => {
238
+ const original = defaultConfig.taskRunner.maxRefinementAttempts;
239
+ defaultConfig.taskRunner.maxRefinementAttempts = 0;
240
+ try {
241
+ resetConfig();
242
+ expect(() => getConfig()).toThrow(/maxRefinementAttempts/);
243
+ resetConfig();
244
+ expect(() => getConfig()).toThrow(/maxRefinementAttempts/);
245
+ } finally {
246
+ defaultConfig.taskRunner.maxRefinementAttempts = original;
247
+ resetConfig();
248
+ }
249
+ });
250
+
251
+ test("passes validation through getConfig with unmodified defaults", () => {
252
+ resetConfig();
253
+ expect(() => getConfig()).not.toThrow();
254
+ });
255
+ });
256
+
209
257
  describe("resetConfig", () => {
210
258
  test("clears cached config", () => {
211
259
  const config1 = getConfig();
@@ -240,3 +288,395 @@ describe("getPipelineConfig", () => {
240
288
  expect(() => getPipelineConfig("nonexistent-slug")).toThrow("not found in registry");
241
289
  });
242
290
  });
291
+
292
+ describe("pipeline registry hydration via shared module", () => {
293
+ let tmpDir: string;
294
+ const prevPoRoot = process.env["PO_ROOT"];
295
+
296
+ afterEach(() => {
297
+ if (prevPoRoot === undefined) delete process.env["PO_ROOT"];
298
+ else process.env["PO_ROOT"] = prevPoRoot;
299
+ resetConfig();
300
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
301
+ });
302
+
303
+ test("getConfig hydrates config.pipelines from a canonical v1 registry", () => {
304
+ tmpDir = mkdtempSync(join(tmpdir(), "pop-cfg-hydrate-"));
305
+ const registryDir = join(tmpDir, "pipeline-config");
306
+ mkdirSync(registryDir, { recursive: true });
307
+ writeFileSync(
308
+ join(registryDir, "registry.json"),
309
+ JSON.stringify({
310
+ version: 1,
311
+ pipelines: { "demo-pipe": { name: "Demo", description: "demo pipeline" } },
312
+ }),
313
+ );
314
+
315
+ process.env["PO_ROOT"] = tmpDir;
316
+ resetConfig();
317
+ const cfg = getConfig();
318
+
319
+ const entry = cfg.pipelines["demo-pipe"];
320
+ expect(entry).toBeDefined();
321
+ expect(entry!.configDir).toBe(join(tmpDir, "pipeline-config", "demo-pipe"));
322
+ expect(entry!.tasksDir).toBe(join(tmpDir, "pipeline-config", "demo-pipe", "tasks"));
323
+ });
324
+
325
+ test("getPipelineConfig(slug, root) resolves via the shared module", () => {
326
+ tmpDir = mkdtempSync(join(tmpdir(), "pop-cfg-resolve-"));
327
+ const slugDir = join(tmpDir, "pipeline-config", "my-slug");
328
+ const tasksDir = join(slugDir, "tasks");
329
+ mkdirSync(tasksDir, { recursive: true });
330
+ writeFileSync(join(slugDir, "pipeline.json"), JSON.stringify({ name: "my-slug" }));
331
+ writeFileSync(
332
+ join(tmpDir, "pipeline-config", "registry.json"),
333
+ JSON.stringify({
334
+ version: 1,
335
+ pipelines: { "my-slug": { name: "My Slug", description: "scaffolded" } },
336
+ }),
337
+ );
338
+
339
+ const result = getPipelineConfig("my-slug", tmpDir);
340
+
341
+ expect(result.pipelineJsonPath).toBe(join(slugDir, "pipeline.json"));
342
+ expect(result.tasksDir).toBe(tasksDir);
343
+ });
344
+
345
+ test("getPipelineConfig(slug, root) throws for unknown slug", () => {
346
+ tmpDir = mkdtempSync(join(tmpdir(), "pop-cfg-unknown-"));
347
+ mkdirSync(join(tmpDir, "pipeline-config"), { recursive: true });
348
+ writeFileSync(
349
+ join(tmpDir, "pipeline-config", "registry.json"),
350
+ JSON.stringify({ version: 1, pipelines: {} }),
351
+ );
352
+
353
+ expect(() => getPipelineConfig("missing", tmpDir)).toThrow(
354
+ "Pipeline 'missing' not found in registry",
355
+ );
356
+ });
357
+ });
358
+
359
+ describe("PORT env override", () => {
360
+ afterEach(() => {
361
+ delete process.env.PORT;
362
+ resetConfig();
363
+ });
364
+
365
+ test("getConfig reads PORT into ui.port", () => {
366
+ process.env.PORT = "4500";
367
+ resetConfig();
368
+ expect(getConfig().ui.port).toBe(4500);
369
+ });
370
+
371
+ test("getConfig rejects PORT=foo", () => {
372
+ process.env.PORT = "foo";
373
+ resetConfig();
374
+ expect(() => getConfig()).toThrow(/PORT/);
375
+ });
376
+
377
+ test("getConfig rejects PORT=empty string", () => {
378
+ process.env.PORT = "";
379
+ resetConfig();
380
+ expect(() => getConfig()).toThrow(/PORT/);
381
+ });
382
+
383
+ test("getConfig rejects PORT=0", () => {
384
+ process.env.PORT = "0";
385
+ resetConfig();
386
+ expect(() => getConfig()).toThrow(/PORT/);
387
+ });
388
+
389
+ test("getConfig rejects PORT=70000", () => {
390
+ process.env.PORT = "70000";
391
+ resetConfig();
392
+ expect(() => getConfig()).toThrow(/PORT/);
393
+ });
394
+
395
+ test("PORT=0 error message names PORT, not ui.port", () => {
396
+ process.env.PORT = "0";
397
+ resetConfig();
398
+ expect(() => getConfig()).toThrow(/^PORT must be between/);
399
+ });
400
+ });
401
+
402
+ describe("PO_UI_PORT env override", () => {
403
+ afterEach(() => {
404
+ delete process.env.PO_UI_PORT;
405
+ resetConfig();
406
+ });
407
+
408
+ test("getConfig reads PO_UI_PORT into ui.port", () => {
409
+ process.env.PO_UI_PORT = "4501";
410
+ resetConfig();
411
+ expect(getConfig().ui.port).toBe(4501);
412
+ });
413
+
414
+ test("getConfig rejects PO_UI_PORT=foo", () => {
415
+ process.env.PO_UI_PORT = "foo";
416
+ resetConfig();
417
+ expect(() => getConfig()).toThrow(/PO_UI_PORT/);
418
+ });
419
+
420
+ test("getConfig rejects PO_UI_PORT=empty string", () => {
421
+ process.env.PO_UI_PORT = "";
422
+ resetConfig();
423
+ expect(() => getConfig()).toThrow(/PO_UI_PORT/);
424
+ });
425
+
426
+ test("getConfig rejects PO_UI_PORT=70000", () => {
427
+ process.env.PO_UI_PORT = "70000";
428
+ resetConfig();
429
+ expect(() => getConfig()).toThrow(/PO_UI_PORT/);
430
+ });
431
+
432
+ test("getConfig rejects PO_UI_PORT=1.5", () => {
433
+ process.env.PO_UI_PORT = "1.5";
434
+ resetConfig();
435
+ expect(() => getConfig()).toThrow(/PO_UI_PORT/);
436
+ });
437
+ });
438
+
439
+ describe("PO_INTERNAL_LLM_* env override", () => {
440
+ afterEach(() => {
441
+ delete process.env.PO_INTERNAL_LLM_PROVIDER;
442
+ delete process.env.PO_INTERNAL_LLM_MODEL;
443
+ delete process.env.PO_MAX_CONCURRENCY;
444
+ resetConfig();
445
+ });
446
+
447
+ test("defaultConfig exposes internalProvider/internalModel defaults", () => {
448
+ expect(defaultConfig.llm.internalProvider).toBe("deepseek");
449
+ expect(defaultConfig.llm.internalModel).toBe("deepseek-v4-flash");
450
+ });
451
+
452
+ test("getConfig defaults to deepseek / deepseek-v4-flash when env is unset", () => {
453
+ resetConfig();
454
+ const config = getConfig();
455
+ expect(config.llm.internalProvider).toBe("deepseek");
456
+ expect(config.llm.internalModel).toBe("deepseek-v4-flash");
457
+ });
458
+
459
+ test("getConfig reads PO_INTERNAL_LLM_PROVIDER into llm.internalProvider", () => {
460
+ process.env.PO_INTERNAL_LLM_PROVIDER = "zai";
461
+ resetConfig();
462
+ expect(getConfig().llm.internalProvider).toBe("zai");
463
+ });
464
+
465
+ test("getConfig reads PO_INTERNAL_LLM_MODEL into llm.internalModel", () => {
466
+ process.env.PO_INTERNAL_LLM_MODEL = "foo:bar";
467
+ resetConfig();
468
+ expect(getConfig().llm.internalModel).toBe("foo:bar");
469
+ });
470
+
471
+ test("getConfig rejects empty PO_INTERNAL_LLM_PROVIDER", () => {
472
+ process.env.PO_INTERNAL_LLM_PROVIDER = "";
473
+ resetConfig();
474
+ expect(() => getConfig()).toThrow(/PO_INTERNAL_LLM_PROVIDER/);
475
+ });
476
+
477
+ test("getConfig rejects empty PO_INTERNAL_LLM_MODEL", () => {
478
+ process.env.PO_INTERNAL_LLM_MODEL = "";
479
+ resetConfig();
480
+ expect(() => getConfig()).toThrow(/PO_INTERNAL_LLM_MODEL/);
481
+ });
482
+
483
+ test("internal overrides do not clobber maxConcurrency", () => {
484
+ process.env.PO_INTERNAL_LLM_PROVIDER = "zai";
485
+ process.env.PO_INTERNAL_LLM_MODEL = "zai:glm-4";
486
+ process.env.PO_MAX_CONCURRENCY = "9";
487
+ resetConfig();
488
+ const config = getConfig();
489
+ expect(config.llm.internalProvider).toBe("zai");
490
+ expect(config.llm.internalModel).toBe("zai:glm-4");
491
+ expect(config.llm.maxConcurrency).toBe(9);
492
+ });
493
+ });
494
+
495
+ describe("PO_MAX_CONCURRENCY env override", () => {
496
+ afterEach(() => {
497
+ delete process.env.PO_MAX_CONCURRENCY;
498
+ resetConfig();
499
+ });
500
+
501
+ test("getConfig reads PO_MAX_CONCURRENCY into llm.maxConcurrency", () => {
502
+ process.env.PO_MAX_CONCURRENCY = "7";
503
+ resetConfig();
504
+ expect(getConfig().llm.maxConcurrency).toBe(7);
505
+ });
506
+
507
+ test("getConfig rejects PO_MAX_CONCURRENCY=foo", () => {
508
+ process.env.PO_MAX_CONCURRENCY = "foo";
509
+ resetConfig();
510
+ expect(() => getConfig()).toThrow(/PO_MAX_CONCURRENCY/);
511
+ });
512
+
513
+ test("getConfig rejects PO_MAX_CONCURRENCY=empty string", () => {
514
+ process.env.PO_MAX_CONCURRENCY = "";
515
+ resetConfig();
516
+ expect(() => getConfig()).toThrow(/PO_MAX_CONCURRENCY/);
517
+ });
518
+
519
+ test("getConfig rejects PO_MAX_CONCURRENCY=0", () => {
520
+ process.env.PO_MAX_CONCURRENCY = "0";
521
+ resetConfig();
522
+ expect(() => getConfig()).toThrow(/PO_MAX_CONCURRENCY/);
523
+ });
524
+
525
+ test("getConfig rejects PO_MAX_CONCURRENCY=1.5", () => {
526
+ process.env.PO_MAX_CONCURRENCY = "1.5";
527
+ resetConfig();
528
+ expect(() => getConfig()).toThrow(/PO_MAX_CONCURRENCY/);
529
+ });
530
+ });
531
+
532
+ describe("PO_SHUTDOWN_TIMEOUT env override", () => {
533
+ afterEach(() => {
534
+ delete process.env.PO_SHUTDOWN_TIMEOUT;
535
+ resetConfig();
536
+ });
537
+
538
+ test("getConfig reads PO_SHUTDOWN_TIMEOUT=0 (boundary)", () => {
539
+ process.env.PO_SHUTDOWN_TIMEOUT = "0";
540
+ resetConfig();
541
+ expect(getConfig().orchestrator.shutdownTimeout).toBe(0);
542
+ });
543
+
544
+ test("getConfig reads PO_SHUTDOWN_TIMEOUT=5000", () => {
545
+ process.env.PO_SHUTDOWN_TIMEOUT = "5000";
546
+ resetConfig();
547
+ expect(getConfig().orchestrator.shutdownTimeout).toBe(5000);
548
+ });
549
+
550
+ test("getConfig rejects PO_SHUTDOWN_TIMEOUT=foo", () => {
551
+ process.env.PO_SHUTDOWN_TIMEOUT = "foo";
552
+ resetConfig();
553
+ expect(() => getConfig()).toThrow(/PO_SHUTDOWN_TIMEOUT/);
554
+ });
555
+
556
+ test("getConfig rejects PO_SHUTDOWN_TIMEOUT=empty string", () => {
557
+ process.env.PO_SHUTDOWN_TIMEOUT = "";
558
+ resetConfig();
559
+ expect(() => getConfig()).toThrow(/PO_SHUTDOWN_TIMEOUT/);
560
+ });
561
+
562
+ test("getConfig rejects PO_SHUTDOWN_TIMEOUT=-1 (min=0)", () => {
563
+ process.env.PO_SHUTDOWN_TIMEOUT = "-1";
564
+ resetConfig();
565
+ expect(() => getConfig()).toThrow(/PO_SHUTDOWN_TIMEOUT/);
566
+ });
567
+ });
568
+
569
+ describe("empty-string numeric env vars", () => {
570
+ const VARS = [
571
+ "PO_TASK_MAX_ATTEMPTS",
572
+ "PO_MAX_RUNNING_JOBS",
573
+ "PO_STALE_RUNNING_GRACE_MS",
574
+ "PO_STALE_LEASE_TIMEOUT_MS",
575
+ ] as const;
576
+
577
+ afterEach(() => {
578
+ for (const name of VARS) delete process.env[name];
579
+ resetConfig();
580
+ });
581
+
582
+ for (const name of VARS) {
583
+ test(`rejects empty ${name}`, () => {
584
+ process.env[name] = "";
585
+ resetConfig();
586
+ expect(() => getConfig()).toThrow(new RegExp(name));
587
+ });
588
+ }
589
+ });
590
+
591
+ describe("PO_LOG_LEVEL env override", () => {
592
+ afterEach(() => {
593
+ delete process.env.PO_LOG_LEVEL;
594
+ resetConfig();
595
+ });
596
+
597
+ test("getConfig reads PO_LOG_LEVEL=debug", () => {
598
+ process.env.PO_LOG_LEVEL = "debug";
599
+ resetConfig();
600
+ expect(getConfig().logging.level).toBe("debug");
601
+ });
602
+
603
+ test("getConfig reads PO_LOG_LEVEL=warn", () => {
604
+ process.env.PO_LOG_LEVEL = "warn";
605
+ resetConfig();
606
+ expect(getConfig().logging.level).toBe("warn");
607
+ });
608
+
609
+ test("getConfig rejects PO_LOG_LEVEL=loud", () => {
610
+ process.env.PO_LOG_LEVEL = "loud";
611
+ resetConfig();
612
+ expect(() => getConfig()).toThrow(/PO_LOG_LEVEL/);
613
+ });
614
+
615
+ test("getConfig rejects PO_LOG_LEVEL=empty string", () => {
616
+ process.env.PO_LOG_LEVEL = "";
617
+ resetConfig();
618
+ expect(() => getConfig()).toThrow(/PO_LOG_LEVEL/);
619
+ });
620
+
621
+ test("getConfig uses default level when PO_LOG_LEVEL is unset", () => {
622
+ resetConfig();
623
+ expect(getConfig().logging.level).toBe("info");
624
+ });
625
+ });
626
+
627
+ describe("default fallbacks for unset numeric env vars", () => {
628
+ afterEach(() => {
629
+ delete process.env.PO_MAX_CONCURRENCY;
630
+ delete process.env.PO_SHUTDOWN_TIMEOUT;
631
+ resetConfig();
632
+ });
633
+
634
+ test("unset PO_MAX_CONCURRENCY falls back to defaultConfig.llm.maxConcurrency", () => {
635
+ resetConfig();
636
+ expect(getConfig().llm.maxConcurrency).toBe(defaultConfig.llm.maxConcurrency);
637
+ });
638
+
639
+ test("unset PO_SHUTDOWN_TIMEOUT falls back to defaultConfig.orchestrator.shutdownTimeout", () => {
640
+ resetConfig();
641
+ expect(getConfig().orchestrator.shutdownTimeout).toBe(defaultConfig.orchestrator.shutdownTimeout);
642
+ });
643
+ });
644
+
645
+ describe("source deletion scan", () => {
646
+ const SRC_ROOT = join(import.meta.dir, "..", "..");
647
+ const DELETED_TOKENS = [
648
+ "loadConfig",
649
+ "LoadConfigOptions",
650
+ "PathsConfig",
651
+ "AppConfig.paths",
652
+ ] as const;
653
+
654
+ function walkTs(dir: string): string[] {
655
+ const out: string[] = [];
656
+ for (const entry of readdirSync(dir)) {
657
+ const full = join(dir, entry);
658
+ if (statSync(full).isDirectory()) {
659
+ out.push(...walkTs(full));
660
+ continue;
661
+ }
662
+ if (/\.tsx?$/.test(entry)) out.push(full);
663
+ }
664
+ return out;
665
+ }
666
+
667
+ test("no src/ file references deleted config symbols", () => {
668
+ const files = walkTs(SRC_ROOT).filter((f) => !f.includes("/__tests__/"));
669
+ const offending: Array<{ file: string; token: string }> = [];
670
+ for (const file of files) {
671
+ const content = readFileSync(file, "utf8");
672
+ for (const token of DELETED_TOKENS) {
673
+ if (content.includes(token)) offending.push({ file, token });
674
+ }
675
+ }
676
+ if (offending.length > 0) {
677
+ const lines = offending.map((o) => ` ${o.file}: ${o.token}`).join("\n");
678
+ throw new Error(`Deleted config symbols still referenced under src/:\n${lines}`);
679
+ }
680
+ expect(offending).toHaveLength(0);
681
+ });
682
+ });