@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.3
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/docs/http-api.md +66 -0
- package/package.json +1 -1
- package/src/api/__tests__/index.test.ts +311 -3
- package/src/api/index.ts +94 -45
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/config.test.ts +31 -1
- package/src/core/__tests__/job-concurrency.test.ts +60 -0
- package/src/core/__tests__/job-view.test.ts +859 -0
- package/src/core/__tests__/orchestrator.test.ts +242 -0
- package/src/core/__tests__/runner-liveness.test.ts +865 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/config.ts +19 -0
- package/src/core/job-concurrency.ts +6 -1
- package/src/core/job-view.ts +329 -0
- package/src/core/orchestrator.ts +78 -6
- package/src/core/runner-liveness.ts +276 -0
- package/src/core/status-writer.ts +24 -5
- package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
- package/src/ui/client/adapters/job-adapter.ts +10 -0
- package/src/ui/client/types.ts +2 -0
- package/src/ui/components/JobTable.tsx +29 -12
- package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-L6cvsCAx.js} +25 -12
- package/src/ui/dist/assets/{index--RH3sAt3.js.map → index-L6cvsCAx.js.map} +1 -1
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +57 -1
- package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
- package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +7 -68
- package/src/ui/server/endpoints/job-endpoints.ts +6 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/state/__tests__/snapshot.test.ts +51 -0
- package/src/ui/state/__tests__/types.test.ts +98 -5
- package/src/ui/state/snapshot.ts +1 -1
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
- package/src/ui/state/transformers/list-transformer.ts +7 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +9 -47
package/docs/http-api.md
CHANGED
|
@@ -12,6 +12,41 @@ This document is the authoritative reference for the Prompt Orchestration Pipeli
|
|
|
12
12
|
- **CORS**: Opt-in via `--cors-origins <comma-separated-origins>` and `--cors-allow-null-origin`. When no origins are configured, no `Access-Control-*` headers are emitted. Cross-origin mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) to `/api/*` are rejected before dispatch when the origin is not allowed.
|
|
13
13
|
- **Content type**: All JSON responses use `Content-Type: application/json`. SSE streams use `Content-Type: text/event-stream`.
|
|
14
14
|
|
|
15
|
+
## Canonical Job Status
|
|
16
|
+
|
|
17
|
+
Job `status` values returned by `/api/jobs` and `/api/jobs/:id` are drawn
|
|
18
|
+
from the canonical `JobStatus` enum:
|
|
19
|
+
|
|
20
|
+
| Value | Meaning |
|
|
21
|
+
|-------|---------|
|
|
22
|
+
| `pending` | Queued, not yet started |
|
|
23
|
+
| `running` | One or more tasks are executing |
|
|
24
|
+
| `waiting` | Paused on a human gate (approve/reject) |
|
|
25
|
+
| `complete` | All terminal tasks finished successfully (persisted `state: "done"` is mapped to this value on read) |
|
|
26
|
+
| `failed` | A task or gate failed (persisted `state: "failed"` and legacy `status: "error"` both surface as this value) |
|
|
27
|
+
|
|
28
|
+
The canonical vocabulary is the only vocabulary the server emits. The
|
|
29
|
+
reader tolerates legacy spellings (`done`, `error`, `completed`) on
|
|
30
|
+
input, but the wire `status` is always one of the five values above.
|
|
31
|
+
|
|
32
|
+
### Interrupted runners
|
|
33
|
+
|
|
34
|
+
When a pipeline runner process dies before its job reaches a terminal
|
|
35
|
+
state (crash, OOM, `SIGKILL`, or the orchestrator dying alongside it),
|
|
36
|
+
the orchestrator marks the orphaned `running` task and the job `failed`
|
|
37
|
+
durably — on runner exit when the orchestrator observed the child, and
|
|
38
|
+
on the next orchestrator startup via a single boot sweep over
|
|
39
|
+
`current/` for jobs orphaned while the orchestrator was down (including
|
|
40
|
+
detached resume runners from `/restart` and `/tasks/:taskId/start`).
|
|
41
|
+
The persisted states stay the existing `failed`; the distinction from a
|
|
42
|
+
task-logic failure is carried by `tasks[taskId].errorContext.kind =
|
|
43
|
+
"runner-interrupted"` when a running task is attributable, or by root
|
|
44
|
+
`errorContext.kind = "runner-interrupted"` when only the job-level
|
|
45
|
+
status remained `running` (with `code`, `signal`, `source`). Recovery stays
|
|
46
|
+
a client action via `POST /api/jobs/:jobId/restart` or
|
|
47
|
+
`POST /api/jobs/:jobId/tasks/:taskId/start`; the orchestrator does not
|
|
48
|
+
auto-respawn.
|
|
49
|
+
|
|
15
50
|
## Response Envelope
|
|
16
51
|
|
|
17
52
|
Every JSON response uses the shape:
|
|
@@ -230,6 +265,37 @@ Capability negotiation is not built into this protocol. Clients discover compati
|
|
|
230
265
|
- **Adding a new event type** is a **minor** change (given the robustness rule above).
|
|
231
266
|
- **Changing an existing event's payload shape** (removing or renaming a field, changing a field's type) is a **breaking** change.
|
|
232
267
|
|
|
268
|
+
## Breaking Changes
|
|
269
|
+
|
|
270
|
+
The changes below are observable to external HTTP/API consumers. They
|
|
271
|
+
are documented here because the spec does not yet ship a real
|
|
272
|
+
schema/version discriminator (deferred to #32). A dated entry is
|
|
273
|
+
added for every change so consumers can grep for the cutover.
|
|
274
|
+
|
|
275
|
+
### 2026-06-19 — Canonical job status and name-keyed task ids
|
|
276
|
+
|
|
277
|
+
The persisted `JobState` vocabulary (`done`) and the legacy HTTP
|
|
278
|
+
`status` field (`error`) are both mapped to the canonical `JobStatus`
|
|
279
|
+
enum on read. Task ids are now name-keyed on the server and the
|
|
280
|
+
browser; an unnamed array entry falls back to a zero-based
|
|
281
|
+
`task-${index}` (e.g. `task-0`, `task-1`) so server and browser agree.
|
|
282
|
+
|
|
283
|
+
| Surface | Before | After |
|
|
284
|
+
|---------|--------|-------|
|
|
285
|
+
| `GET /api/jobs`, `GET /api/jobs/:id` → `status` | `"error"` | `"failed"` |
|
|
286
|
+
| `GET /api/jobs`, `GET /api/jobs/:id` → `status` | `"done"` | `"complete"` |
|
|
287
|
+
| `PipelineOrchestrator.getStatus(...)` → `state` | `"done"` | `"complete"` |
|
|
288
|
+
| `PipelineOrchestrator.listJobs()` → `state` (per job) | mixed (`done` vs `complete`) | always `"complete"` |
|
|
289
|
+
| `tasks` keys (server and browser) | mixed (`task-0`/`task-1` split) | name-keyed; `task-${index}` only for unnamed array entries |
|
|
290
|
+
|
|
291
|
+
Legacy spellings (`done`, `error`, `completed`) are still **accepted**
|
|
292
|
+
on input by `normalizeJobStatus` / `normalizeJobView` so a mixed
|
|
293
|
+
deploy or stale on-disk file does not mis-render. The server **never
|
|
294
|
+
emits** `done` / `error` as a job status.
|
|
295
|
+
|
|
296
|
+
This is the only external break signal shipped with this change.
|
|
297
|
+
A durable schema/version discriminator is tracked in #32.
|
|
298
|
+
|
|
233
299
|
## Protocol Versioning
|
|
234
300
|
|
|
235
301
|
The `protocolVersion` integer returned by `GET /api/meta` is the protocol's semver indicator:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
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",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdtemp, rm, mkdir, readdir } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -214,7 +214,7 @@ describe("PipelineOrchestrator.getStatus", () => {
|
|
|
214
214
|
id: jobId,
|
|
215
215
|
name: "done-job",
|
|
216
216
|
pipeline: "test-pipeline",
|
|
217
|
-
state: "
|
|
217
|
+
state: "done",
|
|
218
218
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
219
219
|
tasks: {},
|
|
220
220
|
}),
|
|
@@ -225,6 +225,7 @@ describe("PipelineOrchestrator.getStatus", () => {
|
|
|
225
225
|
|
|
226
226
|
expect(record.jobId).toBe(jobId);
|
|
227
227
|
expect(record.jobName).toBe("done-job");
|
|
228
|
+
expect(record.state).toBe("complete");
|
|
228
229
|
});
|
|
229
230
|
|
|
230
231
|
it("finds job by name scan fallback", async () => {
|
|
@@ -272,6 +273,57 @@ describe("PipelineOrchestrator.getStatus", () => {
|
|
|
272
273
|
|
|
273
274
|
await expect(orch.getStatus("nonexistent-id")).rejects.toThrow("not found");
|
|
274
275
|
});
|
|
276
|
+
|
|
277
|
+
it("returns unreadable placeholder for corrupt current job instead of throwing", async () => {
|
|
278
|
+
const jobId = "33333333-4444-5555-6666-777777777777";
|
|
279
|
+
const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
|
|
280
|
+
await mkdir(jobDir, { recursive: true });
|
|
281
|
+
await Bun.write(join(jobDir, "tasks-status.json"), "{{{not valid json");
|
|
282
|
+
|
|
283
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
284
|
+
const record = await orch.getStatus(jobId);
|
|
285
|
+
|
|
286
|
+
expect(record.jobId).toBe(jobId);
|
|
287
|
+
expect(record.state).toBe("failed");
|
|
288
|
+
expect(record.readable).toBe(false);
|
|
289
|
+
expect(record.error).toBeDefined();
|
|
290
|
+
const error = record.error as { code: string; message: string; path: string };
|
|
291
|
+
expect(error.code).toBe("STATUS_CORRUPT");
|
|
292
|
+
expect(error.path).toBe(join(jobDir, "tasks-status.json"));
|
|
293
|
+
expect(error.message).toContain("Repair or delete");
|
|
294
|
+
expect(error.message).toContain(jobId);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("skips unexpected current read errors and falls back to complete lookup", async () => {
|
|
298
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
299
|
+
const jobId = "33333333-4444-5555-6666-888888888888";
|
|
300
|
+
const currentDir = join(tmpDir, "pipeline-data", "current", jobId);
|
|
301
|
+
await mkdir(currentDir, { recursive: true });
|
|
302
|
+
await mkdir(join(currentDir, "tasks-status.json"));
|
|
303
|
+
|
|
304
|
+
const completeDir = join(tmpDir, "pipeline-data", "complete", jobId);
|
|
305
|
+
await mkdir(completeDir, { recursive: true });
|
|
306
|
+
await Bun.write(
|
|
307
|
+
join(completeDir, "tasks-status.json"),
|
|
308
|
+
JSON.stringify({
|
|
309
|
+
id: jobId,
|
|
310
|
+
name: "complete-fallback",
|
|
311
|
+
pipeline: "test-pipeline",
|
|
312
|
+
state: "done",
|
|
313
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
314
|
+
tasks: {},
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
319
|
+
const record = await orch.getStatus(jobId);
|
|
320
|
+
|
|
321
|
+
expect(record.jobId).toBe(jobId);
|
|
322
|
+
expect(record.jobName).toBe("complete-fallback");
|
|
323
|
+
expect(record.state).toBe("complete");
|
|
324
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining(jobId));
|
|
325
|
+
warn.mockRestore();
|
|
326
|
+
});
|
|
275
327
|
});
|
|
276
328
|
|
|
277
329
|
// ─── PipelineOrchestrator.listJobs ───────────────────────────────────────────
|
|
@@ -330,7 +382,7 @@ describe("PipelineOrchestrator.listJobs", () => {
|
|
|
330
382
|
id: completeId,
|
|
331
383
|
name: "complete-one",
|
|
332
384
|
pipeline: "test-pipeline",
|
|
333
|
-
state: "
|
|
385
|
+
state: "done",
|
|
334
386
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
335
387
|
tasks: {},
|
|
336
388
|
}),
|
|
@@ -369,4 +421,260 @@ describe("PipelineOrchestrator.listJobs", () => {
|
|
|
369
421
|
|
|
370
422
|
expect(jobs).toEqual([]);
|
|
371
423
|
});
|
|
424
|
+
|
|
425
|
+
it("surfaces corrupt current job as unreadable placeholder alongside valid and pending jobs", async () => {
|
|
426
|
+
const pendingId = "bbbb-1111-2222-3333-0001";
|
|
427
|
+
await Bun.write(
|
|
428
|
+
join(tmpDir, "pipeline-data", "pending", `${pendingId}-seed.json`),
|
|
429
|
+
JSON.stringify({ pipeline: "test-pipeline", name: "pending-one" }),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const validCurrentId = "bbbb-1111-2222-3333-0002";
|
|
433
|
+
const validCurrentDir = join(tmpDir, "pipeline-data", "current", validCurrentId);
|
|
434
|
+
await mkdir(validCurrentDir, { recursive: true });
|
|
435
|
+
await Bun.write(
|
|
436
|
+
join(validCurrentDir, "tasks-status.json"),
|
|
437
|
+
JSON.stringify({
|
|
438
|
+
id: validCurrentId,
|
|
439
|
+
name: "valid-current",
|
|
440
|
+
pipeline: "test-pipeline",
|
|
441
|
+
state: "running",
|
|
442
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
443
|
+
tasks: {},
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
const corruptCurrentId = "bbbb-1111-2222-3333-0003";
|
|
448
|
+
const corruptCurrentDir = join(tmpDir, "pipeline-data", "current", corruptCurrentId);
|
|
449
|
+
await mkdir(corruptCurrentDir, { recursive: true });
|
|
450
|
+
await Bun.write(join(corruptCurrentDir, "tasks-status.json"), "{{{not valid json");
|
|
451
|
+
|
|
452
|
+
const completeId = "bbbb-1111-2222-3333-0004";
|
|
453
|
+
const completeDir = join(tmpDir, "pipeline-data", "complete", completeId);
|
|
454
|
+
await mkdir(completeDir, { recursive: true });
|
|
455
|
+
await Bun.write(
|
|
456
|
+
join(completeDir, "tasks-status.json"),
|
|
457
|
+
JSON.stringify({
|
|
458
|
+
id: completeId,
|
|
459
|
+
name: "complete-one",
|
|
460
|
+
pipeline: "test-pipeline",
|
|
461
|
+
state: "done",
|
|
462
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
463
|
+
tasks: {},
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
468
|
+
const jobs = await orch.listJobs();
|
|
469
|
+
|
|
470
|
+
expect(jobs.length).toBe(4);
|
|
471
|
+
|
|
472
|
+
const ids = jobs.map((j) => j.jobId);
|
|
473
|
+
expect(ids).toContain(pendingId);
|
|
474
|
+
expect(ids).toContain(validCurrentId);
|
|
475
|
+
expect(ids).toContain(corruptCurrentId);
|
|
476
|
+
expect(ids).toContain(completeId);
|
|
477
|
+
|
|
478
|
+
const corrupt = jobs.find((j) => j.jobId === corruptCurrentId)!;
|
|
479
|
+
expect(corrupt.state).toBe("failed");
|
|
480
|
+
expect(corrupt.readable).toBe(false);
|
|
481
|
+
const corruptError = corrupt.error as { code: string; message: string; path: string };
|
|
482
|
+
expect(corruptError.code).toBe("STATUS_CORRUPT");
|
|
483
|
+
expect(corruptError.path).toBe(join(corruptCurrentDir, "tasks-status.json"));
|
|
484
|
+
expect(corruptError.message).toContain("Repair or delete");
|
|
485
|
+
expect(corruptError.message).toContain(corruptCurrentId);
|
|
486
|
+
|
|
487
|
+
const valid = jobs.find((j) => j.jobId === validCurrentId)!;
|
|
488
|
+
expect(valid.readable).toBeUndefined();
|
|
489
|
+
expect(valid.error).toBeUndefined();
|
|
490
|
+
expect(valid.jobName).toBe("valid-current");
|
|
491
|
+
|
|
492
|
+
const pending = jobs.find((j) => j.jobId === pendingId)!;
|
|
493
|
+
expect(pending.state).toBe("pending");
|
|
494
|
+
expect(pending.readable).toBeUndefined();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("skips current job whose status file is missing", async () => {
|
|
498
|
+
const missingId = "cccc-1111-2222-3333-0001";
|
|
499
|
+
const missingDir = join(tmpDir, "pipeline-data", "current", missingId);
|
|
500
|
+
await mkdir(missingDir, { recursive: true });
|
|
501
|
+
|
|
502
|
+
const validId = "cccc-1111-2222-3333-0002";
|
|
503
|
+
const validDir = join(tmpDir, "pipeline-data", "current", validId);
|
|
504
|
+
await mkdir(validDir, { recursive: true });
|
|
505
|
+
await Bun.write(
|
|
506
|
+
join(validDir, "tasks-status.json"),
|
|
507
|
+
JSON.stringify({
|
|
508
|
+
id: validId,
|
|
509
|
+
name: "valid-current",
|
|
510
|
+
pipeline: "test-pipeline",
|
|
511
|
+
state: "running",
|
|
512
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
513
|
+
tasks: {},
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
518
|
+
const jobs = await orch.listJobs();
|
|
519
|
+
|
|
520
|
+
const ids = jobs.map((j) => j.jobId);
|
|
521
|
+
expect(ids).toContain(validId);
|
|
522
|
+
expect(ids).not.toContain(missingId);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("skips current job with unexpected status read error without throwing", async () => {
|
|
526
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
527
|
+
const unreadableId = "dddd-1111-2222-3333-0001";
|
|
528
|
+
const unreadableDir = join(tmpDir, "pipeline-data", "current", unreadableId);
|
|
529
|
+
await mkdir(unreadableDir, { recursive: true });
|
|
530
|
+
await mkdir(join(unreadableDir, "tasks-status.json"));
|
|
531
|
+
|
|
532
|
+
const validId = "dddd-1111-2222-3333-0002";
|
|
533
|
+
const validDir = join(tmpDir, "pipeline-data", "current", validId);
|
|
534
|
+
await mkdir(validDir, { recursive: true });
|
|
535
|
+
await Bun.write(
|
|
536
|
+
join(validDir, "tasks-status.json"),
|
|
537
|
+
JSON.stringify({
|
|
538
|
+
id: validId,
|
|
539
|
+
name: "valid-current",
|
|
540
|
+
pipeline: "test-pipeline",
|
|
541
|
+
state: "running",
|
|
542
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
543
|
+
tasks: {},
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
548
|
+
const jobs = await orch.listJobs();
|
|
549
|
+
|
|
550
|
+
const ids = jobs.map((j) => j.jobId);
|
|
551
|
+
expect(ids).toContain(validId);
|
|
552
|
+
expect(ids).not.toContain(unreadableId);
|
|
553
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining(unreadableId));
|
|
554
|
+
warn.mockRestore();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("getStatus and listJobs return identical state for the same fixture", async () => {
|
|
558
|
+
const jobId = "cccc-5555-6666-7777-0001";
|
|
559
|
+
const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
|
|
560
|
+
await mkdir(jobDir, { recursive: true });
|
|
561
|
+
await Bun.write(
|
|
562
|
+
join(jobDir, "tasks-status.json"),
|
|
563
|
+
JSON.stringify({
|
|
564
|
+
id: jobId,
|
|
565
|
+
name: "cross-surface",
|
|
566
|
+
pipeline: "test-pipeline",
|
|
567
|
+
state: "running",
|
|
568
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
569
|
+
tasks: {
|
|
570
|
+
a: { state: "running" },
|
|
571
|
+
b: { state: "pending" },
|
|
572
|
+
},
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
577
|
+
const getStatusRecord = await orch.getStatus(jobId);
|
|
578
|
+
const listJobsRecords = await orch.listJobs();
|
|
579
|
+
const listJobsRecord = listJobsRecords.find((j) => j.jobId === jobId)!;
|
|
580
|
+
|
|
581
|
+
expect(getStatusRecord.state).toBe("running");
|
|
582
|
+
expect(listJobsRecord.state).toBe("running");
|
|
583
|
+
expect(getStatusRecord.state).toBe(listJobsRecord.state);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("getStatus returns canonical 'complete' for persisted 'done' state", async () => {
|
|
587
|
+
const jobId = "cccc-5555-6666-7777-0002";
|
|
588
|
+
const jobDir = join(tmpDir, "pipeline-data", "complete", jobId);
|
|
589
|
+
await mkdir(jobDir, { recursive: true });
|
|
590
|
+
await Bun.write(
|
|
591
|
+
join(jobDir, "tasks-status.json"),
|
|
592
|
+
JSON.stringify({
|
|
593
|
+
id: jobId,
|
|
594
|
+
name: "done-canonical",
|
|
595
|
+
pipeline: "test-pipeline",
|
|
596
|
+
state: "done",
|
|
597
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
598
|
+
tasks: { a: { state: "done" } },
|
|
599
|
+
}),
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
603
|
+
const record = await orch.getStatus(jobId);
|
|
604
|
+
|
|
605
|
+
expect(record.state).toBe("complete");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("listJobs returns canonical 'complete' for persisted 'done' state without force", async () => {
|
|
609
|
+
const jobId = "cccc-5555-6666-7777-0003";
|
|
610
|
+
const jobDir = join(tmpDir, "pipeline-data", "complete", jobId);
|
|
611
|
+
await mkdir(jobDir, { recursive: true });
|
|
612
|
+
await Bun.write(
|
|
613
|
+
join(jobDir, "tasks-status.json"),
|
|
614
|
+
JSON.stringify({
|
|
615
|
+
id: jobId,
|
|
616
|
+
name: "done-list",
|
|
617
|
+
pipeline: "test-pipeline",
|
|
618
|
+
state: "done",
|
|
619
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
620
|
+
tasks: { a: { state: "done" } },
|
|
621
|
+
}),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
625
|
+
const jobs = await orch.listJobs();
|
|
626
|
+
const record = jobs.find((j) => j.jobId === jobId)!;
|
|
627
|
+
|
|
628
|
+
expect(record.state).toBe("complete");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("getStatus maps persisted 'error' legacy status to canonical 'failed'", async () => {
|
|
632
|
+
const jobId = "cccc-5555-6666-7777-0004";
|
|
633
|
+
const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
|
|
634
|
+
await mkdir(jobDir, { recursive: true });
|
|
635
|
+
await Bun.write(
|
|
636
|
+
join(jobDir, "tasks-status.json"),
|
|
637
|
+
JSON.stringify({
|
|
638
|
+
id: jobId,
|
|
639
|
+
name: "legacy-error",
|
|
640
|
+
pipeline: "test-pipeline",
|
|
641
|
+
state: "unknown-state",
|
|
642
|
+
status: "error",
|
|
643
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
644
|
+
tasks: { a: { state: "failed" } },
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
649
|
+
const record = await orch.getStatus(jobId);
|
|
650
|
+
|
|
651
|
+
expect(record.state).toBe("failed");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("listJobs preserves JobView extras (progress, costs)", async () => {
|
|
655
|
+
const jobId = "cccc-5555-6666-7777-0005";
|
|
656
|
+
const jobDir = join(tmpDir, "pipeline-data", "current", jobId);
|
|
657
|
+
await mkdir(jobDir, { recursive: true });
|
|
658
|
+
await Bun.write(
|
|
659
|
+
join(jobDir, "tasks-status.json"),
|
|
660
|
+
JSON.stringify({
|
|
661
|
+
id: jobId,
|
|
662
|
+
name: "with-extras",
|
|
663
|
+
pipeline: "test-pipeline",
|
|
664
|
+
state: "running",
|
|
665
|
+
progress: 42,
|
|
666
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
667
|
+
costs: { usd: 1.23 },
|
|
668
|
+
tasks: { a: { state: "running" } },
|
|
669
|
+
}),
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
const orch = new PipelineOrchestrator({ autoStart: false });
|
|
673
|
+
const jobs = await orch.listJobs();
|
|
674
|
+
const record = jobs.find((j) => j.jobId === jobId)!;
|
|
675
|
+
|
|
676
|
+
expect(record.state).toBe("running");
|
|
677
|
+
expect(record.progress).toBe(42);
|
|
678
|
+
expect(record.costs).toEqual({ usd: 1.23 });
|
|
679
|
+
});
|
|
372
680
|
});
|
package/src/api/index.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { mkdir, readdir, rename, stat } from "node:fs/promises";
|
|
|
3
3
|
import { resolvePipelinePaths, getPendingSeedPath } from "../config/paths";
|
|
4
4
|
import type { PipelinePaths } from "../config/paths";
|
|
5
5
|
import { getPipelineConfig } from "../core/config";
|
|
6
|
-
import {
|
|
6
|
+
import { normalizeJobView } from "../core/job-view";
|
|
7
|
+
import type { JobView } from "../core/job-view";
|
|
7
8
|
import { SEED_PATTERN } from "../core/orchestrator";
|
|
9
|
+
import { readJobStatusResult, STATUS_FILENAME, type StatusReadResult } from "../core/status-writer";
|
|
8
10
|
|
|
9
11
|
/** Result of a successful job submission. */
|
|
10
12
|
export interface SubmitSuccessResult {
|
|
@@ -27,7 +29,12 @@ export interface SubmitJobOptions {
|
|
|
27
29
|
seedObject: unknown;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Job status record returned by getStatus.
|
|
34
|
+
*
|
|
35
|
+
* Additive optional fields (present only when the on-disk status is unreadable):
|
|
36
|
+
* `readable: false` and `error: { code, message, path }`. Absent ⇒ normal/readable.
|
|
37
|
+
*/
|
|
31
38
|
export interface JobStatusRecord {
|
|
32
39
|
jobId: string;
|
|
33
40
|
jobName: string;
|
|
@@ -71,21 +78,68 @@ async function safeReaddir(dirPath: string): Promise<string[]> {
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
function locationForPath(paths: PipelinePaths, jobDir: string): "current" | "complete" {
|
|
82
|
+
if (jobDir.startsWith(paths.current + path.sep) || jobDir === paths.current) return "current";
|
|
83
|
+
return "complete";
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
async function readJsonFile<T = unknown>(filePath: string): Promise<T> {
|
|
75
87
|
const text = await Bun.file(filePath).text();
|
|
76
88
|
return JSON.parse(text) as T;
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
function
|
|
80
|
-
const {
|
|
81
|
-
|
|
82
|
-
jobId:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
function toJobStatusRecord(view: JobView): JobStatusRecord {
|
|
92
|
+
const {
|
|
93
|
+
id: _id,
|
|
94
|
+
jobId: _jobId,
|
|
95
|
+
name,
|
|
96
|
+
title: _title,
|
|
97
|
+
status,
|
|
98
|
+
pipeline,
|
|
99
|
+
createdAt,
|
|
100
|
+
readable,
|
|
101
|
+
error,
|
|
102
|
+
...rest
|
|
103
|
+
} = view;
|
|
104
|
+
const record: JobStatusRecord = {
|
|
105
|
+
jobId: view.jobId,
|
|
106
|
+
jobName: name,
|
|
107
|
+
pipeline: pipeline ?? "",
|
|
108
|
+
state: status,
|
|
109
|
+
createdAt: createdAt ?? "",
|
|
87
110
|
...rest,
|
|
88
111
|
};
|
|
112
|
+
if (readable !== undefined) record["readable"] = readable;
|
|
113
|
+
if (error !== undefined) record["error"] = error;
|
|
114
|
+
return record;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildCorruptPlaceholder(jobDir: string, jobId: string): JobStatusRecord {
|
|
118
|
+
const statusPath = path.join(jobDir, STATUS_FILENAME);
|
|
119
|
+
return {
|
|
120
|
+
jobId,
|
|
121
|
+
jobName: jobId,
|
|
122
|
+
pipeline: "",
|
|
123
|
+
state: "failed",
|
|
124
|
+
createdAt: "",
|
|
125
|
+
readable: false,
|
|
126
|
+
error: {
|
|
127
|
+
code: "STATUS_CORRUPT",
|
|
128
|
+
message:
|
|
129
|
+
`Status file is unreadable (corrupt). ` +
|
|
130
|
+
`Repair or delete ${statusPath} for job "${jobId}" to recover.`,
|
|
131
|
+
path: statusPath,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readStatusForApi(jobDir: string, label: string): Promise<StatusReadResult | null> {
|
|
137
|
+
try {
|
|
138
|
+
return await readJobStatusResult(jobDir);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.warn(`[api] skipping unreadable status ${label}: ${(err as Error).message}`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
89
143
|
}
|
|
90
144
|
|
|
91
145
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
@@ -147,13 +201,15 @@ export class PipelineOrchestrator {
|
|
|
147
201
|
async getStatus(jobName: string): Promise<JobStatusRecord> {
|
|
148
202
|
// 1. Direct jobId lookup in current, then complete
|
|
149
203
|
for (const dir of [this.paths.current, this.paths.complete]) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
204
|
+
const jobDir = path.join(dir, jobName);
|
|
205
|
+
const result = await readStatusForApi(jobDir, jobName);
|
|
206
|
+
if (!result) continue;
|
|
207
|
+
if (result.ok) {
|
|
208
|
+
return toJobStatusRecord(
|
|
209
|
+
normalizeJobView(result.value, jobName, locationForPath(this.paths, jobDir)),
|
|
210
|
+
);
|
|
156
211
|
}
|
|
212
|
+
if (result.reason === "corrupt") return buildCorruptPlaceholder(jobDir, jobName);
|
|
157
213
|
}
|
|
158
214
|
|
|
159
215
|
// 2. Name scan fallback
|
|
@@ -161,14 +217,14 @@ export class PipelineOrchestrator {
|
|
|
161
217
|
const entries = await safeReaddir(dir);
|
|
162
218
|
for (const entry of entries) {
|
|
163
219
|
if (entry === ".gitkeep") continue;
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
220
|
+
const jobDir = path.join(dir, entry);
|
|
221
|
+
const result = await readStatusForApi(jobDir, entry);
|
|
222
|
+
if (!result) continue;
|
|
223
|
+
if (!result.ok) continue;
|
|
224
|
+
if (result.value["name"] === jobName) {
|
|
225
|
+
return toJobStatusRecord(
|
|
226
|
+
normalizeJobView(result.value, entry, locationForPath(this.paths, jobDir)),
|
|
227
|
+
);
|
|
172
228
|
}
|
|
173
229
|
}
|
|
174
230
|
}
|
|
@@ -225,35 +281,28 @@ export class PipelineOrchestrator {
|
|
|
225
281
|
const currentEntries = await safeReaddir(this.paths.current);
|
|
226
282
|
for (const entry of currentEntries) {
|
|
227
283
|
if (entry === ".gitkeep") continue;
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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}`);
|
|
284
|
+
const jobDir = path.join(this.paths.current, entry);
|
|
285
|
+
const result = await readStatusForApi(jobDir, entry);
|
|
286
|
+
if (!result) continue;
|
|
287
|
+
if (!result.ok) {
|
|
288
|
+
if (result.reason === "corrupt") results.push(buildCorruptPlaceholder(jobDir, entry));
|
|
289
|
+
continue;
|
|
241
290
|
}
|
|
291
|
+
results.push(toJobStatusRecord(normalizeJobView(result.value, entry, "current")));
|
|
242
292
|
}
|
|
243
293
|
|
|
244
294
|
// Complete jobs
|
|
245
295
|
const completeEntries = await safeReaddir(this.paths.complete);
|
|
246
296
|
for (const entry of completeEntries) {
|
|
247
297
|
if (entry === ".gitkeep") continue;
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
} catch (err) {
|
|
255
|
-
console.warn(`[api] skipping unreadable complete job ${entry}: ${(err as Error).message}`);
|
|
298
|
+
const jobDir = path.join(this.paths.complete, entry);
|
|
299
|
+
const result = await readStatusForApi(jobDir, entry);
|
|
300
|
+
if (!result) continue;
|
|
301
|
+
if (!result.ok) {
|
|
302
|
+
if (result.reason === "corrupt") results.push(buildCorruptPlaceholder(jobDir, entry));
|
|
303
|
+
continue;
|
|
256
304
|
}
|
|
305
|
+
results.push(toJobStatusRecord(normalizeJobView(result.value, entry, "complete")));
|
|
257
306
|
}
|
|
258
307
|
|
|
259
308
|
return results;
|