@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.
Files changed (46) hide show
  1. package/docs/http-api.md +66 -0
  2. package/package.json +1 -1
  3. package/src/api/__tests__/index.test.ts +311 -3
  4. package/src/api/index.ts +94 -45
  5. package/src/config/__tests__/statuses.test.ts +41 -0
  6. package/src/config/statuses.ts +20 -1
  7. package/src/core/__tests__/config.test.ts +31 -1
  8. package/src/core/__tests__/job-concurrency.test.ts +60 -0
  9. package/src/core/__tests__/job-view.test.ts +859 -0
  10. package/src/core/__tests__/orchestrator.test.ts +242 -0
  11. package/src/core/__tests__/runner-liveness.test.ts +865 -0
  12. package/src/core/__tests__/single-derivation.test.ts +159 -0
  13. package/src/core/config.ts +19 -0
  14. package/src/core/job-concurrency.ts +6 -1
  15. package/src/core/job-view.ts +329 -0
  16. package/src/core/orchestrator.ts +78 -6
  17. package/src/core/runner-liveness.ts +276 -0
  18. package/src/core/status-writer.ts +24 -5
  19. package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
  20. package/src/ui/client/adapters/job-adapter.ts +10 -0
  21. package/src/ui/client/types.ts +2 -0
  22. package/src/ui/components/JobTable.tsx +29 -12
  23. package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
  24. package/src/ui/components/types.ts +2 -0
  25. package/src/ui/dist/assets/{index--RH3sAt3.js → index-L6cvsCAx.js} +25 -12
  26. package/src/ui/dist/assets/{index--RH3sAt3.js.map → index-L6cvsCAx.js.map} +1 -1
  27. package/src/ui/dist/index.html +1 -1
  28. package/src/ui/embedded-assets.js +6 -6
  29. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  30. package/src/ui/server/__tests__/job-control-endpoints.test.ts +57 -1
  31. package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
  32. package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
  33. package/src/ui/server/config-bridge.ts +3 -4
  34. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  35. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  36. package/src/ui/server/endpoints/job-control-endpoints.ts +7 -68
  37. package/src/ui/server/endpoints/job-endpoints.ts +6 -0
  38. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  39. package/src/ui/state/__tests__/snapshot.test.ts +51 -0
  40. package/src/ui/state/__tests__/types.test.ts +98 -5
  41. package/src/ui/state/snapshot.ts +1 -1
  42. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
  43. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
  44. package/src/ui/state/transformers/list-transformer.ts +7 -3
  45. package/src/ui/state/transformers/status-transformer.ts +36 -170
  46. 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.2",
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: "complete",
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: "complete",
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 { deriveJobStatusFromTasks } from "../config/statuses";
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
- /** Job status record returned by getStatus. */
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 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 ?? ""),
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 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
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 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
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 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}`);
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 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}`);
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;