@ryanfw/prompt-orchestration-pipeline 1.2.10 → 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 +1 -1
- package/src/core/__tests__/orchestrator.test.ts +164 -3
- package/src/core/orchestrator.ts +30 -9
- package/src/ui/server/__tests__/utils.test.ts +128 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +102 -0
- package/src/ui/server/endpoints/upload-endpoints.ts +5 -4
- package/src/ui/server/utils/http-utils.ts +29 -52
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.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 {
|
|
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
|
|
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 {
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -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(
|
|
450
|
-
|
|
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(
|
|
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
|
-
|
|
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(`
|
|
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,9 +1,21 @@
|
|
|
1
|
+
import { unzipSync, zipSync } from "fflate";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
|
|
3
4
|
import { parseMultipartFormData, readRawBody, sendJson } from "../utils/http-utils";
|
|
4
5
|
import { getMimeType, isTextMime } from "../utils/mime-types";
|
|
5
6
|
import { ensureUniqueSlug, generateSlug } from "../utils/slug";
|
|
6
7
|
|
|
8
|
+
function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
9
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
10
|
+
const out = new Uint8Array(total);
|
|
11
|
+
let offset = 0;
|
|
12
|
+
for (const part of parts) {
|
|
13
|
+
out.set(part, offset);
|
|
14
|
+
offset += part.length;
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
describe("server utils", () => {
|
|
8
20
|
it("sends json responses", async () => {
|
|
9
21
|
const response = sendJson(200, { ok: true });
|
|
@@ -46,6 +58,122 @@ describe("server utils", () => {
|
|
|
46
58
|
expect(parsed.files[0]).toMatchObject({ filename: "seed.json", contentType: "application/json" });
|
|
47
59
|
});
|
|
48
60
|
|
|
61
|
+
it("preserves binary zip payloads byte-for-byte", async () => {
|
|
62
|
+
const seed = { name: "binary-seed" };
|
|
63
|
+
const binary = new Uint8Array(256);
|
|
64
|
+
for (let i = 0; i < 256; i++) {
|
|
65
|
+
binary[i] = i;
|
|
66
|
+
}
|
|
67
|
+
const zip = zipSync({
|
|
68
|
+
"seed.json": new TextEncoder().encode(JSON.stringify(seed)),
|
|
69
|
+
"data.bin": binary,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const boundary = "binaryboundary";
|
|
73
|
+
const encoder = new TextEncoder();
|
|
74
|
+
const header = encoder.encode(
|
|
75
|
+
`--${boundary}\r\n` +
|
|
76
|
+
'Content-Disposition: form-data; name="file"; filename="bundle.zip"\r\n' +
|
|
77
|
+
"Content-Type: application/zip\r\n\r\n",
|
|
78
|
+
);
|
|
79
|
+
const footer = encoder.encode(`\r\n--${boundary}--\r\n`);
|
|
80
|
+
const body = concatBytes([header, zip, footer]);
|
|
81
|
+
|
|
82
|
+
const request = new Request("http://localhost", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
85
|
+
body,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const parsed = await parseMultipartFormData(request);
|
|
89
|
+
|
|
90
|
+
// AC1: byte-for-byte identical recovery.
|
|
91
|
+
const file = parsed.files[0]!;
|
|
92
|
+
expect(file.content.length).toBe(zip.length);
|
|
93
|
+
expect(Array.from(file.content)).toEqual(Array.from(zip));
|
|
94
|
+
|
|
95
|
+
// AC2: recovered bytes unzip and include seed.json.
|
|
96
|
+
const extracted = unzipSync(file.content);
|
|
97
|
+
expect(Object.keys(extracted)).toContain("seed.json");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("routes string fields and preserves text file metadata", async () => {
|
|
101
|
+
const boundary = "mixedboundary";
|
|
102
|
+
const body = [
|
|
103
|
+
`--${boundary}`,
|
|
104
|
+
'Content-Disposition: form-data; name="meta"',
|
|
105
|
+
"",
|
|
106
|
+
"value",
|
|
107
|
+
`--${boundary}`,
|
|
108
|
+
'Content-Disposition: form-data; name="file"; filename="seed.json"',
|
|
109
|
+
"Content-Type: application/json",
|
|
110
|
+
"",
|
|
111
|
+
'{"ok":true}',
|
|
112
|
+
`--${boundary}--`,
|
|
113
|
+
"",
|
|
114
|
+
].join("\r\n");
|
|
115
|
+
const request = new Request("http://localhost", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
118
|
+
body,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const parsed = await parseMultipartFormData(request);
|
|
122
|
+
|
|
123
|
+
// AC4: a part without a filename is returned under fields.
|
|
124
|
+
expect(parsed.fields).toEqual({ meta: "value" });
|
|
125
|
+
|
|
126
|
+
// AC5: a .json file part preserves filename and contentType.
|
|
127
|
+
expect(parsed.files[0]).toMatchObject({
|
|
128
|
+
filename: "seed.json",
|
|
129
|
+
contentType: "application/json",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("rejects non-multipart content types", async () => {
|
|
134
|
+
const request = new Request("http://localhost", {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "content-type": "application/json" },
|
|
137
|
+
body: '{"ok":true}',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// AC6: non-multipart content type throws.
|
|
141
|
+
await expect(parseMultipartFormData(request)).rejects.toThrow(/multipart/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects multipart content types without a boundary", async () => {
|
|
145
|
+
const request = new Request("http://localhost", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "multipart/form-data" },
|
|
148
|
+
body: "irrelevant",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await expect(parseMultipartFormData(request)).rejects.toThrow(/boundary/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("enforces the multipart byte cap", async () => {
|
|
155
|
+
const boundary = "capboundary";
|
|
156
|
+
const encoder = new TextEncoder();
|
|
157
|
+
const body = concatBytes([
|
|
158
|
+
encoder.encode(
|
|
159
|
+
`--${boundary}\r\n` +
|
|
160
|
+
'Content-Disposition: form-data; name="file"; filename="big.bin"\r\n' +
|
|
161
|
+
"Content-Type: application/octet-stream\r\n\r\n",
|
|
162
|
+
),
|
|
163
|
+
new Uint8Array(1024),
|
|
164
|
+
encoder.encode(`\r\n--${boundary}--\r\n`),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const request = new Request("http://localhost", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
170
|
+
body,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// AC7: a body larger than the passed maxBytes throws.
|
|
174
|
+
await expect(parseMultipartFormData(request, 64)).rejects.toThrow(/exceeds/);
|
|
175
|
+
});
|
|
176
|
+
|
|
49
177
|
it("maps mime types and text classification", () => {
|
|
50
178
|
expect(getMimeType("file.json")).toBe("application/json");
|
|
51
179
|
expect(getMimeType("file.unknown")).toBe("application/octet-stream");
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { zipSync } from "fflate";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
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
|
+
}
|
|
18
|
+
|
|
19
|
+
function concatBytes(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
20
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
21
|
+
const out = new Uint8Array(total);
|
|
22
|
+
let offset = 0;
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
out.set(part, offset);
|
|
25
|
+
offset += part.length;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("normalizeSeedUpload", () => {
|
|
31
|
+
it("extracts seed and binary artifact from a multipart zip upload", async () => {
|
|
32
|
+
const seed = { name: "demo", pipeline: "x" };
|
|
33
|
+
const artifact = new Uint8Array(256);
|
|
34
|
+
for (let i = 0; i < 256; i++) {
|
|
35
|
+
artifact[i] = i;
|
|
36
|
+
}
|
|
37
|
+
const zip = zipSync({
|
|
38
|
+
"seed.json": new TextEncoder().encode(JSON.stringify(seed)),
|
|
39
|
+
"artifacts/blob.bin": artifact,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const boundary = "uploadboundary";
|
|
43
|
+
const encoder = new TextEncoder();
|
|
44
|
+
const header = encoder.encode(
|
|
45
|
+
`--${boundary}\r\n` +
|
|
46
|
+
'Content-Disposition: form-data; name="file"; filename="bundle.zip"\r\n' +
|
|
47
|
+
"Content-Type: application/zip\r\n\r\n",
|
|
48
|
+
);
|
|
49
|
+
const footer = encoder.encode(`\r\n--${boundary}--\r\n`);
|
|
50
|
+
const body = concatBytes([header, zip, footer]);
|
|
51
|
+
|
|
52
|
+
const request = new Request("http://localhost", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
55
|
+
body,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await normalizeSeedUpload(request);
|
|
59
|
+
|
|
60
|
+
// AC3: seedObject deep-equals the original seed.
|
|
61
|
+
expect(result.seedObject).toEqual(seed);
|
|
62
|
+
|
|
63
|
+
// AC3: exactly one artifact, byte-identical to the source blob.
|
|
64
|
+
expect(result.artifacts).toHaveLength(1);
|
|
65
|
+
const blob = result.artifacts![0]!;
|
|
66
|
+
expect(blob.filename).toBe("artifacts/blob.bin");
|
|
67
|
+
expect(blob.content.length).toBe(artifact.length);
|
|
68
|
+
expect(Array.from(blob.content)).toEqual(Array.from(artifact));
|
|
69
|
+
});
|
|
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
|
-
//
|
|
39
|
-
//
|
|
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
|
|
42
|
+
const stagingJobDir = path.join(pipelineData, "staging", jobId);
|
|
42
43
|
for (const artifact of artifacts) {
|
|
43
|
-
const artifactPath = path.join(
|
|
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
|
}
|
|
@@ -20,7 +20,7 @@ export function sendJson(statusCode: number, data: unknown): Response {
|
|
|
20
20
|
export async function readRawBody(
|
|
21
21
|
req: Request,
|
|
22
22
|
maxBytes: number = DEFAULT_MAX_BYTES,
|
|
23
|
-
): Promise<Uint8Array
|
|
23
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
24
24
|
const buffer = new Uint8Array(await req.arrayBuffer());
|
|
25
25
|
if (buffer.byteLength > maxBytes) {
|
|
26
26
|
throw badRequest(`request body exceeds ${maxBytes} bytes`);
|
|
@@ -28,63 +28,40 @@ export async function readRawBody(
|
|
|
28
28
|
return buffer;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function parseHeaders(block: string): Record<string, string> {
|
|
32
|
-
return Object.fromEntries(
|
|
33
|
-
block
|
|
34
|
-
.split("\r\n")
|
|
35
|
-
.filter(Boolean)
|
|
36
|
-
.flatMap((line) => {
|
|
37
|
-
const [name, ...rest] = line.split(":");
|
|
38
|
-
if (name === undefined) return [];
|
|
39
|
-
return [[name.trim().toLowerCase(), rest.join(":").trim()] as const];
|
|
40
|
-
}),
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function getBoundary(contentType: string | null): string {
|
|
45
|
-
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType ?? "");
|
|
46
|
-
if (!match) throw badRequest("multipart boundary is required");
|
|
47
|
-
return match[1] ?? match[2]!;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
31
|
export async function parseMultipartFormData(
|
|
51
32
|
req: Request,
|
|
33
|
+
maxBytes: number = DEFAULT_MAX_BYTES,
|
|
52
34
|
): Promise<{ fields: Record<string, string>; files: MultipartFile[] }> {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const headerBlock = part.slice(0, separator);
|
|
65
|
-
const contentBlock = part.slice(separator + 4);
|
|
66
|
-
const headers = parseHeaders(headerBlock);
|
|
67
|
-
const disposition = headers["content-disposition"];
|
|
68
|
-
if (!disposition) continue;
|
|
35
|
+
const contentType = req.headers.get("content-type");
|
|
36
|
+
if (
|
|
37
|
+
!contentType ||
|
|
38
|
+
!contentType.toLowerCase().includes("multipart/form-data")
|
|
39
|
+
) {
|
|
40
|
+
throw badRequest("expected multipart/form-data content-type");
|
|
41
|
+
}
|
|
42
|
+
if (!/boundary=/i.test(contentType)) {
|
|
43
|
+
throw badRequest("multipart boundary is required");
|
|
44
|
+
}
|
|
69
45
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
46
|
+
const raw = await readRawBody(req, maxBytes); // enforces the size cap on actual bytes
|
|
47
|
+
const form = await new Response(raw, {
|
|
48
|
+
headers: { "content-type": contentType },
|
|
49
|
+
}).formData();
|
|
74
50
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
51
|
+
const fields: Record<string, string> = {};
|
|
52
|
+
const files: MultipartFile[] = [];
|
|
53
|
+
for (const name of new Set(form.keys())) {
|
|
54
|
+
for (const value of form.getAll(name)) {
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
fields[name] = value;
|
|
57
|
+
} else {
|
|
58
|
+
files.push({
|
|
59
|
+
filename: value.name,
|
|
60
|
+
contentType: value.type || "application/octet-stream",
|
|
61
|
+
content: new Uint8Array(await value.arrayBuffer()),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
78
64
|
}
|
|
79
|
-
|
|
80
|
-
const filename = filenameMatch[1];
|
|
81
|
-
if (filename === undefined) continue;
|
|
82
|
-
files.push({
|
|
83
|
-
filename,
|
|
84
|
-
contentType: headers["content-type"] ?? "application/octet-stream",
|
|
85
|
-
content: new TextEncoder().encode(contentBlock),
|
|
86
|
-
});
|
|
87
65
|
}
|
|
88
|
-
|
|
89
66
|
return { fields, files };
|
|
90
67
|
}
|