@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.0.5",
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
- * Validates and submits a job to the pipeline data directory.
39
- * Stub implementation to be replaced when api module is fully migrated.
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
- _opts: SubmitJobOptions
94
+ opts: SubmitJobOptions,
43
95
  ): Promise<SubmitResult> {
44
- throw new Error("submitJobWithValidation: not yet implemented");
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
- constructor(_opts: OrchestratorOptions) {}
137
+ private readonly autoStart: boolean;
138
+ private readonly root: string;
139
+ private readonly paths: PipelinePaths;
53
140
 
54
- async getStatus(_jobName: string): Promise<JobStatusRecord> {
55
- throw new Error("PipelineOrchestrator.getStatus: not yet implemented");
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
- throw new Error("PipelineOrchestrator.listJobs: not yet implemented");
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("exits with 1 when submitJobWithValidation throws (API not yet implemented)", async () => {
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
- // submitJobWithValidation throws "not yet implemented" — treated as API failure
321
- await expect(handleSubmit(seedPath)).rejects.toThrow("process.exit(1)");
322
- expect(exitSpy).toHaveBeenCalledWith(1);
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