@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,12 +1,13 @@
1
1
  import path from "node:path";
2
- import { readFile, stat } from "node:fs/promises";
3
- import { readFileSync, existsSync } from "node:fs";
2
+ import { resolveAllPipelinesSync, resolvePipelineSync } from "../config/pipeline-registry.ts";
4
3
 
5
4
  export interface OrchestratorConfig {
6
5
  shutdownTimeout: number;
7
6
  processSpawnRetries: number;
8
7
  processSpawnRetryDelay: number;
9
8
  lockFileTimeout: number;
9
+ staleRunningGraceMs: number;
10
+ staleLeaseTimeoutMs: number;
10
11
  watchDebounce: number;
11
12
  watchStabilityThreshold: number;
12
13
  watchPollInterval: number;
@@ -23,6 +24,8 @@ export interface TaskRunnerConfig {
23
24
  export interface LLMConfig {
24
25
  defaultProvider: string;
25
26
  defaultModel: string;
27
+ internalProvider: string;
28
+ internalModel: string;
26
29
  maxConcurrency: number;
27
30
  retryMaxAttempts: number;
28
31
  retryBackoffMs: number;
@@ -35,14 +38,6 @@ export interface UIConfig {
35
38
  maxRecentChanges: number;
36
39
  }
37
40
 
38
- export interface PathsConfig {
39
- root: string;
40
- dataDir: string;
41
- pendingDir: string;
42
- currentDir: string;
43
- completeDir: string;
44
- }
45
-
46
41
  export interface PipelineEntry {
47
42
  configDir: string;
48
43
  tasksDir: string;
@@ -67,17 +62,11 @@ export interface AppConfig {
67
62
  taskRunner: TaskRunnerConfig;
68
63
  llm: LLMConfig;
69
64
  ui: UIConfig;
70
- paths: PathsConfig;
71
65
  pipelines: Record<string, PipelineEntry>;
72
66
  validation: ValidationConfig;
73
67
  logging: LoggingConfig;
74
68
  }
75
69
 
76
- export interface LoadConfigOptions {
77
- configPath?: string;
78
- validate?: boolean;
79
- }
80
-
81
70
  export interface PipelineConfigResult {
82
71
  pipelineJsonPath: string;
83
72
  tasksDir: string;
@@ -89,6 +78,8 @@ export const defaultConfig = {
89
78
  processSpawnRetries: 3,
90
79
  processSpawnRetryDelay: 1000,
91
80
  lockFileTimeout: 30000,
81
+ staleRunningGraceMs: 30000,
82
+ staleLeaseTimeoutMs: 30000,
92
83
  watchDebounce: 500,
93
84
  watchStabilityThreshold: 1000,
94
85
  watchPollInterval: 100,
@@ -103,6 +94,8 @@ export const defaultConfig = {
103
94
  llm: {
104
95
  defaultProvider: "openai",
105
96
  defaultModel: "gpt-4o",
97
+ internalProvider: "deepseek",
98
+ internalModel: "deepseek-v4-flash",
106
99
  maxConcurrency: 5,
107
100
  retryMaxAttempts: 3,
108
101
  retryBackoffMs: 1000,
@@ -113,13 +106,6 @@ export const defaultConfig = {
113
106
  heartbeatInterval: 30000,
114
107
  maxRecentChanges: 50,
115
108
  },
116
- paths: {
117
- root: process.cwd(),
118
- dataDir: "data",
119
- pendingDir: "data/pending",
120
- currentDir: "data/current",
121
- completeDir: "data/complete",
122
- },
123
109
  pipelines: {},
124
110
  validation: {
125
111
  seedNameMinLength: 3,
@@ -133,6 +119,13 @@ export const defaultConfig = {
133
119
  },
134
120
  } satisfies AppConfig;
135
121
 
122
+ type DeepReadonly<T> =
123
+ T extends (infer U)[]
124
+ ? ReadonlyArray<DeepReadonly<U>>
125
+ : T extends object
126
+ ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
127
+ : T;
128
+
136
129
  // ─── Deep Merge ───────────────────────────────────────────────────────────────
137
130
 
138
131
  type PlainObject = Record<string, unknown>;
@@ -162,162 +155,161 @@ function deepMerge(target: PlainObject, source: PlainObject): PlainObject {
162
155
 
163
156
  const VALID_LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
164
157
 
165
- function loadFromEnvironment(config: AppConfig): AppConfig {
166
- const overrides: PlainObject = {};
158
+ function envInt(
159
+ name: string,
160
+ raw: string | undefined,
161
+ opts?: { min?: number; max?: number }
162
+ ): number | undefined {
163
+ if (raw === undefined) return undefined;
164
+ if (raw === "") {
165
+ throw new Error(`${name} must not be empty`);
166
+ }
167
+ const n = Number(raw);
168
+ const min = opts?.min;
169
+ const max = opts?.max;
170
+ if (min !== undefined || max !== undefined) {
171
+ if (!Number.isInteger(n) || (min !== undefined && n < min) || (max !== undefined && n > max)) {
172
+ if (min !== undefined && max !== undefined) {
173
+ throw new Error(`${name} must be between ${min} and ${max}, got ${raw}`);
174
+ }
175
+ if (min !== undefined) {
176
+ throw new Error(`${name} must be an integer >= ${min}, got ${raw}`);
177
+ }
178
+ throw new Error(`${name} must be an integer <= ${max}, got ${raw}`);
179
+ }
180
+ return n;
181
+ }
182
+ if (!Number.isInteger(n)) {
183
+ throw new Error(`${name} must be an integer, got ${raw}`);
184
+ }
185
+ return n;
186
+ }
167
187
 
168
- const root = process.env["PO_ROOT"] ? path.resolve(process.env["PO_ROOT"]) : undefined;
169
- const dataDir = process.env["PO_DATA_DIR"];
170
- if (root || dataDir) {
171
- overrides["paths"] = {
172
- ...(root ? { root } : {}),
173
- ...(dataDir ? { dataDir } : {}),
174
- };
188
+ function envString(name: string, raw: string | undefined): string | undefined {
189
+ if (raw === undefined) return undefined;
190
+ if (raw === "") {
191
+ throw new Error(`${name} must not be empty`);
175
192
  }
193
+ return raw;
194
+ }
176
195
 
177
- const portRaw = process.env["PORT"];
178
- const poPortRaw = process.env["PO_UI_PORT"];
196
+ function loadFromEnvironment(config: AppConfig): AppConfig {
197
+ const overrides: PlainObject = {};
198
+
199
+ const port = envInt("PORT", process.env["PORT"], { min: 1, max: 65535 });
200
+ const poPort = envInt("PO_UI_PORT", process.env["PO_UI_PORT"], { min: 1, max: 65535 });
179
201
  const host = process.env["PO_HOST"];
180
202
  const uiOverrides: PlainObject = {};
181
- if (portRaw) uiOverrides["port"] = parseInt(portRaw, 10);
182
- if (poPortRaw) uiOverrides["port"] = parseInt(poPortRaw, 10);
203
+ if (port !== undefined) uiOverrides["port"] = port;
204
+ if (poPort !== undefined) uiOverrides["port"] = poPort;
183
205
  if (host) uiOverrides["host"] = host;
184
206
  if (Object.keys(uiOverrides).length > 0) overrides["ui"] = uiOverrides;
185
207
 
186
- const maxConcurrencyRaw = process.env["PO_MAX_CONCURRENCY"];
187
- if (maxConcurrencyRaw) {
188
- overrides["llm"] = { maxConcurrency: parseInt(maxConcurrencyRaw, 10) };
189
- }
190
-
191
- const maxAttemptsRaw = process.env["PO_TASK_MAX_ATTEMPTS"];
192
- if (maxAttemptsRaw) {
193
- const maxAttempts = Number(maxAttemptsRaw);
194
- if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
195
- throw new Error(`PO_TASK_MAX_ATTEMPTS must be an integer >= 1, got ${maxAttemptsRaw}`);
196
- }
208
+ const maxConcurrency = envInt("PO_MAX_CONCURRENCY", process.env["PO_MAX_CONCURRENCY"], { min: 1 });
209
+ const internalProvider = envString("PO_INTERNAL_LLM_PROVIDER", process.env["PO_INTERNAL_LLM_PROVIDER"]);
210
+ const internalModel = envString("PO_INTERNAL_LLM_MODEL", process.env["PO_INTERNAL_LLM_MODEL"]);
211
+ const llmOverrides: PlainObject = {};
212
+ if (maxConcurrency !== undefined) llmOverrides["maxConcurrency"] = maxConcurrency;
213
+ if (internalProvider !== undefined) llmOverrides["internalProvider"] = internalProvider;
214
+ if (internalModel !== undefined) llmOverrides["internalModel"] = internalModel;
215
+ if (Object.keys(llmOverrides).length > 0) overrides["llm"] = llmOverrides;
216
+
217
+ const maxAttempts = envInt("PO_TASK_MAX_ATTEMPTS", process.env["PO_TASK_MAX_ATTEMPTS"], { min: 1 });
218
+ if (maxAttempts !== undefined) {
197
219
  overrides["taskRunner"] = {
198
220
  ...((overrides["taskRunner"] as PlainObject | undefined) ?? {}),
199
221
  maxAttempts,
200
222
  };
201
223
  }
202
224
 
203
- const shutdownTimeoutRaw = process.env["PO_SHUTDOWN_TIMEOUT"];
204
- if (shutdownTimeoutRaw) {
225
+ const shutdownTimeout = envInt("PO_SHUTDOWN_TIMEOUT", process.env["PO_SHUTDOWN_TIMEOUT"], { min: 0 });
226
+ if (shutdownTimeout !== undefined) {
205
227
  overrides["orchestrator"] = {
206
228
  ...((overrides["orchestrator"] as PlainObject | undefined) ?? {}),
207
- shutdownTimeout: parseInt(shutdownTimeoutRaw, 10),
229
+ shutdownTimeout,
208
230
  };
209
231
  }
210
232
 
211
- const maxRunningJobsRaw = process.env["PO_MAX_RUNNING_JOBS"];
212
- if (maxRunningJobsRaw !== undefined) {
213
- const maxConcurrentJobs = Number(maxRunningJobsRaw);
214
- if (!Number.isInteger(maxConcurrentJobs) || maxConcurrentJobs < 1) {
215
- throw new Error(
216
- `orchestrator.maxConcurrentJobs must be a positive integer (PO_MAX_RUNNING_JOBS), got ${maxRunningJobsRaw}`
217
- );
218
- }
233
+ const maxRunningJobs = envInt("PO_MAX_RUNNING_JOBS", process.env["PO_MAX_RUNNING_JOBS"], { min: 1 });
234
+ if (maxRunningJobs !== undefined) {
219
235
  overrides["orchestrator"] = {
220
236
  ...((overrides["orchestrator"] as PlainObject | undefined) ?? {}),
221
- maxConcurrentJobs,
237
+ maxConcurrentJobs: maxRunningJobs,
222
238
  };
223
239
  }
224
240
 
225
- const logLevel = process.env["PO_LOG_LEVEL"];
226
- if (logLevel && (VALID_LOG_LEVELS as readonly string[]).includes(logLevel)) {
227
- overrides["logging"] = { level: logLevel };
241
+ const staleRunningGraceMs = envInt(
242
+ "PO_STALE_RUNNING_GRACE_MS",
243
+ process.env["PO_STALE_RUNNING_GRACE_MS"],
244
+ { min: 1 }
245
+ );
246
+ if (staleRunningGraceMs !== undefined) {
247
+ overrides["orchestrator"] = {
248
+ ...((overrides["orchestrator"] as PlainObject | undefined) ?? {}),
249
+ staleRunningGraceMs,
250
+ };
228
251
  }
229
252
 
230
- return deepMerge(config as unknown as PlainObject, overrides) as unknown as AppConfig;
231
- }
253
+ const staleLeaseTimeoutMs = envInt(
254
+ "PO_STALE_LEASE_TIMEOUT_MS",
255
+ process.env["PO_STALE_LEASE_TIMEOUT_MS"],
256
+ { min: 1 }
257
+ );
258
+ if (staleLeaseTimeoutMs !== undefined) {
259
+ overrides["orchestrator"] = {
260
+ ...((overrides["orchestrator"] as PlainObject | undefined) ?? {}),
261
+ staleLeaseTimeoutMs,
262
+ };
263
+ }
232
264
 
233
- async function loadFromFile(filePath: string): Promise<Record<string, unknown> | null> {
234
- let text: string;
235
- try {
236
- text = await readFile(filePath, "utf8");
237
- } catch (error) {
238
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
239
- return null;
265
+ const logLevel = process.env["PO_LOG_LEVEL"];
266
+ if (logLevel !== undefined) {
267
+ if (!(VALID_LOG_LEVELS as readonly string[]).includes(logLevel)) {
268
+ throw new Error(`PO_LOG_LEVEL must be one of ${VALID_LOG_LEVELS.join(", ")}, got ${logLevel}`);
240
269
  }
241
- throw new Error(`Failed to load config file: ${filePath}`);
270
+ overrides["logging"] = { level: logLevel as "debug" | "info" | "warn" | "error" };
242
271
  }
243
272
 
244
- try {
245
- return JSON.parse(text) as Record<string, unknown>;
246
- } catch (err) {
247
- throw new Error(`Failed to load config file: ${filePath}: ${String(err)}`);
248
- }
273
+ return deepMerge(config as unknown as PlainObject, overrides) as unknown as AppConfig;
249
274
  }
250
275
 
251
276
  function validateConfig(config: AppConfig): void {
252
- const errors: string[] = [];
253
-
254
- if (config.ui.port < 1 || config.ui.port > 65535) {
255
- errors.push(`ui.port must be between 1 and 65535, got ${config.ui.port}`);
256
- }
257
- if (config.llm.maxConcurrency < 1) {
258
- errors.push(`llm.maxConcurrency must be >= 1, got ${config.llm.maxConcurrency}`);
259
- }
260
277
  if (config.taskRunner.maxRefinementAttempts < 1) {
261
- errors.push(`taskRunner.maxRefinementAttempts must be >= 1, got ${config.taskRunner.maxRefinementAttempts}`);
262
- }
263
- if (!Number.isInteger(config.taskRunner.maxAttempts) || config.taskRunner.maxAttempts < 1) {
264
- errors.push(`taskRunner.maxAttempts must be an integer >= 1, got ${config.taskRunner.maxAttempts}`);
265
- }
266
- if (!Number.isInteger(config.orchestrator.maxConcurrentJobs) || config.orchestrator.maxConcurrentJobs < 1) {
267
- errors.push(`orchestrator.maxConcurrentJobs must be a positive integer, got ${config.orchestrator.maxConcurrentJobs}`);
268
- }
269
- if (!(VALID_LOG_LEVELS as readonly string[]).includes(config.logging.level)) {
270
- errors.push(`logging.level must be one of ${VALID_LOG_LEVELS.join(", ")}, got ${config.logging.level}`);
278
+ throw new Error(
279
+ `taskRunner.maxRefinementAttempts must be >= 1, got ${config.taskRunner.maxRefinementAttempts}`
280
+ );
271
281
  }
282
+ }
272
283
 
273
- if (errors.length > 0) {
274
- throw new Error(`Configuration validation failed: ${errors.join("; ")}`);
284
+ function deepFreeze<T>(value: T): DeepReadonly<T> {
285
+ if (value === null || value === undefined) return value as DeepReadonly<T>;
286
+ if (typeof value !== "object") return value as DeepReadonly<T>;
287
+ Object.freeze(value);
288
+ if (Array.isArray(value)) {
289
+ for (const item of value) {
290
+ if (item !== null && typeof item === "object") {
291
+ deepFreeze(item);
292
+ }
293
+ }
294
+ return value as DeepReadonly<T>;
275
295
  }
296
+ for (const key of Object.keys(value as object)) {
297
+ const v = (value as Record<string, unknown>)[key];
298
+ if (v !== null && typeof v === "object") {
299
+ deepFreeze(v);
300
+ }
301
+ }
302
+ return value as DeepReadonly<T>;
276
303
  }
277
304
 
278
305
  // ─── Module State ─────────────────────────────────────────────────────────────
279
306
 
280
- let cachedConfig: AppConfig | null = null;
307
+ let cachedConfig: DeepReadonly<AppConfig> | null = null;
281
308
 
282
309
  // ─── Registry Hydration ───────────────────────────────────────────────────────
283
310
 
284
- interface RegistryPipelines {
285
- pipelines: Record<string, { configDir?: string; tasksDir?: string }>;
286
- }
287
-
288
- interface LegacyRegistry {
289
- slugs: string[];
290
- }
291
-
292
- async function hydratePipelinesFromRegistry(config: AppConfig, registryPath: string): Promise<void> {
293
- let raw: RegistryPipelines | LegacyRegistry;
294
- try {
295
- raw = JSON.parse(await readFile(registryPath, "utf8")) as RegistryPipelines | LegacyRegistry;
296
- } catch (err) {
297
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
298
- return;
299
- }
300
- throw new Error(`Failed to read pipeline registry: ${err instanceof Error ? err.message : String(err)}`);
301
- }
302
-
303
- if ("slugs" in raw) {
304
- console.warn(`[config] Legacy registry format detected at ${registryPath} — skipping`);
305
- return;
306
- }
307
-
308
- const root = config.paths.root;
309
- for (const [slug, entry] of Object.entries(raw.pipelines)) {
310
- const resolvedConfigDir = entry.configDir
311
- ? (path.isAbsolute(entry.configDir) ? entry.configDir : path.join(root, entry.configDir))
312
- : path.join(root, "pipeline-config", slug);
313
- const resolvedTasksDir = entry.tasksDir
314
- ? (path.isAbsolute(entry.tasksDir) ? entry.tasksDir : path.join(root, entry.tasksDir))
315
- : path.join(resolvedConfigDir, "tasks");
316
- config.pipelines[slug] = {
317
- configDir: resolvedConfigDir,
318
- tasksDir: resolvedTasksDir,
319
- };
320
- }
311
+ function applyPipelineRegistryEntriesSync(config: AppConfig, poRoot: string): void {
312
+ Object.assign(config.pipelines, resolveAllPipelinesSync(poRoot));
321
313
  }
322
314
 
323
315
  // ─── Public API ───────────────────────────────────────────────────────────────
@@ -326,52 +318,7 @@ export function resetConfig(): void {
326
318
  cachedConfig = null;
327
319
  }
328
320
 
329
- export async function loadConfig(options?: LoadConfigOptions): Promise<AppConfig> {
330
- let config = JSON.parse(JSON.stringify(defaultConfig)) as AppConfig;
331
-
332
- if (options?.configPath) {
333
- const fileData = await loadFromFile(options.configPath);
334
- if (fileData) {
335
- config = deepMerge(config as unknown as PlainObject, fileData) as unknown as AppConfig;
336
- }
337
- }
338
-
339
- config = loadFromEnvironment(config);
340
-
341
- if (!process.env["PO_ROOT"]) {
342
- throw new Error("PO_ROOT is required");
343
- }
344
-
345
- await hydratePipelinesFromRegistry(config, path.join(config.paths.root, "pipeline-config", "registry.json"));
346
-
347
- if (Object.keys(config.pipelines).length === 0) {
348
- throw new Error("No pipelines are registered");
349
- } else {
350
- const errors: string[] = [];
351
- for (const [slug, entry] of Object.entries(config.pipelines)) {
352
- const configDirExists = existsSync(path.join(entry.configDir, "pipeline.json"));
353
- const tasksDirExists = await stat(entry.tasksDir).then(() => true).catch(() => false);
354
- if (!configDirExists) {
355
- errors.push(`Pipeline '${slug}': pipeline.json not found`);
356
- }
357
- if (!tasksDirExists) {
358
- errors.push(`Pipeline '${slug}': tasksDir not found`);
359
- }
360
- }
361
- if (errors.length > 0) {
362
- throw new Error(errors.join("; "));
363
- }
364
- }
365
-
366
- if (options?.validate !== false) {
367
- validateConfig(config);
368
- }
369
-
370
- cachedConfig = config;
371
- return config;
372
- }
373
-
374
- export function getConfig(): AppConfig {
321
+ export function getConfig(): DeepReadonly<AppConfig> {
375
322
  if (cachedConfig) return cachedConfig;
376
323
 
377
324
  let config = JSON.parse(JSON.stringify(defaultConfig)) as AppConfig;
@@ -380,37 +327,22 @@ export function getConfig(): AppConfig {
380
327
  const rawPoRoot = process.env["PO_ROOT"];
381
328
  if (!rawPoRoot) {
382
329
  if (process.env["NODE_ENV"] === "test") {
383
- cachedConfig = config;
330
+ validateConfig(config);
331
+ cachedConfig = deepFreeze(config);
384
332
  return cachedConfig;
385
333
  }
386
334
  throw new Error("PO_ROOT is required");
387
335
  }
388
336
  const poRoot = path.resolve(rawPoRoot);
389
337
 
390
- const registryPath = path.join(poRoot, "pipeline-config", "registry.json");
391
- if (existsSync(registryPath)) {
392
- const raw = JSON.parse(readFileSync(registryPath, "utf8")) as RegistryPipelines | LegacyRegistry;
393
- if (!("slugs" in raw)) {
394
- for (const [slug, entry] of Object.entries(raw.pipelines)) {
395
- const resolvedConfigDir = entry.configDir
396
- ? (path.isAbsolute(entry.configDir) ? entry.configDir : path.join(poRoot, entry.configDir))
397
- : path.join(poRoot, "pipeline-config", slug);
398
- const resolvedTasksDir = entry.tasksDir
399
- ? (path.isAbsolute(entry.tasksDir) ? entry.tasksDir : path.join(poRoot, entry.tasksDir))
400
- : path.join(resolvedConfigDir, "tasks");
401
- config.pipelines[slug] = {
402
- configDir: resolvedConfigDir,
403
- tasksDir: resolvedTasksDir,
404
- };
405
- }
406
- }
407
- }
338
+ applyPipelineRegistryEntriesSync(config, poRoot);
408
339
 
409
340
  if (Object.keys(config.pipelines).length === 0 && process.env["NODE_ENV"] !== "test") {
410
341
  console.warn("[config] No pipelines found in registry");
411
342
  }
412
343
 
413
- cachedConfig = config;
344
+ validateConfig(config);
345
+ cachedConfig = deepFreeze(config);
414
346
  return cachedConfig;
415
347
  }
416
348
 
@@ -435,27 +367,7 @@ export function getConfigValue(dotPath: string, defaultValue?: unknown): unknown
435
367
 
436
368
  export function getPipelineConfig(slug: string, root?: string): PipelineConfigResult {
437
369
  if (root) {
438
- const registryPath = path.join(root, "pipeline-config", "registry.json");
439
- if (existsSync(registryPath)) {
440
- const raw = JSON.parse(readFileSync(registryPath, "utf8")) as RegistryPipelines | LegacyRegistry;
441
- if (!("slugs" in raw)) {
442
- const entry = raw.pipelines[slug];
443
- if (!entry) {
444
- throw new Error(`Pipeline '${slug}' not found in registry`);
445
- }
446
- const resolvedConfigDir = entry.configDir
447
- ? (path.isAbsolute(entry.configDir) ? entry.configDir : path.join(root, entry.configDir))
448
- : path.join(root, "pipeline-config", slug);
449
- const resolvedTasksDir = entry.tasksDir
450
- ? (path.isAbsolute(entry.tasksDir) ? entry.tasksDir : path.join(root, entry.tasksDir))
451
- : path.join(resolvedConfigDir, "tasks");
452
- return {
453
- pipelineJsonPath: path.join(resolvedConfigDir, "pipeline.json"),
454
- tasksDir: resolvedTasksDir,
455
- };
456
- }
457
- }
458
- throw new Error(`Pipeline '${slug}' not found in registry`);
370
+ return resolvePipelineSync(root, slug);
459
371
  }
460
372
 
461
373
  const config = getConfig();
@@ -26,9 +26,12 @@ export const MAX_RUN_TASKS = 64;
26
26
 
27
27
  export class ControlValidationError extends Error {
28
28
  override name = "ControlValidationError";
29
+ readonly violations: string[];
29
30
 
30
31
  constructor(violations: string[]) {
32
+ const normalizedViolations = [...violations];
31
33
  super(violations.join("\n"));
34
+ this.violations = normalizedViolations;
32
35
  }
33
36
  }
34
37