@ryanfw/prompt-orchestration-pipeline 1.2.7 → 1.2.8
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.
- package/package.json +1 -1
- package/src/config/__tests__/models.test.ts +31 -1
- package/src/config/models.ts +81 -35
- package/src/core/__tests__/config.test.ts +79 -0
- package/src/core/__tests__/pipeline-runner.test.ts +268 -1
- package/src/core/__tests__/task-runner.test.ts +1 -2
- package/src/core/config.ts +17 -0
- package/src/core/pipeline-runner.ts +39 -4
- package/src/core/status-writer.ts +4 -0
- package/src/core/task-runner.ts +1 -1
- package/src/providers/__tests__/base.test.ts +1 -1
- package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
- package/src/ui/client/adapters/job-adapter.ts +1 -0
- package/src/ui/client/types.ts +1 -0
- package/src/ui/components/DAGGrid.tsx +11 -1
- package/src/ui/components/JobDetail.tsx +2 -1
- package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index-SKy2shWc.js → index-CNlnQmK4.js} +48 -10
- package/src/ui/dist/assets/{index-SKy2shWc.js.map → index-CNlnQmK4.js.map} +1 -1
- package/src/ui/dist/assets/style-DNbNL3Yg.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +474 -2
- package/src/ui/server/endpoints/job-control-endpoints.ts +136 -22
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
- package/src/ui/state/transformers/status-transformer.ts +1 -0
- package/src/ui/state/types.ts +1 -0
- package/src/utils/__tests__/dag.test.ts +35 -0
- package/src/utils/dag.ts +1 -0
- 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.
|
|
3
|
+
"version": "1.2.8",
|
|
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 =
|
|
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
|
});
|
package/src/config/models.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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.
|
|
349
|
-
tokenCostOutPerMillion:
|
|
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<
|
|
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(
|
|
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) =>
|
|
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(
|
|
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(
|
|
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 (
|
|
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<
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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>> =
|
|
462
|
-
Object.
|
|
463
|
-
|
|
464
|
-
alias
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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({
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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}`,
|
|
@@ -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,84 @@ 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
|
+
|
|
81
|
+
describe("validateConfig (via loadConfig)", () => {
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
resetConfig();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("throws when taskRunner.maxAttempts is 0", async () => {
|
|
87
|
+
const dir = await mkdtemp(join(tmpdir(), "config-test-"));
|
|
88
|
+
const configDir = join(dir, "pipeline-config", "test");
|
|
89
|
+
const tasksDir = join(configDir, "tasks");
|
|
90
|
+
await mkdir(tasksDir, { recursive: true });
|
|
91
|
+
await writeFile(join(configDir, "pipeline.json"), JSON.stringify({ name: "test", tasks: ["t1"] }));
|
|
92
|
+
await writeFile(join(dir, "pipeline-config", "registry.json"), JSON.stringify({
|
|
93
|
+
pipelines: { test: { configDir, tasksDir } }
|
|
94
|
+
}));
|
|
95
|
+
const overrideFile = join(dir, "config.json");
|
|
96
|
+
await writeFile(overrideFile, JSON.stringify({ taskRunner: { maxAttempts: 0 } }));
|
|
97
|
+
const origRoot = process.env.PO_ROOT;
|
|
98
|
+
process.env.PO_ROOT = dir;
|
|
99
|
+
try {
|
|
100
|
+
await expect(loadConfig({ configPath: overrideFile })).rejects.toThrow("taskRunner.maxAttempts must be an integer >= 1");
|
|
101
|
+
} finally {
|
|
102
|
+
if (origRoot) process.env.PO_ROOT = origRoot; else delete process.env.PO_ROOT;
|
|
103
|
+
await rm(dir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("throws when taskRunner.maxAttempts is non-integer", async () => {
|
|
108
|
+
const dir = await mkdtemp(join(tmpdir(), "config-test-"));
|
|
109
|
+
const configDir = join(dir, "pipeline-config", "test");
|
|
110
|
+
const tasksDir = join(configDir, "tasks");
|
|
111
|
+
await mkdir(tasksDir, { recursive: true });
|
|
112
|
+
await writeFile(join(configDir, "pipeline.json"), JSON.stringify({ name: "test", tasks: ["t1"] }));
|
|
113
|
+
await writeFile(join(dir, "pipeline-config", "registry.json"), JSON.stringify({
|
|
114
|
+
pipelines: { test: { configDir, tasksDir } }
|
|
115
|
+
}));
|
|
116
|
+
const overrideFile = join(dir, "config.json");
|
|
117
|
+
await writeFile(overrideFile, JSON.stringify({ taskRunner: { maxAttempts: 2.5 } }));
|
|
118
|
+
const origRoot = process.env.PO_ROOT;
|
|
119
|
+
process.env.PO_ROOT = dir;
|
|
120
|
+
try {
|
|
121
|
+
await expect(loadConfig({ configPath: overrideFile })).rejects.toThrow("taskRunner.maxAttempts must be an integer >= 1");
|
|
122
|
+
} finally {
|
|
123
|
+
if (origRoot) process.env.PO_ROOT = origRoot; else delete process.env.PO_ROOT;
|
|
124
|
+
await rm(dir, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("PO_TASK_MAX_ATTEMPTS env override", () => {
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
delete process.env.PO_TASK_MAX_ATTEMPTS;
|
|
132
|
+
resetConfig();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("getConfig reads PO_TASK_MAX_ATTEMPTS into taskRunner.maxAttempts", () => {
|
|
136
|
+
process.env.PO_TASK_MAX_ATTEMPTS = "5";
|
|
137
|
+
resetConfig();
|
|
138
|
+
const config: AppConfig = getConfig();
|
|
139
|
+
expect(config.taskRunner.maxAttempts).toBe(5);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("getConfig rejects non-numeric PO_TASK_MAX_ATTEMPTS values", () => {
|
|
143
|
+
process.env.PO_TASK_MAX_ATTEMPTS = "abc";
|
|
144
|
+
resetConfig();
|
|
145
|
+
expect(() => getConfig()).toThrow("PO_TASK_MAX_ATTEMPTS must be an integer >= 1");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("getConfig rejects non-integer PO_TASK_MAX_ATTEMPTS values", () => {
|
|
149
|
+
process.env.PO_TASK_MAX_ATTEMPTS = "2.5";
|
|
150
|
+
resetConfig();
|
|
151
|
+
expect(() => getConfig()).toThrow("PO_TASK_MAX_ATTEMPTS must be an integer >= 1");
|
|
152
|
+
});
|
|
74
153
|
});
|
|
75
154
|
|
|
76
155
|
describe("getConfig", () => {
|
|
@@ -9,12 +9,14 @@ import { join } from "node:path";
|
|
|
9
9
|
// deterministic no-ops; config/validation/module-loader are replaced so we
|
|
10
10
|
// don't need a real pipelines directory on disk.
|
|
11
11
|
|
|
12
|
+
const mockGetConfig = mock(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
13
|
+
|
|
12
14
|
mock.module("../config", () => ({
|
|
13
15
|
getPipelineConfig: mock((_slug: string) => ({
|
|
14
16
|
pipelineJsonPath: "/mock/pipeline.json",
|
|
15
17
|
tasksDir: "/mock/tasks",
|
|
16
18
|
})),
|
|
17
|
-
getConfig:
|
|
19
|
+
getConfig: mockGetConfig,
|
|
18
20
|
loadConfig: mock(async () => ({})),
|
|
19
21
|
resetConfig: mock(() => {}),
|
|
20
22
|
}));
|
|
@@ -95,6 +97,7 @@ const PO_ENV_KEYS = [
|
|
|
95
97
|
"PO_TASK_REGISTRY",
|
|
96
98
|
"PO_START_FROM_TASK",
|
|
97
99
|
"PO_RUN_SINGLE_TASK",
|
|
100
|
+
"PO_TASK_MAX_ATTEMPTS",
|
|
98
101
|
] as const;
|
|
99
102
|
|
|
100
103
|
interface MultiTaskFixture {
|
|
@@ -359,3 +362,267 @@ describe("runPipelineJob — outer-catch failure surfacing", () => {
|
|
|
359
362
|
expect(stderrContainsMessage).toBe(true);
|
|
360
363
|
});
|
|
361
364
|
});
|
|
365
|
+
|
|
366
|
+
describe("runPipelineJob — bounded retry loop", () => {
|
|
367
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
368
|
+
const cleanupDirs: string[] = [];
|
|
369
|
+
let originalSleep: typeof Bun.sleep;
|
|
370
|
+
let sleepDelays: number[];
|
|
371
|
+
|
|
372
|
+
beforeEach(() => {
|
|
373
|
+
for (const key of Object.keys(savedEnv)) delete savedEnv[key];
|
|
374
|
+
for (const key of PO_ENV_KEYS) {
|
|
375
|
+
savedEnv[key] = process.env[key];
|
|
376
|
+
delete process.env[key];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
mockRunPipeline.mockClear();
|
|
380
|
+
mockEnsureTaskSymlinkBridge.mockClear();
|
|
381
|
+
mockValidateTaskSymlinks.mockClear();
|
|
382
|
+
mockRepairTaskSymlinks.mockClear();
|
|
383
|
+
mockCleanupTaskSymlinks.mockClear();
|
|
384
|
+
mockLoadFreshModule.mockClear();
|
|
385
|
+
mockGetConfig.mockReset();
|
|
386
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
387
|
+
|
|
388
|
+
sleepDelays = [];
|
|
389
|
+
originalSleep = Bun.sleep;
|
|
390
|
+
(Bun as unknown as { sleep: (ms: number) => Promise<void> }).sleep = async (ms: number) => {
|
|
391
|
+
sleepDelays.push(ms);
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
afterEach(async () => {
|
|
396
|
+
(Bun as unknown as { sleep: typeof Bun.sleep }).sleep = originalSleep;
|
|
397
|
+
|
|
398
|
+
for (const key of PO_ENV_KEYS) {
|
|
399
|
+
if (savedEnv[key] === undefined) {
|
|
400
|
+
delete process.env[key];
|
|
401
|
+
} else {
|
|
402
|
+
process.env[key] = savedEnv[key];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
process.exitCode = 0;
|
|
406
|
+
|
|
407
|
+
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
function makeFailureResult() {
|
|
411
|
+
return {
|
|
412
|
+
ok: false as const,
|
|
413
|
+
failedStage: "generate",
|
|
414
|
+
error: {
|
|
415
|
+
name: "TaskFailure",
|
|
416
|
+
message: "stub failure",
|
|
417
|
+
stack: "stack",
|
|
418
|
+
debug: { stage: "generate", logPath: "/tmp/log" },
|
|
419
|
+
},
|
|
420
|
+
logs: [{ stage: "generate", ok: false as const, ms: 5, error: "stub" }],
|
|
421
|
+
context: {} as Record<string, unknown>,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function makeSuccessResult() {
|
|
426
|
+
return {
|
|
427
|
+
ok: true as const,
|
|
428
|
+
logs: [{ stage: "generate", ok: true as const, ms: 5 }],
|
|
429
|
+
context: {} as Record<string, unknown>,
|
|
430
|
+
llmMetrics: [],
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
test("maxAttempts: 1 — failing task runs once and exits non-zero", async () => {
|
|
435
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 1 } }));
|
|
436
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
437
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
438
|
+
|
|
439
|
+
mockRunPipeline.mockImplementation(async () => makeFailureResult() as never);
|
|
440
|
+
|
|
441
|
+
const exitCalls: Array<number | undefined> = [];
|
|
442
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
443
|
+
exitCalls.push(code);
|
|
444
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
445
|
+
}) as typeof process.exit);
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await runPipelineJob(fixture.jobId);
|
|
449
|
+
} catch (e) {
|
|
450
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
451
|
+
} finally {
|
|
452
|
+
exitSpy.mockRestore();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
expect(mockRunPipeline.mock.calls.length).toBe(1);
|
|
456
|
+
expect(sleepDelays).toEqual([]);
|
|
457
|
+
expect(exitCalls).toContain(1);
|
|
458
|
+
|
|
459
|
+
const statusText = await readFile(join(fixture.jobDir, "tasks-status.json"), "utf-8");
|
|
460
|
+
const status = JSON.parse(statusText) as {
|
|
461
|
+
tasks: Record<string, { state?: string; attempts?: number; restartCount?: number }>;
|
|
462
|
+
};
|
|
463
|
+
expect(status.tasks["task-a"]?.state).toBe("failed");
|
|
464
|
+
expect(status.tasks["task-a"]?.restartCount).toBeUndefined();
|
|
465
|
+
expect(status.tasks["task-a"]?.attempts).toBe(1);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("maxAttempts: 3 — fails twice then succeeds: three calls, restartCount=2, exits zero", async () => {
|
|
469
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
470
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
471
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
472
|
+
|
|
473
|
+
let call = 0;
|
|
474
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
475
|
+
call += 1;
|
|
476
|
+
return (call <= 2 ? makeFailureResult() : makeSuccessResult()) as never;
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const exitCalls: Array<number | undefined> = [];
|
|
480
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
481
|
+
exitCalls.push(code);
|
|
482
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
483
|
+
}) as typeof process.exit);
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
await runPipelineJob(fixture.jobId);
|
|
487
|
+
} catch (e) {
|
|
488
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
489
|
+
} finally {
|
|
490
|
+
exitSpy.mockRestore();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
expect(mockRunPipeline.mock.calls.length).toBe(3);
|
|
494
|
+
expect(sleepDelays).toEqual([2000, 4000]);
|
|
495
|
+
expect(exitCalls).toEqual([]);
|
|
496
|
+
|
|
497
|
+
const statusText = await readFile(fixture.statusPath, "utf-8");
|
|
498
|
+
const status = JSON.parse(statusText) as {
|
|
499
|
+
tasks: Record<string, { state?: string; attempts?: number; restartCount?: number }>;
|
|
500
|
+
};
|
|
501
|
+
expect(status.tasks["task-a"]?.state).toBe("done");
|
|
502
|
+
expect(status.tasks["task-a"]?.attempts).toBe(3);
|
|
503
|
+
expect(status.tasks["task-a"]?.restartCount).toBe(2);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("maxAttempts: 3 — always fails: three calls, restartCount=2, exits non-zero", async () => {
|
|
507
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
508
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
509
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
510
|
+
|
|
511
|
+
mockRunPipeline.mockImplementation(async () => makeFailureResult() as never);
|
|
512
|
+
|
|
513
|
+
const exitCalls: Array<number | undefined> = [];
|
|
514
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
515
|
+
exitCalls.push(code);
|
|
516
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
517
|
+
}) as typeof process.exit);
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
await runPipelineJob(fixture.jobId);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
523
|
+
} finally {
|
|
524
|
+
exitSpy.mockRestore();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
expect(mockRunPipeline.mock.calls.length).toBe(3);
|
|
528
|
+
expect(sleepDelays).toEqual([2000, 4000]);
|
|
529
|
+
expect(exitCalls).toContain(1);
|
|
530
|
+
|
|
531
|
+
const statusText = await readFile(join(fixture.jobDir, "tasks-status.json"), "utf-8");
|
|
532
|
+
const status = JSON.parse(statusText) as {
|
|
533
|
+
tasks: Record<string, { state?: string; attempts?: number; restartCount?: number }>;
|
|
534
|
+
};
|
|
535
|
+
expect(status.tasks["task-a"]?.state).toBe("failed");
|
|
536
|
+
expect(status.tasks["task-a"]?.attempts).toBe(3);
|
|
537
|
+
expect(status.tasks["task-a"]?.restartCount).toBe(2);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("interim status between attempts: state=running, no failedStage/error, restartCount incremented", async () => {
|
|
541
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
542
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
543
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
544
|
+
|
|
545
|
+
let call = 0;
|
|
546
|
+
let interimSnapshot: { state?: string; attempts?: number; failedStage?: unknown; error?: unknown; restartCount?: number } | undefined;
|
|
547
|
+
|
|
548
|
+
// Capture the snapshot from disk *during* the second call (after the first failure
|
|
549
|
+
// and the interim writeJobStatus). At call #2 we read tasks-status.json, then
|
|
550
|
+
// return success so the test ends cleanly.
|
|
551
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
552
|
+
call += 1;
|
|
553
|
+
if (call === 2) {
|
|
554
|
+
const text = await readFile(join(fixture.jobDir, "tasks-status.json"), "utf-8");
|
|
555
|
+
const parsed = JSON.parse(text) as {
|
|
556
|
+
tasks: Record<string, { state?: string; attempts?: number; failedStage?: unknown; error?: unknown; restartCount?: number }>;
|
|
557
|
+
};
|
|
558
|
+
interimSnapshot = parsed.tasks["task-a"];
|
|
559
|
+
return makeSuccessResult() as never;
|
|
560
|
+
}
|
|
561
|
+
return makeFailureResult() as never;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
565
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
566
|
+
}) as typeof process.exit);
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
await runPipelineJob(fixture.jobId);
|
|
570
|
+
} catch (e) {
|
|
571
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
572
|
+
} finally {
|
|
573
|
+
exitSpy.mockRestore();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
expect(interimSnapshot).toBeDefined();
|
|
577
|
+
expect(interimSnapshot?.state).toBe("running");
|
|
578
|
+
expect(interimSnapshot?.failedStage).toBeUndefined();
|
|
579
|
+
expect(interimSnapshot?.error).toBeUndefined();
|
|
580
|
+
expect(interimSnapshot?.attempts).toBe(2);
|
|
581
|
+
expect(interimSnapshot?.restartCount).toBe(1);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("missing taskRunner config falls back to the default retry cap", async () => {
|
|
585
|
+
mockGetConfig.mockImplementation(() => ({} as never));
|
|
586
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
587
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
588
|
+
|
|
589
|
+
let call = 0;
|
|
590
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
591
|
+
call += 1;
|
|
592
|
+
return (call === 1 ? makeFailureResult() : makeSuccessResult()) as never;
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
await runPipelineJob(fixture.jobId);
|
|
596
|
+
|
|
597
|
+
expect(mockRunPipeline.mock.calls.length).toBe(2);
|
|
598
|
+
expect(sleepDelays).toEqual([2000]);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("exceptions from runPipeline bypass result retries and surface through outer failure handling", async () => {
|
|
602
|
+
mockGetConfig.mockImplementation(() => ({ taskRunner: { maxAttempts: 3 } }));
|
|
603
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
604
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
605
|
+
|
|
606
|
+
mockRunPipeline.mockImplementation(async () => {
|
|
607
|
+
throw new Error("task module exploded");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const exitCalls: Array<number | undefined> = [];
|
|
611
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
612
|
+
exitCalls.push(code);
|
|
613
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
614
|
+
}) as typeof process.exit);
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
await runPipelineJob(fixture.jobId);
|
|
618
|
+
} catch (e) {
|
|
619
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
620
|
+
} finally {
|
|
621
|
+
exitSpy.mockRestore();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
expect(mockRunPipeline.mock.calls.length).toBe(1);
|
|
625
|
+
expect(sleepDelays).toEqual([]);
|
|
626
|
+
expect(exitCalls).toContain(1);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
@@ -91,11 +91,10 @@ describe("task-runner does not write job-level status fields", () => {
|
|
|
91
91
|
|
|
92
92
|
const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
|
|
93
93
|
|
|
94
|
-
//
|
|
94
|
+
// Lifecycle fields are owned by pipeline-runner; task-runner persists progress only.
|
|
95
95
|
expect(status.state).toBe("pending");
|
|
96
96
|
expect(status.current).toBeNull();
|
|
97
97
|
expect(status.currentStage).toBeNull();
|
|
98
|
-
expect(status.progress).toBe(100);
|
|
99
98
|
});
|
|
100
99
|
|
|
101
100
|
it("does not set snapshot.state on task failure", async () => {
|