@ryanfw/prompt-orchestration-pipeline 1.2.11 → 1.2.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.2.11",
3
+ "version": "1.2.12",
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",
@@ -3,7 +3,13 @@ import { mkdtemp, mkdir, writeFile, utimes, rm, readdir, readFile } from "node:f
3
3
  import { existsSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { drainPendingQueue, handleChildExit } from "../orchestrator";
6
+ import {
7
+ drainPendingQueue,
8
+ handleChildExit,
9
+ resolveDirs,
10
+ promoteSeedFile,
11
+ restoreSeedFile,
12
+ } from "../orchestrator";
7
13
  import {
8
14
  getConcurrencyRuntimePaths,
9
15
  getJobConcurrencyStatus,
@@ -38,6 +44,81 @@ function fakeSpawnRunner(jobIdToPid: Map<string, number>): (jobId: string) => Pr
38
44
  };
39
45
  }
40
46
 
47
+ describe("resolveDirs", () => {
48
+ test("returns a staging dir under the pipeline-data root", () => {
49
+ expect(resolveDirs("/x/pipeline-data").staging).toBe("/x/pipeline-data/staging");
50
+ });
51
+ });
52
+
53
+ describe("promoteSeedFile", () => {
54
+ test("moves staged artifacts into current/files/artifacts (byte-identical) then the seed, removing staging", async () => {
55
+ const dir = await setupDir("promote-staged-");
56
+ try {
57
+ const jobId = "job-promote";
58
+ const stagingJobDir = join(dir, "staging", jobId);
59
+ const currentJobDir = join(dir, "current", jobId);
60
+ const bytes = new Uint8Array(256);
61
+ for (let i = 0; i < 256; i++) bytes[i] = i;
62
+ await mkdir(join(stagingJobDir, "a"), { recursive: true });
63
+ await writeFile(join(stagingJobDir, "top.txt"), "top");
64
+ await writeFile(join(stagingJobDir, "a", "b.bin"), bytes);
65
+ const seedPath = await writeSeed(dir, jobId, { pipeline: "p" }, 1700000000);
66
+
67
+ await promoteSeedFile(seedPath, currentJobDir, stagingJobDir);
68
+
69
+ expect(existsSync(join(currentJobDir, "seed.json"))).toBe(true);
70
+ expect(await readFile(join(currentJobDir, "files", "artifacts", "top.txt"), "utf-8")).toBe("top");
71
+ const readBytes = new Uint8Array(await readFile(join(currentJobDir, "files", "artifacts", "a", "b.bin")));
72
+ expect(Array.from(readBytes)).toEqual(Array.from(bytes));
73
+ expect(existsSync(stagingJobDir)).toBe(false);
74
+ expect(existsSync(seedPath)).toBe(false);
75
+ } finally {
76
+ await rm(dir, { recursive: true });
77
+ }
78
+ });
79
+
80
+ test("with no staging dir, produces only current/seed.json (no files/artifacts)", async () => {
81
+ const dir = await setupDir("promote-nostage-");
82
+ try {
83
+ const jobId = "job-nostage";
84
+ const currentJobDir = join(dir, "current", jobId);
85
+ const stagingJobDir = join(dir, "staging", jobId);
86
+ const seedPath = await writeSeed(dir, jobId, { pipeline: "p" }, 1700000000);
87
+
88
+ await promoteSeedFile(seedPath, currentJobDir, stagingJobDir);
89
+
90
+ expect(existsSync(join(currentJobDir, "seed.json"))).toBe(true);
91
+ expect(existsSync(join(currentJobDir, "files"))).toBe(false);
92
+ } finally {
93
+ await rm(dir, { recursive: true });
94
+ }
95
+ });
96
+ });
97
+
98
+ describe("restoreSeedFile", () => {
99
+ test("returns artifacts to staging, seed to pending, and removes current/", async () => {
100
+ const dir = await setupDir("restore-");
101
+ try {
102
+ const jobId = "job-restore";
103
+ const currentJobDir = join(dir, "current", jobId);
104
+ const stagingJobDir = join(dir, "staging", jobId);
105
+ const pendingSeedPath = join(dir, "pending", `${jobId}-seed.json`);
106
+ await mkdir(join(dir, "staging"), { recursive: true });
107
+ await mkdir(join(currentJobDir, "files", "artifacts"), { recursive: true });
108
+ await writeFile(join(currentJobDir, "files", "artifacts", "x.bin"), "data");
109
+ await writeFile(join(currentJobDir, "seed.json"), JSON.stringify({ pipeline: "p" }));
110
+
111
+ await restoreSeedFile(currentJobDir, pendingSeedPath, stagingJobDir);
112
+
113
+ expect(existsSync(pendingSeedPath)).toBe(true);
114
+ expect(await readFile(join(stagingJobDir, "x.bin"), "utf-8")).toBe("data");
115
+ expect(existsSync(currentJobDir)).toBe(false);
116
+ } finally {
117
+ await rm(dir, { recursive: true });
118
+ }
119
+ });
120
+ });
121
+
41
122
  describe("drainPendingQueue", () => {
42
123
  test("with limit 2 and three seeds, promotes exactly two and one remains in pending", async () => {
43
124
  const dir = await setupDir("drain-limit2-");
@@ -88,12 +169,13 @@ describe("drainPendingQueue", () => {
88
169
  }
89
170
  });
90
171
 
91
- test("seed whose current/<jobId>/ already exists is not promoted, slot is released, seed remains in pending", async () => {
92
- const dir = await setupDir("drain-existing-");
172
+ test("AC5: seed whose current/<jobId>/seed.json already exists is skipped, slot released, seed remains in pending", async () => {
173
+ const dir = await setupDir("drain-existing-seed-");
93
174
  try {
94
175
  await writeSeed(dir, "job-existing", { pipeline: "p" }, 1700000000);
95
176
  await writeSeed(dir, "job-fresh", { pipeline: "p" }, 1700000100);
96
177
  await mkdir(join(dir, "current", "job-existing"), { recursive: true });
178
+ await writeFile(join(dir, "current", "job-existing", "seed.json"), JSON.stringify({ pipeline: "p" }));
97
179
  const pids = new Map<string, number>();
98
180
  const result = await drainPendingQueue({
99
181
  dataDir: dir,
@@ -102,6 +184,7 @@ describe("drainPendingQueue", () => {
102
184
  spawnRunner: fakeSpawnRunner(pids),
103
185
  });
104
186
  expect(result.promoted).toEqual(["job-fresh"]);
187
+ expect(pids.has("job-existing")).toBe(false);
105
188
  const status = await getJobConcurrencyStatus(dir, 5, 1000);
106
189
  expect(status.activeJobs.map((l) => l.jobId).sort()).toEqual(["job-fresh"]);
107
190
  expect(existsSync(join(dir, "pending", "job-existing-seed.json"))).toBe(true);
@@ -112,6 +195,55 @@ describe("drainPendingQueue", () => {
112
195
  }
113
196
  });
114
197
 
198
+ test("AC3: seed with staged artifacts (current/ absent) promotes, moves artifacts, spawns once", async () => {
199
+ const dir = await setupDir("drain-staged-");
200
+ try {
201
+ await writeSeed(dir, "job-staged", { pipeline: "p" }, 1700000000);
202
+ await mkdir(join(dir, "staging", "job-staged"), { recursive: true });
203
+ await writeFile(join(dir, "staging", "job-staged", "notes.md"), "hello");
204
+ const pids = new Map<string, number>();
205
+ const result = await drainPendingQueue({
206
+ dataDir: dir,
207
+ maxConcurrentJobs: 5,
208
+ lockTimeoutMs: 1000,
209
+ spawnRunner: fakeSpawnRunner(pids),
210
+ });
211
+ expect(result.promoted).toEqual(["job-staged"]);
212
+ expect(pids.has("job-staged")).toBe(true);
213
+ expect(existsSync(join(dir, "pending", "job-staged-seed.json"))).toBe(false);
214
+ expect(existsSync(join(dir, "current", "job-staged", "seed.json"))).toBe(true);
215
+ expect(
216
+ await readFile(join(dir, "current", "job-staged", "files", "artifacts", "notes.md"), "utf-8"),
217
+ ).toBe("hello");
218
+ expect(existsSync(join(dir, "staging", "job-staged"))).toBe(false);
219
+ } finally {
220
+ await rm(dir, { recursive: true });
221
+ }
222
+ });
223
+
224
+ test("AC6: crash-mid-promote self-heal — artifacts present, seed absent, no staging → promotes", async () => {
225
+ const dir = await setupDir("drain-selfheal-");
226
+ try {
227
+ await writeSeed(dir, "job-heal", { pipeline: "p" }, 1700000000);
228
+ await mkdir(join(dir, "current", "job-heal", "files", "artifacts"), { recursive: true });
229
+ await writeFile(join(dir, "current", "job-heal", "files", "artifacts", "x.bin"), "x");
230
+ const pids = new Map<string, number>();
231
+ const result = await drainPendingQueue({
232
+ dataDir: dir,
233
+ maxConcurrentJobs: 5,
234
+ lockTimeoutMs: 1000,
235
+ spawnRunner: fakeSpawnRunner(pids),
236
+ });
237
+ expect(result.promoted).toEqual(["job-heal"]);
238
+ expect(pids.has("job-heal")).toBe(true);
239
+ expect(existsSync(join(dir, "pending", "job-heal-seed.json"))).toBe(false);
240
+ expect(existsSync(join(dir, "current", "job-heal", "seed.json"))).toBe(true);
241
+ expect(existsSync(join(dir, "current", "job-heal", "files", "artifacts", "x.bin"))).toBe(true);
242
+ } finally {
243
+ await rm(dir, { recursive: true });
244
+ }
245
+ });
246
+
115
247
  test("promotes seeds in deterministic order (mtime asc)", async () => {
116
248
  const dir = await setupDir("drain-order-");
117
249
  try {
@@ -181,6 +313,35 @@ describe("drainPendingQueue", () => {
181
313
  }
182
314
  });
183
315
 
316
+ test("AC8: rejection relocates staged artifacts under rejected/<jobId>/files/artifacts and removes staging", async () => {
317
+ const dir = await setupDir("drain-reject-staging-");
318
+ try {
319
+ await writeSeed(dir, "job-bad", { pipeline: "p" }, 1700000000);
320
+ await mkdir(join(dir, "staging", "job-bad"), { recursive: true });
321
+ await writeFile(join(dir, "staging", "job-bad", "notes.md"), "hello");
322
+ const failingSpawn = async (): Promise<{ pid: number }> => {
323
+ throw new Error("boom");
324
+ };
325
+
326
+ for (let i = 0; i < 3; i++) {
327
+ await drainPendingQueue({
328
+ dataDir: dir,
329
+ maxConcurrentJobs: 2,
330
+ lockTimeoutMs: 1000,
331
+ spawnRunner: failingSpawn,
332
+ });
333
+ }
334
+
335
+ expect(existsSync(join(dir, "rejected", "job-bad", "seed.json"))).toBe(true);
336
+ expect(
337
+ await readFile(join(dir, "rejected", "job-bad", "files", "artifacts", "notes.md"), "utf-8"),
338
+ ).toBe("hello");
339
+ expect(existsSync(join(dir, "staging", "job-bad"))).toBe(false);
340
+ } finally {
341
+ await rm(dir, { recursive: true });
342
+ }
343
+ });
344
+
184
345
  test("repeated invalid seed reads move the seed to rejected", async () => {
185
346
  const dir = await setupDir("drain-invalid-reject-");
186
347
  try {
@@ -20,6 +20,7 @@ interface ResolvedDirs {
20
20
  current: string;
21
21
  complete: string;
22
22
  rejected: string;
23
+ staging: string;
23
24
  }
24
25
 
25
26
  /** Parsed seed file content (fields consumed by orchestrator). */
@@ -138,6 +139,7 @@ export function resolveDirs(dataDir: string): ResolvedDirs {
138
139
  current: join(root, "current"),
139
140
  complete: join(root, "complete"),
140
141
  rejected: join(root, "rejected"),
142
+ staging: join(root, "staging"),
141
143
  };
142
144
  }
143
145
 
@@ -438,18 +440,30 @@ async function dirExists(path: string): Promise<boolean> {
438
440
  }
439
441
  }
440
442
 
441
- async function promoteSeedFile(
443
+ export async function promoteSeedFile(
442
444
  pendingSeedPath: string,
443
445
  currentJobDir: string,
446
+ stagingJobDir: string,
444
447
  ): Promise<void> {
445
448
  await mkdir(currentJobDir, { recursive: true });
449
+ if (await dirExists(stagingJobDir)) {
450
+ await mkdir(join(currentJobDir, "files"), { recursive: true });
451
+ await rename(stagingJobDir, join(currentJobDir, "files", "artifacts"));
452
+ }
446
453
  await rename(pendingSeedPath, join(currentJobDir, "seed.json"));
447
454
  }
448
455
 
449
- async function restoreSeedFile(currentJobDir: string, pendingSeedPath: string): Promise<void> {
450
- const currentSeedPath = join(currentJobDir, "seed.json");
456
+ export async function restoreSeedFile(
457
+ currentJobDir: string,
458
+ pendingSeedPath: string,
459
+ stagingJobDir: string,
460
+ ): Promise<void> {
461
+ const artifactsDir = join(currentJobDir, "files", "artifacts");
462
+ if (await dirExists(artifactsDir)) {
463
+ await rename(artifactsDir, stagingJobDir);
464
+ }
451
465
  try {
452
- await rename(currentSeedPath, pendingSeedPath);
466
+ await rename(join(currentJobDir, "seed.json"), pendingSeedPath);
453
467
  } catch (err) {
454
468
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
455
469
  }
@@ -465,6 +479,11 @@ async function rejectSeedFile(
465
479
  ): Promise<void> {
466
480
  const rejectedJobDir = join(dataDir, "rejected", jobId);
467
481
  await mkdir(rejectedJobDir, { recursive: true });
482
+ const stagingJobDir = join(dataDir, "staging", jobId);
483
+ if (await dirExists(stagingJobDir)) {
484
+ await mkdir(join(rejectedJobDir, "files"), { recursive: true });
485
+ await rename(stagingJobDir, join(rejectedJobDir, "files", "artifacts"));
486
+ }
468
487
  try {
469
488
  await rename(seedPath, join(rejectedJobDir, "seed.json"));
470
489
  } catch (err) {
@@ -558,14 +577,15 @@ export async function drainPendingQueue(
558
577
  }
559
578
 
560
579
  const currentJobDir = join(dataDir, "current", jobId);
561
- if (await dirExists(currentJobDir)) {
580
+ const stagingJobDir = join(dataDir, "staging", jobId);
581
+ if (await Bun.file(join(currentJobDir, "seed.json")).exists()) {
562
582
  await releaseJobSlot(dataDir, jobId, lockTimeoutMs);
563
- logger.warn(`current job directory already exists for ${jobId}; skipping promotion`);
583
+ logger.warn(`seed already promoted for ${jobId}; skipping`);
564
584
  continue;
565
585
  }
566
586
 
567
587
  try {
568
- await promoteSeedFile(seedPath, currentJobDir);
588
+ await promoteSeedFile(seedPath, currentJobDir, stagingJobDir);
569
589
  } catch (err) {
570
590
  await releaseJobSlot(dataDir, jobId, lockTimeoutMs);
571
591
  logger.error(`failed to promote seed file for job ${jobId}`, err);
@@ -578,7 +598,7 @@ export async function drainPendingQueue(
578
598
  } catch (err) {
579
599
  await releaseJobSlot(dataDir, jobId, lockTimeoutMs);
580
600
  try {
581
- await restoreSeedFile(currentJobDir, seedPath);
601
+ await restoreSeedFile(currentJobDir, seedPath, stagingJobDir);
582
602
  } catch (restoreErr) {
583
603
  logger.error(`failed to restore queued seed after spawn failure for job ${jobId}`, restoreErr);
584
604
  }
@@ -607,7 +627,7 @@ export async function drainPendingQueue(
607
627
  logger.error(`failed to release slot after pid update failure for job ${jobId}`, releaseErr);
608
628
  }
609
629
  try {
610
- await restoreSeedFile(currentJobDir, seedPath);
630
+ await restoreSeedFile(currentJobDir, seedPath, stagingJobDir);
611
631
  } catch (restoreErr) {
612
632
  logger.error(`failed to restore queued seed after pid update failure for job ${jobId}`, restoreErr);
613
633
  }
@@ -643,6 +663,7 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
643
663
  .then(() => mkdir(dirs.current, { recursive: true }))
644
664
  .then(() => mkdir(dirs.complete, { recursive: true }))
645
665
  .then(() => mkdir(dirs.rejected, { recursive: true }))
666
+ .then(() => mkdir(dirs.staging, { recursive: true }))
646
667
  .then(() => new Promise<OrchestratorHandle>((resolve, reject) => {
647
668
  const running = new Map<string, ChildHandle>();
648
669
  const spawnFn = opts.spawn ?? createDefaultSpawn();
@@ -1,7 +1,20 @@
1
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
1
5
  import { zipSync } from "fflate";
2
6
  import { describe, expect, it } from "vitest";
3
7
 
4
- import { normalizeSeedUpload } from "../upload-endpoints";
8
+ import { handleSeedUploadDirect, normalizeSeedUpload } from "../upload-endpoints";
9
+
10
+ async function pathExists(target: string): Promise<boolean> {
11
+ try {
12
+ await stat(target);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
5
18
 
6
19
  function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
7
20
  const total = parts.reduce((sum, part) => sum + part.length, 0);
@@ -55,3 +68,35 @@ describe("normalizeSeedUpload", () => {
55
68
  expect(Array.from(blob.content)).toEqual(Array.from(artifact));
56
69
  });
57
70
  });
71
+
72
+ describe("handleSeedUploadDirect", () => {
73
+ it("stages artifacts under staging/{jobId}/ without creating current/{jobId}/", async () => {
74
+ const dataDir = await mkdtemp(path.join(tmpdir(), "upload-direct-"));
75
+ try {
76
+ const content = new TextEncoder().encode("# notes\nhello\n");
77
+ const seedObject = { name: "demo", pipeline: "x" };
78
+
79
+ const response = await handleSeedUploadDirect(seedObject, dataDir, [
80
+ { filename: "notes.md", content },
81
+ ]);
82
+ expect(response.status).toBe(201);
83
+ const body = (await response.json()) as { data: { jobId: string } };
84
+ const jobId = body.data.jobId;
85
+
86
+ const pipelineData = path.join(dataDir, "pipeline-data");
87
+
88
+ // Artifact staged byte-identical under staging/{jobId}/.
89
+ const staged = path.join(pipelineData, "staging", jobId, "notes.md");
90
+ const stagedBytes = await readFile(staged);
91
+ expect(Array.from(stagedBytes)).toEqual(Array.from(content));
92
+
93
+ // Pending seed trigger written.
94
+ expect(await pathExists(path.join(pipelineData, "pending", `${jobId}-seed.json`))).toBe(true);
95
+
96
+ // The endpoint must not create current/{jobId}/.
97
+ expect(await pathExists(path.join(pipelineData, "current", jobId))).toBe(false);
98
+ } finally {
99
+ await rm(dataDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+ });
@@ -35,12 +35,13 @@ export async function handleSeedUploadDirect(
35
35
  const pipelineData = path.join(dataDir, "pipeline-data");
36
36
  const pendingDir = path.join(pipelineData, "pending");
37
37
 
38
- // Write artifacts to current/{jobId}/ first (before the trigger file)
39
- // so they are in place when the orchestrator processes the seed.
38
+ // Stage artifacts under staging/{jobId}/ (before the trigger file).
39
+ // The orchestrator owns current/{jobId}/ and moves staged artifacts into
40
+ // current/{jobId}/files/artifacts/ during promotion.
40
41
  if (artifacts.length > 0) {
41
- const jobDir = path.join(pipelineData, "current", jobId);
42
+ const stagingJobDir = path.join(pipelineData, "staging", jobId);
42
43
  for (const artifact of artifacts) {
43
- const artifactPath = path.join(jobDir, "files", "artifacts", artifact.filename);
44
+ const artifactPath = path.join(stagingJobDir, artifact.filename);
44
45
  await mkdir(path.dirname(artifactPath), { recursive: true });
45
46
  await Bun.write(artifactPath, artifact.content);
46
47
  }