@ryanfw/prompt-orchestration-pipeline 1.2.6 → 1.2.7

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.
@@ -0,0 +1,361 @@
1
+ import { describe, test, expect, mock, spyOn, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtemp, writeFile, mkdir, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // ─── Mocks: NON-target externals only ─────────────────────────────────────────
7
+ // We leave status-writer and lifecycle-policy REAL so the stale-snapshot bug
8
+ // manifests end-to-end. runPipeline and symlink helpers are replaced with
9
+ // deterministic no-ops; config/validation/module-loader are replaced so we
10
+ // don't need a real pipelines directory on disk.
11
+
12
+ mock.module("../config", () => ({
13
+ getPipelineConfig: mock((_slug: string) => ({
14
+ pipelineJsonPath: "/mock/pipeline.json",
15
+ tasksDir: "/mock/tasks",
16
+ })),
17
+ getConfig: mock(() => ({})),
18
+ loadConfig: mock(async () => ({})),
19
+ resetConfig: mock(() => {}),
20
+ }));
21
+
22
+ mock.module("../validation", () => ({
23
+ validatePipelineOrThrow: mock((_pipeline: unknown, _pathHint?: string) => {}),
24
+ }));
25
+
26
+ const mockLoadFreshModule = mock(async (_path: string) => ({
27
+ default: {} as Record<string, string>,
28
+ }));
29
+
30
+ mock.module("../module-loader", () => ({
31
+ loadFreshModule: mockLoadFreshModule,
32
+ }));
33
+
34
+ const mockRunPipeline = mock(async (_modulePath: string, _ctx: unknown) => ({
35
+ ok: true as const,
36
+ logs: [{ stage: "generate", ok: true as const, ms: 10 }],
37
+ context: {} as Record<string, unknown>,
38
+ llmMetrics: [],
39
+ }));
40
+
41
+ mock.module("../task-runner", () => ({
42
+ runPipeline: mockRunPipeline,
43
+ }));
44
+
45
+ const mockEnsureTaskSymlinkBridge = mock(
46
+ async (_workDir: string, _taskName: string, _registryDir: string, modulePath: string) => ({
47
+ relocatedEntryPath: modulePath,
48
+ }),
49
+ );
50
+
51
+ mock.module("../symlink-bridge", () => ({
52
+ ensureTaskSymlinkBridge: mockEnsureTaskSymlinkBridge,
53
+ }));
54
+
55
+ const mockValidateTaskSymlinks = mock(async () => true);
56
+ const mockRepairTaskSymlinks = mock(async () => {});
57
+ const mockCleanupTaskSymlinks = mock(async () => {});
58
+
59
+ mock.module("../symlink-utils", () => ({
60
+ validateTaskSymlinks: mockValidateTaskSymlinks,
61
+ repairTaskSymlinks: mockRepairTaskSymlinks,
62
+ cleanupTaskSymlinks: mockCleanupTaskSymlinks,
63
+ }));
64
+
65
+ // Silence the job-level logger so test output stays readable.
66
+ const quietLogger = {
67
+ debug: mock(() => {}),
68
+ log: mock(() => {}),
69
+ warn: mock(() => {}),
70
+ error: mock(() => {}),
71
+ group: mock(() => {}),
72
+ groupEnd: mock(() => {}),
73
+ sse: mock(() => {}),
74
+ };
75
+
76
+ mock.module("../logger", () => ({
77
+ createJobLogger: mock(() => quietLogger),
78
+ createLogger: mock(() => quietLogger),
79
+ createTaskLogger: mock(() => quietLogger),
80
+ }));
81
+
82
+ // ─── Import the module under test (after all mock.module calls) ───────────────
83
+
84
+ import { runPipelineJob } from "../pipeline-runner";
85
+
86
+ // ─── Test fixtures and helpers ────────────────────────────────────────────────
87
+
88
+ const PO_ENV_KEYS = [
89
+ "PO_ROOT",
90
+ "PO_DATA_DIR",
91
+ "PO_CURRENT_DIR",
92
+ "PO_COMPLETE_DIR",
93
+ "PO_PIPELINE_PATH",
94
+ "PO_PIPELINE_SLUG",
95
+ "PO_TASK_REGISTRY",
96
+ "PO_START_FROM_TASK",
97
+ "PO_RUN_SINGLE_TASK",
98
+ ] as const;
99
+
100
+ interface MultiTaskFixture {
101
+ tmpDir: string;
102
+ jobId: string;
103
+ jobDir: string;
104
+ completeDir: string;
105
+ statusPath: string;
106
+ }
107
+
108
+ async function setupMultiTaskFixture(taskNames: string[]): Promise<MultiTaskFixture> {
109
+ const tmpDir = await mkdtemp(join(tmpdir(), "pipeline-runner-regression-"));
110
+ const jobId = "job-regression";
111
+ const currentDir = join(tmpDir, "current");
112
+ const completeDir = join(tmpDir, "complete");
113
+ const jobDir = join(currentDir, jobId);
114
+ const pipelineDir = join(tmpDir, "pipeline");
115
+
116
+ await mkdir(jobDir, { recursive: true });
117
+ await mkdir(completeDir, { recursive: true });
118
+ await mkdir(pipelineDir, { recursive: true });
119
+
120
+ await writeFile(join(jobDir, "seed.json"), JSON.stringify({ pipeline: "test-pipeline" }));
121
+
122
+ const tasks: Record<string, { state: string }> = {};
123
+ for (const name of taskNames) {
124
+ tasks[name] = { state: "pending" };
125
+ }
126
+ await writeFile(
127
+ join(jobDir, "tasks-status.json"),
128
+ JSON.stringify({
129
+ id: jobId,
130
+ state: "pending",
131
+ current: null,
132
+ currentStage: null,
133
+ lastUpdated: new Date().toISOString(),
134
+ tasks,
135
+ files: { artifacts: [], logs: [], tmp: [] },
136
+ }),
137
+ );
138
+
139
+ await writeFile(join(pipelineDir, "pipeline.json"), JSON.stringify({ tasks: taskNames }));
140
+
141
+ process.env["PO_ROOT"] = tmpDir;
142
+ process.env["PO_DATA_DIR"] = ".";
143
+ process.env["PO_CURRENT_DIR"] = currentDir;
144
+ process.env["PO_COMPLETE_DIR"] = completeDir;
145
+ process.env["PO_PIPELINE_PATH"] = join(pipelineDir, "pipeline.json");
146
+ process.env["PO_TASK_REGISTRY"] = join(pipelineDir, "tasks", "index.js");
147
+
148
+ mockLoadFreshModule.mockImplementation(async (_path: string) => ({
149
+ default: Object.fromEntries(taskNames.map((n) => [n, `./${n}.js`])),
150
+ }));
151
+
152
+ return { tmpDir, jobId, jobDir, completeDir, statusPath: join(completeDir, jobId, "tasks-status.json") };
153
+ }
154
+
155
+ describe("runPipelineJob — multi-task success regression", () => {
156
+ const savedEnv: Record<string, string | undefined> = {};
157
+ const cleanupDirs: string[] = [];
158
+
159
+ beforeEach(() => {
160
+ for (const key of PO_ENV_KEYS) {
161
+ savedEnv[key] = process.env[key];
162
+ delete process.env[key];
163
+ }
164
+
165
+ mockRunPipeline.mockClear();
166
+ mockRunPipeline.mockImplementation(async (_modulePath: string, _ctx: unknown) => ({
167
+ ok: true as const,
168
+ logs: [{ stage: "generate", ok: true as const, ms: 10 }],
169
+ context: {} as Record<string, unknown>,
170
+ llmMetrics: [],
171
+ }));
172
+ mockEnsureTaskSymlinkBridge.mockClear();
173
+ mockValidateTaskSymlinks.mockClear();
174
+ mockRepairTaskSymlinks.mockClear();
175
+ mockCleanupTaskSymlinks.mockClear();
176
+ mockLoadFreshModule.mockClear();
177
+ });
178
+
179
+ afterEach(async () => {
180
+ for (const key of PO_ENV_KEYS) {
181
+ if (savedEnv[key] === undefined) {
182
+ delete process.env[key];
183
+ } else {
184
+ process.env[key] = savedEnv[key];
185
+ }
186
+ }
187
+ process.exitCode = 0;
188
+
189
+ await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
190
+ });
191
+
192
+ test("two-task successful run: both tasks end in state 'done'", async () => {
193
+ const fixture = await setupMultiTaskFixture(["task-a", "task-b"]);
194
+ cleanupDirs.push(fixture.tmpDir);
195
+
196
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
197
+ throw new Error(`process.exit called with ${String(code)}`);
198
+ }) as typeof process.exit);
199
+
200
+ try {
201
+ await runPipelineJob(fixture.jobId);
202
+ } finally {
203
+ exitSpy.mockRestore();
204
+ }
205
+
206
+ const statusText = await readFile(fixture.statusPath, "utf-8");
207
+ const status = JSON.parse(statusText) as {
208
+ tasks: Record<string, { state?: string }>;
209
+ };
210
+ expect(status.tasks["task-a"]?.state).toBe("done");
211
+ expect(status.tasks["task-b"]?.state).toBe("done");
212
+ });
213
+
214
+ test("three-task successful run: all three tasks end in state 'done'", async () => {
215
+ const fixture = await setupMultiTaskFixture(["task-a", "task-b", "task-c"]);
216
+ cleanupDirs.push(fixture.tmpDir);
217
+
218
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
219
+ throw new Error(`process.exit called with ${String(code)}`);
220
+ }) as typeof process.exit);
221
+
222
+ try {
223
+ await runPipelineJob(fixture.jobId);
224
+ } finally {
225
+ exitSpy.mockRestore();
226
+ }
227
+
228
+ const statusText = await readFile(fixture.statusPath, "utf-8");
229
+ const status = JSON.parse(statusText) as {
230
+ tasks: Record<string, { state?: string }>;
231
+ };
232
+ expect(status.tasks["task-a"]?.state).toBe("done");
233
+ expect(status.tasks["task-b"]?.state).toBe("done");
234
+ expect(status.tasks["task-c"]?.state).toBe("done");
235
+ });
236
+
237
+ test("multi-task success does not throw a lifecycle-policy block error", async () => {
238
+ const fixture = await setupMultiTaskFixture(["task-a", "task-b", "task-c"]);
239
+ cleanupDirs.push(fixture.tmpDir);
240
+
241
+ const exitCalls: Array<number | undefined> = [];
242
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
243
+ exitCalls.push(code);
244
+ throw new Error(`process.exit called with ${String(code)}`);
245
+ }) as typeof process.exit);
246
+
247
+ const consoleErrorMessages: unknown[][] = [];
248
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => {
249
+ consoleErrorMessages.push(args);
250
+ });
251
+
252
+ try {
253
+ await runPipelineJob(fixture.jobId);
254
+ } catch {
255
+ // If the bug throws, runPipelineJob's outer catch will call process.exit,
256
+ // which our spy converts to a throw. Swallow here so we can inspect the
257
+ // captured console.error output below.
258
+ } finally {
259
+ exitSpy.mockRestore();
260
+ consoleErrorSpy.mockRestore();
261
+ }
262
+
263
+ const anyLifecycleBlock = consoleErrorMessages.some((args) =>
264
+ args.some((a) => {
265
+ if (a instanceof Error) return /Lifecycle policy blocked task start/.test(a.message);
266
+ if (typeof a === "string") return /Lifecycle policy blocked task start/.test(a);
267
+ return false;
268
+ }),
269
+ );
270
+ expect(anyLifecycleBlock).toBe(false);
271
+ expect(exitCalls).toEqual([]);
272
+ });
273
+ });
274
+
275
+ describe("runPipelineJob — outer-catch failure surfacing", () => {
276
+ const savedEnv: Record<string, string | undefined> = {};
277
+ const cleanupDirs: string[] = [];
278
+
279
+ beforeEach(() => {
280
+ for (const key of Object.keys(savedEnv)) delete savedEnv[key];
281
+ for (const key of PO_ENV_KEYS) {
282
+ savedEnv[key] = process.env[key];
283
+ delete process.env[key];
284
+ }
285
+
286
+ mockRunPipeline.mockClear();
287
+ mockEnsureTaskSymlinkBridge.mockClear();
288
+ mockValidateTaskSymlinks.mockClear();
289
+ mockRepairTaskSymlinks.mockClear();
290
+ mockCleanupTaskSymlinks.mockClear();
291
+ mockLoadFreshModule.mockClear();
292
+ });
293
+
294
+ afterEach(async () => {
295
+ for (const key of PO_ENV_KEYS) {
296
+ if (savedEnv[key] === undefined) {
297
+ delete process.env[key];
298
+ } else {
299
+ process.env[key] = savedEnv[key];
300
+ }
301
+ }
302
+ process.exitCode = 0;
303
+
304
+ await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
305
+ });
306
+
307
+ test("unhandled error: sets exitCode=1, writes orchestrator failure log, stderr includes message", async () => {
308
+ const fixture = await setupMultiTaskFixture(["task-a"]);
309
+ cleanupDirs.push(fixture.tmpDir);
310
+
311
+ const injectedMessage = "injected-outer-catch-failure";
312
+ mockLoadFreshModule.mockImplementation(async (_path: string) => {
313
+ throw new Error(injectedMessage);
314
+ });
315
+
316
+ const exitCalls: Array<number | undefined> = [];
317
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
318
+ exitCalls.push(code);
319
+ throw new Error(`__test_exit__:${String(code)}`);
320
+ }) as typeof process.exit);
321
+
322
+ const fakeTimer = { unref: () => fakeTimer, ref: () => fakeTimer };
323
+ const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(
324
+ ((() => fakeTimer as unknown as ReturnType<typeof setTimeout>) as unknown) as typeof setTimeout,
325
+ );
326
+
327
+ const consoleErrorMessages: unknown[][] = [];
328
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => {
329
+ consoleErrorMessages.push(args);
330
+ });
331
+
332
+ try {
333
+ await runPipelineJob(fixture.jobId);
334
+ } catch (e) {
335
+ if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
336
+ } finally {
337
+ exitSpy.mockRestore();
338
+ setTimeoutSpy.mockRestore();
339
+ consoleErrorSpy.mockRestore();
340
+ }
341
+
342
+ expect(process.exitCode).toBe(1);
343
+ expect(exitCalls).toContain(1);
344
+
345
+ const failurePath = join(
346
+ fixture.jobDir,
347
+ "files",
348
+ "logs",
349
+ "orchestrator-runPipelineJob-failure-details.json",
350
+ );
351
+ const failureText = await readFile(failurePath, "utf-8");
352
+ const failure = JSON.parse(failureText) as { message?: unknown };
353
+ expect(typeof failure.message).toBe("string");
354
+ expect(failure.message).toContain(injectedMessage);
355
+
356
+ const stderrContainsMessage = consoleErrorMessages.some((args) =>
357
+ args.some((a) => typeof a === "string" && a.includes(injectedMessage)),
358
+ );
359
+ expect(stderrContainsMessage).toBe(true);
360
+ });
361
+ });
@@ -60,7 +60,7 @@ describe("runPipeline log tracking", () => {
60
60
  });
61
61
 
62
62
  describe("task-runner does not write job-level status fields", () => {
63
- it("does not set snapshot.state, snapshot.current, snapshot.currentStage, or snapshot.progress on success", async () => {
63
+ it("does not set snapshot.state, snapshot.current, or snapshot.currentStage on success", async () => {
64
64
  const root = await makeTempRoot();
65
65
  const workDir = path.join(root, "job-1");
66
66
  await mkdir(workDir, { recursive: true });
@@ -91,11 +91,11 @@ 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
- // Job-level fields must remain untouched by task-runner
94
+ // Job-level lifecycle fields must remain untouched by task-runner.
95
95
  expect(status.state).toBe("pending");
96
96
  expect(status.current).toBeNull();
97
97
  expect(status.currentStage).toBeNull();
98
- expect(status.progress).toBeUndefined();
98
+ expect(status.progress).toBe(100);
99
99
  });
100
100
 
101
101
  it("does not set snapshot.state on task failure", async () => {
@@ -268,9 +268,6 @@ export async function runPipelineJob(jobId: string): Promise<void> {
268
268
  const pipeline = await loadPipeline(config.pipelineJsonPath);
269
269
  const taskRegistry = await loadTaskRegistry(config.taskRegistryPath);
270
270
 
271
- const statusText = await Bun.file(config.statusPath).text();
272
- const status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
273
-
274
271
  const { startFromTask, runSingleTask } = config;
275
272
 
276
273
  // ─── Validate startFromTask / runSingleTask config ───────────────────────
@@ -294,6 +291,9 @@ export async function runPipelineJob(jobId: string): Promise<void> {
294
291
  for (const task of pipeline.tasks) {
295
292
  const taskName = getTaskName(task);
296
293
 
294
+ const statusText = await Bun.file(config.statusPath).text();
295
+ const status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
296
+
297
297
  // Skip tasks before startFromTask
298
298
  if (!reachedStartFrom) {
299
299
  if (taskName === startFromTask) {
@@ -485,9 +485,32 @@ export async function runPipelineJob(jobId: string): Promise<void> {
485
485
  await completeJob(config, finalStatus, pipelineArtifacts);
486
486
  }
487
487
  } catch (err) {
488
- console.error("Unhandled error in runPipelineJob:", err);
488
+ const normalized = normalizeError(err);
489
+ console.error(normalized.message);
489
490
  if (workDir !== undefined) {
490
- await cleanupPidFile(workDir);
491
+ try {
492
+ const failureIO = createTaskFileIO({
493
+ workDir,
494
+ taskName: "orchestrator",
495
+ trackTaskFiles: false,
496
+ getStage: () => "runPipelineJob",
497
+ statusPath: join(workDir, "tasks-status.json"),
498
+ });
499
+ const failureLogName = generateLogName(
500
+ "orchestrator",
501
+ "runPipelineJob",
502
+ LogEvent.FAILURE_DETAILS,
503
+ LogFileExtension.JSON,
504
+ );
505
+ await failureIO.writeLog(failureLogName, JSON.stringify(normalized, null, 2));
506
+ } catch {
507
+ // Do not mask the original failure if log-write fails
508
+ }
509
+ try {
510
+ await cleanupPidFile(workDir);
511
+ } catch {
512
+ // Do not mask the original failure if PID cleanup fails
513
+ }
491
514
  }
492
515
  process.exitCode = 1;
493
516
  setTimeout(() => process.exit(1), 5000).unref();
@@ -4,7 +4,7 @@
4
4
  import { mkdir } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { KNOWN_STAGES } from "./progress";
7
+ import { KNOWN_STAGES, computeDeterministicProgress } from "./progress";
8
8
  import type { StageName } from "./progress";
9
9
  import { createTaskFileIO, generateLogName, trackFile } from "./file-io";
10
10
  import type { TaskFileIO } from "./file-io";
@@ -795,17 +795,14 @@ export async function runPipeline(
795
795
 
796
796
  // Write done status (best-effort)
797
797
  try {
798
- const lastStage = KNOWN_STAGES[KNOWN_STAGES.length - 1];
798
+ const lastStage = KNOWN_STAGES[KNOWN_STAGES.length - 1]!;
799
799
  const doneProgress = computeDeterministicProgress(
800
800
  pipelineTasks ?? [taskName],
801
801
  taskName,
802
802
  lastStage,
803
803
  );
804
804
  await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
805
- snapshot.state = TaskState.DONE;
806
805
  snapshot.progress = doneProgress;
807
- snapshot.current = null;
808
- snapshot.currentStage = null;
809
806
  if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
810
807
  snapshot.tasks[taskName]!.state = TaskState.DONE;
811
808
  snapshot.tasks[taskName]!.currentStage = null;
@@ -165,8 +165,8 @@ describe("isRetryableError", () => {
165
165
  });
166
166
 
167
167
  describe("DEFAULT_REQUEST_TIMEOUT_MS", () => {
168
- it("is 120 000 ms", () => {
169
- expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(120_000);
168
+ it("is 3 600 000 ms", () => {
169
+ expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(3_600_000);
170
170
  });
171
171
  });
172
172
 
@@ -352,7 +352,7 @@ describe("openaiChat", () => {
352
352
  organization: "org_test",
353
353
  baseURL: "https://example.test/v1",
354
354
  maxRetries: 0,
355
- timeout: 120_000,
355
+ timeout: 3_600_000,
356
356
  });
357
357
  });
358
358