@ryanfw/prompt-orchestration-pipeline 1.0.5 → 1.2.0
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/api/__tests__/index.test.ts +372 -0
- package/src/api/index.ts +214 -14
- package/src/cli/__tests__/index.test.ts +39 -5
- package/src/config/__tests__/models.test.ts +14 -5
- package/src/config/models.ts +149 -105
- package/src/core/logger.ts +15 -6
- package/src/llm/__tests__/index.test.ts +13 -1
- package/src/llm/index.ts +9 -0
- package/src/providers/__tests__/alibaba.test.ts +186 -0
- package/src/providers/__tests__/zhipu.test.ts +2 -2
- package/src/providers/alibaba.ts +193 -0
- package/src/providers/types.ts +9 -0
- package/src/providers/zhipu.ts +1 -1
- package/src/ui/dist/assets/{index-bRQpD9rj.js → index-CkBEIVbA.js} +445 -20
- package/src/ui/dist/assets/index-CkBEIVbA.js.map +1 -0
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +4 -4
- package/src/ui/pages/Code.tsx +120 -45
- package/src/ui/server/endpoints/job-control-endpoints.ts +109 -3
- package/src/ui/dist/assets/index-bRQpD9rj.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
submitJobWithValidation,
|
|
8
|
+
PipelineOrchestrator,
|
|
9
|
+
type SubmitSuccessResult,
|
|
10
|
+
type SubmitFailureResult,
|
|
11
|
+
} from "../index.ts";
|
|
12
|
+
|
|
13
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
async function readJson(filePath: string): Promise<unknown> {
|
|
16
|
+
const text = await Bun.file(filePath).text();
|
|
17
|
+
return JSON.parse(text) as unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function scaffoldWorkspace(tmpDir: string): Promise<void> {
|
|
21
|
+
const pipelineConfigDir = join(tmpDir, "pipeline-config", "test-pipeline");
|
|
22
|
+
|
|
23
|
+
await mkdir(pipelineConfigDir, { recursive: true });
|
|
24
|
+
await mkdir(join(pipelineConfigDir, "tasks"), { recursive: true });
|
|
25
|
+
await mkdir(join(tmpDir, "pipeline-data", "pending"), { recursive: true });
|
|
26
|
+
await mkdir(join(tmpDir, "pipeline-data", "current"), { recursive: true });
|
|
27
|
+
await mkdir(join(tmpDir, "pipeline-data", "complete"), { recursive: true });
|
|
28
|
+
|
|
29
|
+
await Bun.write(
|
|
30
|
+
join(tmpDir, "pipeline-config", "registry.json"),
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
pipelines: {
|
|
33
|
+
"test-pipeline": {
|
|
34
|
+
configDir: join(tmpDir, "pipeline-config", "test-pipeline"),
|
|
35
|
+
tasksDir: join(tmpDir, "pipeline-config", "test-pipeline", "tasks"),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await Bun.write(
|
|
42
|
+
join(pipelineConfigDir, "pipeline.json"),
|
|
43
|
+
JSON.stringify({ name: "test-pipeline", tasks: [] }),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── submitJobWithValidation ─────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("submitJobWithValidation", () => {
|
|
50
|
+
let tmpDir: string;
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
tmpDir = await mkdtemp(join(tmpdir(), "pop-api-submit-test-"));
|
|
54
|
+
await scaffoldWorkspace(tmpDir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("writes seed file to pending and returns success", async () => {
|
|
62
|
+
const result = await submitJobWithValidation({
|
|
63
|
+
dataDir: tmpDir,
|
|
64
|
+
seedObject: { pipeline: "test-pipeline" },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(result.success).toBe(true);
|
|
68
|
+
const success = result as SubmitSuccessResult;
|
|
69
|
+
expect(success.jobId).toBeTruthy();
|
|
70
|
+
expect(success.jobName).toBe(success.jobId);
|
|
71
|
+
|
|
72
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
73
|
+
const seedFile = pendingFiles.find((f) => f.endsWith("-seed.json"));
|
|
74
|
+
expect(seedFile).toBeDefined();
|
|
75
|
+
|
|
76
|
+
const written = await readJson(join(tmpDir, "pipeline-data", "pending", seedFile!));
|
|
77
|
+
expect(written).toEqual({ pipeline: "test-pipeline" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns seed name as jobName when provided", async () => {
|
|
81
|
+
const result = await submitJobWithValidation({
|
|
82
|
+
dataDir: tmpDir,
|
|
83
|
+
seedObject: { pipeline: "test-pipeline", name: "my-job" },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.success).toBe(true);
|
|
87
|
+
expect((result as SubmitSuccessResult).jobName).toBe("my-job");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects non-object seed with message containing 'JSON object'", async () => {
|
|
91
|
+
const result = await submitJobWithValidation({
|
|
92
|
+
dataDir: tmpDir,
|
|
93
|
+
seedObject: "not an object",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result.success).toBe(false);
|
|
97
|
+
expect((result as SubmitFailureResult).message).toContain("JSON object");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects array seed with message containing 'JSON object'", async () => {
|
|
101
|
+
const result = await submitJobWithValidation({
|
|
102
|
+
dataDir: tmpDir,
|
|
103
|
+
seedObject: [1, 2, 3],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.success).toBe(false);
|
|
107
|
+
expect((result as SubmitFailureResult).message).toContain("JSON object");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects null seed with message containing 'JSON object'", async () => {
|
|
111
|
+
const result = await submitJobWithValidation({
|
|
112
|
+
dataDir: tmpDir,
|
|
113
|
+
seedObject: null,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.success).toBe(false);
|
|
117
|
+
expect((result as SubmitFailureResult).message).toContain("JSON object");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects seed missing pipeline field with message containing 'pipeline'", async () => {
|
|
121
|
+
const result = await submitJobWithValidation({
|
|
122
|
+
dataDir: tmpDir,
|
|
123
|
+
seedObject: { name: "my-job" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.success).toBe(false);
|
|
127
|
+
expect((result as SubmitFailureResult).message).toContain("pipeline");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects seed with empty pipeline string", async () => {
|
|
131
|
+
const result = await submitJobWithValidation({
|
|
132
|
+
dataDir: tmpDir,
|
|
133
|
+
seedObject: { pipeline: "" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.success).toBe(false);
|
|
137
|
+
expect((result as SubmitFailureResult).message).toContain("pipeline");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("rejects non-existent pipeline slug with message containing 'not found'", async () => {
|
|
141
|
+
const result = await submitJobWithValidation({
|
|
142
|
+
dataDir: tmpDir,
|
|
143
|
+
seedObject: { pipeline: "does-not-exist" },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.success).toBe(false);
|
|
147
|
+
expect((result as SubmitFailureResult).message).toContain("not found");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("rejects seed with empty name string", async () => {
|
|
151
|
+
const result = await submitJobWithValidation({
|
|
152
|
+
dataDir: tmpDir,
|
|
153
|
+
seedObject: { pipeline: "test-pipeline", name: "" },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.success).toBe(false);
|
|
157
|
+
expect((result as SubmitFailureResult).message).toContain("name");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─── PipelineOrchestrator.getStatus ──────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("PipelineOrchestrator.getStatus", () => {
|
|
164
|
+
let tmpDir: string;
|
|
165
|
+
let savedPoRoot: string | undefined;
|
|
166
|
+
|
|
167
|
+
beforeEach(async () => {
|
|
168
|
+
tmpDir = await mkdtemp(join(tmpdir(), "pop-api-status-test-"));
|
|
169
|
+
await scaffoldWorkspace(tmpDir);
|
|
170
|
+
savedPoRoot = process.env["PO_ROOT"];
|
|
171
|
+
process.env["PO_ROOT"] = tmpDir;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterEach(async () => {
|
|
175
|
+
if (savedPoRoot === undefined) {
|
|
176
|
+
delete process.env["PO_ROOT"];
|
|
177
|
+
} else {
|
|
178
|
+
process.env["PO_ROOT"] = savedPoRoot;
|
|
179
|
+
}
|
|
180
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns status for job in current directory", async () => {
|
|
184
|
+
const jobId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
185
|
+
const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
|
|
186
|
+
await mkdir(jobDir, { recursive: true });
|
|
187
|
+
await Bun.write(
|
|
188
|
+
join(jobDir, "tasks-status.json"),
|
|
189
|
+
JSON.stringify({
|
|
190
|
+
id: jobId,
|
|
191
|
+
name: "current-job",
|
|
192
|
+
pipeline: "test-pipeline",
|
|
193
|
+
state: "running",
|
|
194
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
195
|
+
tasks: {},
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
200
|
+
const record = await orch.getStatus(jobId);
|
|
201
|
+
|
|
202
|
+
expect(record.jobId).toBe(jobId);
|
|
203
|
+
expect(record.jobName).toBe("current-job");
|
|
204
|
+
expect(record.pipeline).toBe("test-pipeline");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("returns status for job in complete directory", async () => {
|
|
208
|
+
const jobId = "ffffffff-1111-2222-3333-444444444444";
|
|
209
|
+
const jobDir = join(tmpDir, "pipeline-data", "complete", jobId);
|
|
210
|
+
await mkdir(jobDir, { recursive: true });
|
|
211
|
+
await Bun.write(
|
|
212
|
+
join(jobDir, "tasks-status.json"),
|
|
213
|
+
JSON.stringify({
|
|
214
|
+
id: jobId,
|
|
215
|
+
name: "done-job",
|
|
216
|
+
pipeline: "test-pipeline",
|
|
217
|
+
state: "complete",
|
|
218
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
219
|
+
tasks: {},
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
224
|
+
const record = await orch.getStatus(jobId);
|
|
225
|
+
|
|
226
|
+
expect(record.jobId).toBe(jobId);
|
|
227
|
+
expect(record.jobName).toBe("done-job");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("finds job by name scan fallback", async () => {
|
|
231
|
+
const jobId = "11111111-2222-3333-4444-555555555555";
|
|
232
|
+
const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
|
|
233
|
+
await mkdir(jobDir, { recursive: true });
|
|
234
|
+
await Bun.write(
|
|
235
|
+
join(jobDir, "tasks-status.json"),
|
|
236
|
+
JSON.stringify({
|
|
237
|
+
id: jobId,
|
|
238
|
+
name: "named-job",
|
|
239
|
+
pipeline: "test-pipeline",
|
|
240
|
+
state: "running",
|
|
241
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
242
|
+
tasks: {},
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
247
|
+
const record = await orch.getStatus("named-job");
|
|
248
|
+
|
|
249
|
+
expect(record.jobId).toBe(jobId);
|
|
250
|
+
expect(record.jobName).toBe("named-job");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("returns pending record for seed file in pending directory", async () => {
|
|
254
|
+
const jobId = "22222222-3333-4444-5555-666666666666";
|
|
255
|
+
await Bun.write(
|
|
256
|
+
join(tmpDir, "pipeline-data", "pending", `${jobId}-seed.json`),
|
|
257
|
+
JSON.stringify({ pipeline: "test-pipeline", name: "pending-job" }),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
261
|
+
const record = await orch.getStatus(jobId);
|
|
262
|
+
|
|
263
|
+
expect(record.jobId).toBe(jobId);
|
|
264
|
+
expect(record.jobName).toBe("pending-job");
|
|
265
|
+
expect(record.state).toBe("pending");
|
|
266
|
+
expect(record.pipeline).toBe("test-pipeline");
|
|
267
|
+
expect(record.createdAt).toBeTruthy();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("throws for nonexistent job with message containing 'not found'", async () => {
|
|
271
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
272
|
+
|
|
273
|
+
await expect(orch.getStatus("nonexistent-id")).rejects.toThrow("not found");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ─── PipelineOrchestrator.listJobs ───────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe("PipelineOrchestrator.listJobs", () => {
|
|
280
|
+
let tmpDir: string;
|
|
281
|
+
let savedPoRoot: string | undefined;
|
|
282
|
+
|
|
283
|
+
beforeEach(async () => {
|
|
284
|
+
tmpDir = await mkdtemp(join(tmpdir(), "pop-api-list-test-"));
|
|
285
|
+
await scaffoldWorkspace(tmpDir);
|
|
286
|
+
savedPoRoot = process.env["PO_ROOT"];
|
|
287
|
+
process.env["PO_ROOT"] = tmpDir;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
afterEach(async () => {
|
|
291
|
+
if (savedPoRoot === undefined) {
|
|
292
|
+
delete process.env["PO_ROOT"];
|
|
293
|
+
} else {
|
|
294
|
+
process.env["PO_ROOT"] = savedPoRoot;
|
|
295
|
+
}
|
|
296
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("returns combined records from pending, current, and complete", async () => {
|
|
300
|
+
// Pending job
|
|
301
|
+
const pendingId = "aaaa-bbbb-cccc-dddd-1111";
|
|
302
|
+
await Bun.write(
|
|
303
|
+
join(tmpDir, "pipeline-data", "pending", `${pendingId}-seed.json`),
|
|
304
|
+
JSON.stringify({ pipeline: "test-pipeline", name: "pending-one" }),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// Current job
|
|
308
|
+
const currentId = "aaaa-bbbb-cccc-dddd-2222";
|
|
309
|
+
const currentDir = join(tmpDir, "pipeline-data", "current", currentId);
|
|
310
|
+
await mkdir(currentDir, { recursive: true });
|
|
311
|
+
await Bun.write(
|
|
312
|
+
join(currentDir, "tasks-status.json"),
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
id: currentId,
|
|
315
|
+
name: "current-one",
|
|
316
|
+
pipeline: "test-pipeline",
|
|
317
|
+
state: "running",
|
|
318
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
319
|
+
tasks: {},
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Complete job
|
|
324
|
+
const completeId = "aaaa-bbbb-cccc-dddd-3333";
|
|
325
|
+
const completeDir = join(tmpDir, "pipeline-data", "complete", completeId);
|
|
326
|
+
await mkdir(completeDir, { recursive: true });
|
|
327
|
+
await Bun.write(
|
|
328
|
+
join(completeDir, "tasks-status.json"),
|
|
329
|
+
JSON.stringify({
|
|
330
|
+
id: completeId,
|
|
331
|
+
name: "complete-one",
|
|
332
|
+
pipeline: "test-pipeline",
|
|
333
|
+
state: "complete",
|
|
334
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
335
|
+
tasks: {},
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
340
|
+
const jobs = await orch.listJobs();
|
|
341
|
+
|
|
342
|
+
expect(jobs.length).toBe(3);
|
|
343
|
+
|
|
344
|
+
const ids = jobs.map((j) => j.jobId);
|
|
345
|
+
expect(ids).toContain(pendingId);
|
|
346
|
+
expect(ids).toContain(currentId);
|
|
347
|
+
expect(ids).toContain(completeId);
|
|
348
|
+
|
|
349
|
+
const pending = jobs.find((j) => j.jobId === pendingId)!;
|
|
350
|
+
expect(pending.state).toBe("pending");
|
|
351
|
+
|
|
352
|
+
const complete = jobs.find((j) => j.jobId === completeId)!;
|
|
353
|
+
expect(complete.state).toBe("complete");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("returns empty array when all directories are empty", async () => {
|
|
357
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
358
|
+
const jobs = await orch.listJobs();
|
|
359
|
+
|
|
360
|
+
expect(jobs).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("does not throw when pipeline-data directories are missing", async () => {
|
|
364
|
+
// Remove all pipeline-data directories
|
|
365
|
+
await rm(join(tmpDir, "pipeline-data"), { recursive: true, force: true });
|
|
366
|
+
|
|
367
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
368
|
+
const jobs = await orch.listJobs();
|
|
369
|
+
|
|
370
|
+
expect(jobs).toEqual([]);
|
|
371
|
+
});
|
|
372
|
+
});
|
package/src/api/index.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readdir, rename, stat } from "node:fs/promises";
|
|
3
|
+
import { resolvePipelinePaths, getPendingSeedPath } from "../config/paths";
|
|
4
|
+
import type { PipelinePaths } from "../config/paths";
|
|
5
|
+
import { getPipelineConfig } from "../core/config";
|
|
6
|
+
import { deriveJobStatusFromTasks } from "../config/statuses";
|
|
7
|
+
import { SEED_PATTERN } from "../core/orchestrator";
|
|
8
|
+
|
|
1
9
|
/** Result of a successful job submission. */
|
|
2
10
|
export interface SubmitSuccessResult {
|
|
3
11
|
success: true;
|
|
@@ -34,28 +42,220 @@ export interface OrchestratorOptions {
|
|
|
34
42
|
autoStart: boolean;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
// ─── Private Helpers ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
|
48
|
+
const tmp = `${filePath}.${Date.now()}.tmp`;
|
|
49
|
+
await Bun.write(tmp, JSON.stringify(data, null, 2));
|
|
50
|
+
await rename(tmp, filePath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertSeedObject(value: unknown): asserts value is Record<string, unknown> {
|
|
54
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
55
|
+
throw new Error("seed must be a JSON object");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertPipelineSlug(value: unknown): asserts value is string {
|
|
60
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
61
|
+
throw new Error("seed.pipeline must be a non-empty string");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function safeReaddir(dirPath: string): Promise<string[]> {
|
|
66
|
+
try {
|
|
67
|
+
return await readdir(dirPath);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readJsonFile<T = unknown>(filePath: string): Promise<T> {
|
|
75
|
+
const text = await Bun.file(filePath).text();
|
|
76
|
+
return JSON.parse(text) as T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mapStatusToRecord(data: Record<string, unknown>): JobStatusRecord {
|
|
80
|
+
const { id, name, pipeline, state, createdAt, ...rest } = data;
|
|
81
|
+
return {
|
|
82
|
+
jobId: String(id ?? ""),
|
|
83
|
+
jobName: String(name ?? id ?? ""),
|
|
84
|
+
pipeline: String(pipeline ?? ""),
|
|
85
|
+
state: String(state ?? ""),
|
|
86
|
+
createdAt: String(createdAt ?? ""),
|
|
87
|
+
...rest,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
41
93
|
export async function submitJobWithValidation(
|
|
42
|
-
|
|
94
|
+
opts: SubmitJobOptions,
|
|
43
95
|
): Promise<SubmitResult> {
|
|
44
|
-
|
|
96
|
+
const rootDir = path.resolve(opts.dataDir);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
assertSeedObject(opts.seedObject);
|
|
100
|
+
assertPipelineSlug(opts.seedObject["pipeline"]);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { success: false, message: (err as Error).message };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const seed = opts.seedObject as Record<string, unknown>;
|
|
106
|
+
|
|
107
|
+
if (seed["name"] !== undefined) {
|
|
108
|
+
if (typeof seed["name"] !== "string" || seed["name"].length === 0) {
|
|
109
|
+
return { success: false, message: "seed.name must be a non-empty string if provided" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
getPipelineConfig(seed["pipeline"] as string, rootDir);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { success: false, message: (err as Error).message };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const jobId = crypto.randomUUID();
|
|
120
|
+
const jobName = (typeof seed["name"] === "string" && seed["name"].length > 0)
|
|
121
|
+
? seed["name"]
|
|
122
|
+
: jobId;
|
|
123
|
+
const pendingPath = getPendingSeedPath(rootDir, jobId);
|
|
124
|
+
|
|
125
|
+
await mkdir(path.dirname(pendingPath), { recursive: true });
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await atomicWriteJson(pendingPath, opts.seedObject);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new Error(`failed to write seed file for job ${jobId}: ${(err as Error).message}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { success: true, jobId, jobName };
|
|
45
134
|
}
|
|
46
135
|
|
|
47
|
-
/**
|
|
48
|
-
* Pipeline orchestrator class for status/job management.
|
|
49
|
-
* Stub implementation — to be replaced when api module is fully migrated.
|
|
50
|
-
*/
|
|
51
136
|
export class PipelineOrchestrator {
|
|
52
|
-
|
|
137
|
+
private readonly autoStart: boolean;
|
|
138
|
+
private readonly root: string;
|
|
139
|
+
private readonly paths: PipelinePaths;
|
|
53
140
|
|
|
54
|
-
|
|
55
|
-
|
|
141
|
+
constructor(opts: OrchestratorOptions) {
|
|
142
|
+
this.autoStart = opts.autoStart;
|
|
143
|
+
this.root = path.resolve(process.env["PO_ROOT"] ?? process.cwd());
|
|
144
|
+
this.paths = resolvePipelinePaths(this.root);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getStatus(jobName: string): Promise<JobStatusRecord> {
|
|
148
|
+
// 1. Direct jobId lookup in current, then complete
|
|
149
|
+
for (const dir of [this.paths.current, this.paths.complete]) {
|
|
150
|
+
const statusPath = path.join(dir, jobName, "tasks-status.json");
|
|
151
|
+
try {
|
|
152
|
+
const data = await readJsonFile<Record<string, unknown>>(statusPath);
|
|
153
|
+
return mapStatusToRecord(data);
|
|
154
|
+
} catch {
|
|
155
|
+
// not found here, continue
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Name scan fallback
|
|
160
|
+
for (const dir of [this.paths.current, this.paths.complete]) {
|
|
161
|
+
const entries = await safeReaddir(dir);
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (entry === ".gitkeep") continue;
|
|
164
|
+
const statusPath = path.join(dir, entry, "tasks-status.json");
|
|
165
|
+
try {
|
|
166
|
+
const data = await readJsonFile<Record<string, unknown>>(statusPath);
|
|
167
|
+
if (data["name"] === jobName) {
|
|
168
|
+
return mapStatusToRecord(data);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// skip unreadable entries
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 3. Pending fallback
|
|
177
|
+
const pendingFiles = await safeReaddir(this.paths.pending);
|
|
178
|
+
for (const file of pendingFiles) {
|
|
179
|
+
const match = file.match(SEED_PATTERN);
|
|
180
|
+
if (!match) continue;
|
|
181
|
+
const id = match[1]!;
|
|
182
|
+
if (id !== jobName) continue;
|
|
183
|
+
|
|
184
|
+
const filePath = path.join(this.paths.pending, file);
|
|
185
|
+
const seed = await readJsonFile<Record<string, unknown>>(filePath);
|
|
186
|
+
const fileStat = await stat(filePath);
|
|
187
|
+
return {
|
|
188
|
+
jobId: id,
|
|
189
|
+
jobName: typeof seed["name"] === "string" ? seed["name"] : id,
|
|
190
|
+
pipeline: String(seed["pipeline"] ?? ""),
|
|
191
|
+
state: "pending",
|
|
192
|
+
createdAt: fileStat.birthtime.toISOString(),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw new Error(`job '${jobName}' not found in pending, current, or complete`);
|
|
56
197
|
}
|
|
57
198
|
|
|
58
199
|
async listJobs(): Promise<JobStatusRecord[]> {
|
|
59
|
-
|
|
200
|
+
const results: JobStatusRecord[] = [];
|
|
201
|
+
|
|
202
|
+
// Pending jobs
|
|
203
|
+
const pendingFiles = await safeReaddir(this.paths.pending);
|
|
204
|
+
for (const file of pendingFiles) {
|
|
205
|
+
const match = file.match(SEED_PATTERN);
|
|
206
|
+
if (!match) continue;
|
|
207
|
+
const id = match[1]!;
|
|
208
|
+
const filePath = path.join(this.paths.pending, file);
|
|
209
|
+
try {
|
|
210
|
+
const seed = await readJsonFile<Record<string, unknown>>(filePath);
|
|
211
|
+
const fileStat = await stat(filePath);
|
|
212
|
+
results.push({
|
|
213
|
+
jobId: id,
|
|
214
|
+
jobName: typeof seed["name"] === "string" ? seed["name"] : id,
|
|
215
|
+
pipeline: String(seed["pipeline"] ?? ""),
|
|
216
|
+
state: "pending",
|
|
217
|
+
createdAt: fileStat.birthtime.toISOString(),
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.warn(`[api] skipping unreadable pending seed ${file}: ${(err as Error).message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Current jobs
|
|
225
|
+
const currentEntries = await safeReaddir(this.paths.current);
|
|
226
|
+
for (const entry of currentEntries) {
|
|
227
|
+
if (entry === ".gitkeep") continue;
|
|
228
|
+
const statusPath = path.join(this.paths.current, entry, "tasks-status.json");
|
|
229
|
+
try {
|
|
230
|
+
const data = await readJsonFile<Record<string, unknown>>(statusPath);
|
|
231
|
+
const tasks = data["tasks"];
|
|
232
|
+
const taskArray = tasks && typeof tasks === "object" && !Array.isArray(tasks)
|
|
233
|
+
? Object.values(tasks as Record<string, { state: unknown }>)
|
|
234
|
+
: [];
|
|
235
|
+
const state = deriveJobStatusFromTasks(taskArray);
|
|
236
|
+
const record = mapStatusToRecord(data);
|
|
237
|
+
record.state = state;
|
|
238
|
+
results.push(record);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.warn(`[api] skipping unreadable current job ${entry}: ${(err as Error).message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Complete jobs
|
|
245
|
+
const completeEntries = await safeReaddir(this.paths.complete);
|
|
246
|
+
for (const entry of completeEntries) {
|
|
247
|
+
if (entry === ".gitkeep") continue;
|
|
248
|
+
const statusPath = path.join(this.paths.complete, entry, "tasks-status.json");
|
|
249
|
+
try {
|
|
250
|
+
const data = await readJsonFile<Record<string, unknown>>(statusPath);
|
|
251
|
+
const record = mapStatusToRecord(data);
|
|
252
|
+
record.state = "complete";
|
|
253
|
+
results.push(record);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.warn(`[api] skipping unreadable complete job ${entry}: ${(err as Error).message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return results;
|
|
60
260
|
}
|
|
61
261
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rm, mkdir, readdir } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { access } from "node:fs/promises";
|
|
@@ -286,6 +286,8 @@ describe("handleSubmit", () => {
|
|
|
286
286
|
let tmpDir: string;
|
|
287
287
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
288
288
|
let exitSpy: MockInstance<any>;
|
|
289
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
290
|
+
let cwdSpy: MockInstance<any>;
|
|
289
291
|
|
|
290
292
|
beforeEach(async () => {
|
|
291
293
|
tmpDir = await mkdtemp(join(tmpdir(), "pop-submit-test-"));
|
|
@@ -299,6 +301,7 @@ describe("handleSubmit", () => {
|
|
|
299
301
|
afterEach(async () => {
|
|
300
302
|
await rm(tmpDir, { recursive: true, force: true });
|
|
301
303
|
exitSpy.mockRestore();
|
|
304
|
+
if (cwdSpy) cwdSpy.mockRestore();
|
|
302
305
|
vi.clearAllMocks();
|
|
303
306
|
});
|
|
304
307
|
|
|
@@ -314,12 +317,43 @@ describe("handleSubmit", () => {
|
|
|
314
317
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
315
318
|
});
|
|
316
319
|
|
|
317
|
-
it("
|
|
320
|
+
it("submits a valid seed to a configured workspace", async () => {
|
|
321
|
+
// Scaffold workspace: registry, pipeline config, and pending directory
|
|
322
|
+
const configDir = join(tmpDir, "pipeline-config", "test-pipeline");
|
|
323
|
+
await mkdir(configDir, { recursive: true });
|
|
324
|
+
await mkdir(join(tmpDir, "pipeline-data", "pending"), { recursive: true });
|
|
325
|
+
|
|
326
|
+
await Bun.write(
|
|
327
|
+
join(tmpDir, "pipeline-config", "registry.json"),
|
|
328
|
+
JSON.stringify({
|
|
329
|
+
pipelines: {
|
|
330
|
+
"test-pipeline": {
|
|
331
|
+
configDir,
|
|
332
|
+
tasksDir: join(configDir, "tasks"),
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
await Bun.write(
|
|
338
|
+
join(configDir, "pipeline.json"),
|
|
339
|
+
JSON.stringify({ name: "test-pipeline", tasks: [] }),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Write valid seed file
|
|
318
343
|
const seedPath = join(tmpDir, "seed.json");
|
|
319
344
|
await Bun.write(seedPath, JSON.stringify({ pipeline: "test-pipeline" }));
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
345
|
+
|
|
346
|
+
// Stub process.cwd() so handleSubmit resolves the workspace root
|
|
347
|
+
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(tmpDir);
|
|
348
|
+
|
|
349
|
+
await handleSubmit(seedPath);
|
|
350
|
+
|
|
351
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
352
|
+
|
|
353
|
+
// Verify a seed file was written to pending
|
|
354
|
+
const pendingFiles = await readdir(join(tmpDir, "pipeline-data", "pending"));
|
|
355
|
+
const seedFiles = pendingFiles.filter((f) => f.endsWith("-seed.json"));
|
|
356
|
+
expect(seedFiles.length).toBe(1);
|
|
323
357
|
});
|
|
324
358
|
});
|
|
325
359
|
|