@ryanfw/prompt-orchestration-pipeline 1.2.7 → 1.2.9

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/config/__tests__/models.test.ts +31 -1
  3. package/src/config/models.ts +81 -35
  4. package/src/config/paths.ts +13 -8
  5. package/src/core/__tests__/config.test.ts +121 -0
  6. package/src/core/__tests__/job-concurrency.test.ts +554 -0
  7. package/src/core/__tests__/orchestrator.test.ts +353 -0
  8. package/src/core/__tests__/pipeline-runner.test.ts +430 -2
  9. package/src/core/__tests__/task-runner.test.ts +1 -2
  10. package/src/core/config.ts +48 -1
  11. package/src/core/job-concurrency.ts +462 -0
  12. package/src/core/orchestrator.ts +370 -57
  13. package/src/core/pipeline-runner.ts +79 -15
  14. package/src/core/status-writer.ts +4 -0
  15. package/src/core/task-runner.ts +1 -1
  16. package/src/providers/__tests__/base.test.ts +1 -1
  17. package/src/ui/client/__tests__/api.test.ts +101 -1
  18. package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
  19. package/src/ui/client/__tests__/useConcurrencyStatus.test.ts +126 -0
  20. package/src/ui/client/adapters/job-adapter.ts +1 -0
  21. package/src/ui/client/api.ts +77 -7
  22. package/src/ui/client/hooks/useConcurrencyStatus.ts +102 -0
  23. package/src/ui/client/types.ts +34 -1
  24. package/src/ui/components/DAGGrid.tsx +11 -1
  25. package/src/ui/components/JobDetail.tsx +2 -1
  26. package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
  27. package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
  28. package/src/ui/components/types.ts +2 -0
  29. package/src/ui/dist/assets/{index-SKy2shWc.js → index-BnAqY4_n.js} +336 -52
  30. package/src/ui/dist/assets/index-BnAqY4_n.js.map +1 -0
  31. package/src/ui/dist/assets/style-BKG0bHu-.css +2 -0
  32. package/src/ui/dist/index.html +2 -2
  33. package/src/ui/embedded-assets.js +6 -6
  34. package/src/ui/pages/PromptPipelineDashboard.tsx +186 -4
  35. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +272 -1
  36. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +190 -0
  37. package/src/ui/server/__tests__/index.test.ts +92 -3
  38. package/src/ui/server/__tests__/job-control-endpoints.test.ts +660 -3
  39. package/src/ui/server/endpoints/concurrency-endpoint.ts +72 -0
  40. package/src/ui/server/endpoints/job-control-endpoints.ts +248 -37
  41. package/src/ui/server/index.ts +21 -2
  42. package/src/ui/server/router.ts +2 -0
  43. package/src/ui/state/__tests__/watcher.test.ts +31 -0
  44. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
  45. package/src/ui/state/transformers/status-transformer.ts +1 -0
  46. package/src/ui/state/types.ts +3 -0
  47. package/src/ui/state/watcher.ts +9 -1
  48. package/src/utils/__tests__/dag.test.ts +35 -0
  49. package/src/utils/dag.ts +1 -0
  50. package/src/ui/dist/assets/index-SKy2shWc.js.map +0 -1
  51. package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
5
5
  "type": "module",
6
6
  "main": "src/ui/server/index.ts",
@@ -15,7 +15,7 @@ import {
15
15
  } from "../models";
16
16
  import type { ModelConfigEntry } from "../models";
17
17
 
18
- const MODEL_COUNT = 48;
18
+ const MODEL_COUNT = 50;
19
19
  const PROVIDER_COUNT = 8;
20
20
 
21
21
  describe("ModelAlias", () => {
@@ -177,6 +177,21 @@ describe("getModelConfig", () => {
177
177
  expect(config!.provider).toBe("openai");
178
178
  });
179
179
 
180
+ it("returns configs for Alibaba Qwen 3.6 models", () => {
181
+ expect(getModelConfig("alibaba:qwen3.6-flash")).toMatchObject({
182
+ provider: "alibaba",
183
+ model: "qwen3.6-flash",
184
+ });
185
+ expect(getModelConfig("alibaba:qwen3.6-plus")).toMatchObject({
186
+ provider: "alibaba",
187
+ model: "qwen3.6-plus",
188
+ });
189
+ expect(getModelConfig("alibaba:qwen3.6-max-preview")).toMatchObject({
190
+ provider: "alibaba",
191
+ model: "qwen3.6-max-preview",
192
+ });
193
+ });
194
+
180
195
  it("returns null for unknown aliases", () => {
181
196
  expect(getModelConfig("nonexistent:model")).toBeNull();
182
197
  expect(getModelConfig("invalid")).toBeNull();
@@ -218,6 +233,14 @@ describe("FUNCTION_NAME_BY_ALIAS", () => {
218
233
  it("has correct value for gemini:flash-2.5-lite", () => {
219
234
  expect(FUNCTION_NAME_BY_ALIAS["gemini:flash-2.5-lite"]).toBe("flash25Lite");
220
235
  });
236
+
237
+ it("has correct values for Alibaba Qwen 3.6 models", () => {
238
+ expect(FUNCTION_NAME_BY_ALIAS["alibaba:qwen3.6-flash"]).toBe("qwen36Flash");
239
+ expect(FUNCTION_NAME_BY_ALIAS["alibaba:qwen3.6-plus"]).toBe("qwen36Plus");
240
+ expect(FUNCTION_NAME_BY_ALIAS["alibaba:qwen3.6-max-preview"]).toBe(
241
+ "qwen36MaxPreview",
242
+ );
243
+ });
221
244
  });
222
245
 
223
246
  describe("PROVIDER_FUNCTIONS", () => {
@@ -248,6 +271,13 @@ describe("PROVIDER_FUNCTIONS", () => {
248
271
  }
249
272
  });
250
273
 
274
+ it("includes callable paths for Alibaba Qwen 3.6 models", () => {
275
+ const alibabaPaths = PROVIDER_FUNCTIONS.alibaba.map((entry) => entry.fullPath);
276
+ expect(alibabaPaths).toContain("llm.alibaba.qwen36Flash");
277
+ expect(alibabaPaths).toContain("llm.alibaba.qwen36Plus");
278
+ expect(alibabaPaths).toContain("llm.alibaba.qwen36MaxPreview");
279
+ });
280
+
251
281
  it("is frozen (top-level)", () => {
252
282
  expect(Object.isFrozen(PROVIDER_FUNCTIONS)).toBe(true);
253
283
  });
@@ -27,7 +27,11 @@ function deepFreeze<T>(obj: T): T {
27
27
  if (obj === null || typeof obj !== "object") return obj;
28
28
  Object.freeze(obj);
29
29
  for (const value of Object.values(obj as object)) {
30
- if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
30
+ if (
31
+ value !== null &&
32
+ typeof value === "object" &&
33
+ !Object.isFrozen(value)
34
+ ) {
31
35
  deepFreeze(value);
32
36
  }
33
37
  }
@@ -84,8 +88,10 @@ export const ModelAlias = Object.freeze({
84
88
  ZAI_GLM_4_5_AIR: "zai:glm-4-5-air",
85
89
  ZAI_GLM_4_5_AIR_X: "zai:glm-4-5-air-x",
86
90
  // Alibaba (Qwen via DashScope, international/Singapore deployment)
87
- ALIBABA_QWEN3_MAX: "alibaba:qwen3-max",
91
+ ALIBABA_QWEN3_6_MAX_PREVIEW: "alibaba:qwen3.6-max-preview",
88
92
  ALIBABA_QWEN3_6_PLUS: "alibaba:qwen3.6-plus",
93
+ ALIBABA_QWEN3_6_FLASH: "alibaba:qwen3.6-flash",
94
+ ALIBABA_QWEN3_MAX: "alibaba:qwen3-max",
89
95
  ALIBABA_QWEN3_5_PLUS: "alibaba:qwen3.5-plus",
90
96
  ALIBABA_QWEN3_5_FLASH: "alibaba:qwen3.5-flash",
91
97
  ALIBABA_QWEN_PLUS: "alibaba:qwen-plus",
@@ -342,11 +348,11 @@ const MODEL_CONFIG_RAW: Record<ModelAliasKey, ModelConfigEntry> = {
342
348
  tokenCostOutPerMillion: 4.5,
343
349
  },
344
350
  // Alibaba (Qwen via DashScope, international/Singapore deployment, base tier)
345
- "alibaba:qwen3-max": {
351
+ "alibaba:qwen3.6-max-preview": {
346
352
  provider: "alibaba",
347
- model: "qwen3-max",
348
- tokenCostInPerMillion: 1.2,
349
- tokenCostOutPerMillion: 6,
353
+ model: "qwen3.6-max-preview",
354
+ tokenCostInPerMillion: 1.3,
355
+ tokenCostOutPerMillion: 7.8,
350
356
  },
351
357
  "alibaba:qwen3.6-plus": {
352
358
  provider: "alibaba",
@@ -354,6 +360,18 @@ const MODEL_CONFIG_RAW: Record<ModelAliasKey, ModelConfigEntry> = {
354
360
  tokenCostInPerMillion: 0.276,
355
361
  tokenCostOutPerMillion: 1.651,
356
362
  },
363
+ "alibaba:qwen3.6-flash": {
364
+ provider: "alibaba",
365
+ model: "qwen3.6-flash",
366
+ tokenCostInPerMillion: 0.025,
367
+ tokenCostOutPerMillion: 1.5,
368
+ },
369
+ "alibaba:qwen3-max": {
370
+ provider: "alibaba",
371
+ model: "qwen3-max",
372
+ tokenCostInPerMillion: 1.2,
373
+ tokenCostOutPerMillion: 6,
374
+ },
357
375
  "alibaba:qwen3.5-plus": {
358
376
  provider: "alibaba",
359
377
  model: "qwen3.5-plus",
@@ -399,7 +417,9 @@ const MODEL_CONFIG_RAW: Record<ModelAliasKey, ModelConfigEntry> = {
399
417
  };
400
418
 
401
419
  export const MODEL_CONFIG: Readonly<Record<ModelAliasKey, ModelConfigEntry>> =
402
- deepFreeze(MODEL_CONFIG_RAW) as Readonly<Record<ModelAliasKey, ModelConfigEntry>>;
420
+ deepFreeze(MODEL_CONFIG_RAW) as Readonly<
421
+ Record<ModelAliasKey, ModelConfigEntry>
422
+ >;
403
423
 
404
424
  export const VALID_MODEL_ALIASES: ReadonlySet<ModelAliasKey> = new Set(
405
425
  Object.keys(MODEL_CONFIG) as ModelAliasKey[],
@@ -409,18 +429,24 @@ export const VALID_MODEL_ALIASES: ReadonlySet<ModelAliasKey> = new Set(
409
429
 
410
430
  export function aliasToFunctionName(alias: string): string {
411
431
  if (typeof alias !== "string") {
412
- throw new Error(`Invalid model alias: expected string, got ${typeof alias}`);
432
+ throw new Error(
433
+ `Invalid model alias: expected string, got ${typeof alias}`,
434
+ );
413
435
  }
414
436
  if (!alias.includes(":")) {
415
437
  throw new Error(`Invalid model alias: "${alias}" does not contain a colon`);
416
438
  }
417
439
  const model = alias.split(":").slice(1).join(":");
418
- return model.replace(/[-.]([a-z0-9])/gi, (_, char: string) => char.toUpperCase());
440
+ return model.replace(/[-.]([a-z0-9])/gi, (_, char: string) =>
441
+ char.toUpperCase(),
442
+ );
419
443
  }
420
444
 
421
445
  export function getProviderFromAlias(alias: string): ProviderName {
422
446
  if (typeof alias !== "string") {
423
- throw new Error(`Invalid model alias: expected string, got ${typeof alias}`);
447
+ throw new Error(
448
+ `Invalid model alias: expected string, got ${typeof alias}`,
449
+ );
424
450
  }
425
451
  if (!alias.includes(":")) {
426
452
  throw new Error(`Invalid model alias: "${alias}" does not contain a colon`);
@@ -430,7 +456,9 @@ export function getProviderFromAlias(alias: string): ProviderName {
430
456
 
431
457
  export function getModelFromAlias(alias: string): string {
432
458
  if (typeof alias !== "string") {
433
- throw new Error(`Invalid model alias: expected string, got ${typeof alias}`);
459
+ throw new Error(
460
+ `Invalid model alias: expected string, got ${typeof alias}`,
461
+ );
434
462
  }
435
463
  if (!alias.includes(":")) {
436
464
  throw new Error(`Invalid model alias: "${alias}" does not contain a colon`);
@@ -439,33 +467,38 @@ export function getModelFromAlias(alias: string): string {
439
467
  }
440
468
 
441
469
  export function getModelConfig(alias: string): ModelConfigEntry | null {
442
- return (MODEL_CONFIG as Record<string, ModelConfigEntry | undefined>)[alias] ?? null;
470
+ return (
471
+ (MODEL_CONFIG as Record<string, ModelConfigEntry | undefined>)[alias] ??
472
+ null
473
+ );
443
474
  }
444
475
 
445
476
  // ─── Default Model By Provider ───────────────────────────────────────────────
446
477
 
447
- export const DEFAULT_MODEL_BY_PROVIDER: Readonly<Record<ProviderName, ModelAliasKey>> =
448
- Object.freeze({
449
- openai: "openai:gpt-5.4",
450
- anthropic: "anthropic:sonnet-4-6",
451
- gemini: "gemini:flash-2.5",
452
- deepseek: "deepseek:v4-flash",
453
- moonshot: "moonshot:kimi-k2.6",
454
- "claude-code": "claude-code:sonnet",
455
- zai: "zai:glm-5-1",
456
- alibaba: "alibaba:qwen3-max",
457
- } as const);
478
+ export const DEFAULT_MODEL_BY_PROVIDER: Readonly<
479
+ Record<ProviderName, ModelAliasKey>
480
+ > = Object.freeze({
481
+ openai: "openai:gpt-5.4",
482
+ anthropic: "anthropic:sonnet-4-6",
483
+ gemini: "gemini:flash-2.5",
484
+ deepseek: "deepseek:v4-flash",
485
+ moonshot: "moonshot:kimi-k2.6",
486
+ "claude-code": "claude-code:sonnet",
487
+ zai: "zai:glm-5-1",
488
+ alibaba: "alibaba:qwen3-max",
489
+ } as const);
458
490
 
459
491
  // ─── Function Name Derived Index ─────────────────────────────────────────────
460
492
 
461
- export const FUNCTION_NAME_BY_ALIAS: Readonly<Record<ModelAliasKey, string>> = Object.freeze(
462
- Object.fromEntries(
463
- (Object.keys(MODEL_CONFIG) as ModelAliasKey[]).map((alias) => [
464
- alias,
465
- aliasToFunctionName(alias),
466
- ]),
467
- ) as Record<ModelAliasKey, string>,
468
- );
493
+ export const FUNCTION_NAME_BY_ALIAS: Readonly<Record<ModelAliasKey, string>> =
494
+ Object.freeze(
495
+ Object.fromEntries(
496
+ (Object.keys(MODEL_CONFIG) as ModelAliasKey[]).map((alias) => [
497
+ alias,
498
+ aliasToFunctionName(alias),
499
+ ]),
500
+ ) as Record<ModelAliasKey, string>,
501
+ );
469
502
 
470
503
  // ─── Provider Functions Index ────────────────────────────────────────────────
471
504
 
@@ -487,7 +520,13 @@ export function buildProviderFunctionsIndex(): ProviderFunctionsIndex {
487
520
  index[provider] = [];
488
521
  }
489
522
  index[provider]!.push(
490
- Object.freeze({ alias, provider, model, functionName, fullPath }) as ProviderFunctionEntry,
523
+ Object.freeze({
524
+ alias,
525
+ provider,
526
+ model,
527
+ functionName,
528
+ fullPath,
529
+ }) as ProviderFunctionEntry,
491
530
  );
492
531
  }
493
532
 
@@ -499,7 +538,8 @@ export function buildProviderFunctionsIndex(): ProviderFunctionsIndex {
499
538
  return Object.freeze(index) as ProviderFunctionsIndex;
500
539
  }
501
540
 
502
- export const PROVIDER_FUNCTIONS: ProviderFunctionsIndex = buildProviderFunctionsIndex();
541
+ export const PROVIDER_FUNCTIONS: ProviderFunctionsIndex =
542
+ buildProviderFunctionsIndex();
503
543
 
504
544
  // ─── Module-Load Invariant Validation ────────────────────────────────────────
505
545
 
@@ -546,13 +586,19 @@ export function validateModelRegistry(
546
586
  }
547
587
 
548
588
  // Check non-negative token costs
549
- if (typeof entry.tokenCostInPerMillion !== "number" || entry.tokenCostInPerMillion < 0) {
589
+ if (
590
+ typeof entry.tokenCostInPerMillion !== "number" ||
591
+ entry.tokenCostInPerMillion < 0
592
+ ) {
550
593
  throw new Error(
551
594
  `Model config invariant violation: alias "${alias}" has invalid tokenCostInPerMillion: ` +
552
595
  `${entry.tokenCostInPerMillion}`,
553
596
  );
554
597
  }
555
- if (typeof entry.tokenCostOutPerMillion !== "number" || entry.tokenCostOutPerMillion < 0) {
598
+ if (
599
+ typeof entry.tokenCostOutPerMillion !== "number" ||
600
+ entry.tokenCostOutPerMillion < 0
601
+ ) {
556
602
  throw new Error(
557
603
  `Model config invariant violation: alias "${alias}" has invalid tokenCostOutPerMillion: ` +
558
604
  `${entry.tokenCostOutPerMillion}`,
@@ -8,25 +8,30 @@ export interface PipelinePaths {
8
8
  readonly rejected: string;
9
9
  }
10
10
 
11
+ export function getPipelineDataDir(baseDir: string): string {
12
+ return join(baseDir, "pipeline-data");
13
+ }
14
+
11
15
  export function resolvePipelinePaths(baseDir: string): PipelinePaths {
16
+ const dataDir = getPipelineDataDir(baseDir);
12
17
  return {
13
- pending: join(baseDir, "pipeline-data", "pending"),
14
- current: join(baseDir, "pipeline-data", "current"),
15
- complete: join(baseDir, "pipeline-data", "complete"),
16
- rejected: join(baseDir, "pipeline-data", "rejected"),
18
+ pending: join(dataDir, "pending"),
19
+ current: join(dataDir, "current"),
20
+ complete: join(dataDir, "complete"),
21
+ rejected: join(dataDir, "rejected"),
17
22
  };
18
23
  }
19
24
 
20
25
  export function getPendingSeedPath(baseDir: string, jobId: string): string {
21
- return join(baseDir, "pipeline-data", "pending", `${jobId}-seed.json`);
26
+ return join(getPipelineDataDir(baseDir), "pending", `${jobId}-seed.json`);
22
27
  }
23
28
 
24
29
  export function getCurrentSeedPath(baseDir: string, jobId: string): string {
25
- return join(baseDir, "pipeline-data", "current", jobId, "seed.json");
30
+ return join(getPipelineDataDir(baseDir), "current", jobId, "seed.json");
26
31
  }
27
32
 
28
33
  export function getCompleteSeedPath(baseDir: string, jobId: string): string {
29
- return join(baseDir, "pipeline-data", "complete", jobId, "seed.json");
34
+ return join(getPipelineDataDir(baseDir), "complete", jobId, "seed.json");
30
35
  }
31
36
 
32
37
  export function getJobDirectoryPath(
@@ -34,7 +39,7 @@ export function getJobDirectoryPath(
34
39
  jobId: string,
35
40
  location: JobLocationValue,
36
41
  ): string {
37
- return join(baseDir, "pipeline-data", location, jobId);
42
+ return join(getPipelineDataDir(baseDir), location, jobId);
38
43
  }
39
44
 
40
45
  export function getJobMetadataPath(
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect, afterEach } from "bun:test";
2
2
  import { defaultConfig, resetConfig, loadConfig, getConfig, getConfigValue, getPipelineConfig } from "../config";
3
+ import type { AppConfig } from "../config";
3
4
  import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
@@ -71,6 +72,126 @@ describe("defaultConfig", () => {
71
72
  expect(defaultConfig.ui.port).toBe(3000);
72
73
  expect(defaultConfig.taskRunner.maxRefinementAttempts).toBeGreaterThan(0);
73
74
  });
75
+
76
+ test("taskRunner.maxAttempts defaults to 3", () => {
77
+ expect(defaultConfig.taskRunner.maxAttempts).toBe(3);
78
+ });
79
+
80
+ test("orchestrator.maxConcurrentJobs defaults to 3", () => {
81
+ expect(defaultConfig.orchestrator.maxConcurrentJobs).toBe(3);
82
+ });
83
+ });
84
+
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
+ describe("PO_TASK_MAX_ATTEMPTS env override", () => {
134
+ afterEach(() => {
135
+ delete process.env.PO_TASK_MAX_ATTEMPTS;
136
+ resetConfig();
137
+ });
138
+
139
+ test("getConfig reads PO_TASK_MAX_ATTEMPTS into taskRunner.maxAttempts", () => {
140
+ process.env.PO_TASK_MAX_ATTEMPTS = "5";
141
+ resetConfig();
142
+ const config: AppConfig = getConfig();
143
+ expect(config.taskRunner.maxAttempts).toBe(5);
144
+ });
145
+
146
+ test("getConfig rejects non-numeric PO_TASK_MAX_ATTEMPTS values", () => {
147
+ process.env.PO_TASK_MAX_ATTEMPTS = "abc";
148
+ resetConfig();
149
+ expect(() => getConfig()).toThrow("PO_TASK_MAX_ATTEMPTS must be an integer >= 1");
150
+ });
151
+
152
+ test("getConfig rejects non-integer PO_TASK_MAX_ATTEMPTS values", () => {
153
+ process.env.PO_TASK_MAX_ATTEMPTS = "2.5";
154
+ resetConfig();
155
+ expect(() => getConfig()).toThrow("PO_TASK_MAX_ATTEMPTS must be an integer >= 1");
156
+ });
157
+ });
158
+
159
+ describe("PO_MAX_RUNNING_JOBS env override", () => {
160
+ afterEach(() => {
161
+ delete process.env.PO_MAX_RUNNING_JOBS;
162
+ resetConfig();
163
+ });
164
+
165
+ test("getConfig reads PO_MAX_RUNNING_JOBS into orchestrator.maxConcurrentJobs", () => {
166
+ process.env.PO_MAX_RUNNING_JOBS = "7";
167
+ resetConfig();
168
+ const config: AppConfig = getConfig();
169
+ expect(config.orchestrator.maxConcurrentJobs).toBe(7);
170
+ });
171
+
172
+ test("getConfig rejects PO_MAX_RUNNING_JOBS=0", () => {
173
+ process.env.PO_MAX_RUNNING_JOBS = "0";
174
+ resetConfig();
175
+ expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
176
+ });
177
+
178
+ test("getConfig rejects negative PO_MAX_RUNNING_JOBS", () => {
179
+ process.env.PO_MAX_RUNNING_JOBS = "-1";
180
+ resetConfig();
181
+ expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
182
+ });
183
+
184
+ test("getConfig rejects non-integer PO_MAX_RUNNING_JOBS", () => {
185
+ process.env.PO_MAX_RUNNING_JOBS = "1.5";
186
+ resetConfig();
187
+ expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
188
+ });
189
+
190
+ test("getConfig rejects non-numeric PO_MAX_RUNNING_JOBS", () => {
191
+ process.env.PO_MAX_RUNNING_JOBS = "abc";
192
+ resetConfig();
193
+ expect(() => getConfig()).toThrow("orchestrator.maxConcurrentJobs");
194
+ });
74
195
  });
75
196
 
76
197
  describe("getConfig", () => {