@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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
drainPendingQueue,
|
|
8
8
|
handleChildExit,
|
|
9
9
|
resolveDirs,
|
|
10
|
+
startOrchestrator,
|
|
10
11
|
promoteSeedFile,
|
|
11
12
|
restoreSeedFile,
|
|
12
13
|
} from "../orchestrator";
|
|
@@ -463,6 +464,146 @@ describe("handleChildExit", () => {
|
|
|
463
464
|
await rm(dir, { recursive: true });
|
|
464
465
|
}
|
|
465
466
|
});
|
|
467
|
+
|
|
468
|
+
test("handleChildExit still attempts reconciliation when slot release fails", async () => {
|
|
469
|
+
const dir = await setupDir("exit-release-fails-");
|
|
470
|
+
try {
|
|
471
|
+
const releaseError = new Error("release failed");
|
|
472
|
+
let drained = false;
|
|
473
|
+
let reconciled = false;
|
|
474
|
+
|
|
475
|
+
await expect(handleChildExit({
|
|
476
|
+
dataDir: dir,
|
|
477
|
+
jobId: "job-release-fails",
|
|
478
|
+
triggerDrain: () => {
|
|
479
|
+
drained = true;
|
|
480
|
+
},
|
|
481
|
+
lockTimeoutMs: 1000,
|
|
482
|
+
releaseSlot: async () => {
|
|
483
|
+
throw releaseError;
|
|
484
|
+
},
|
|
485
|
+
reconcileJob: async () => {
|
|
486
|
+
reconciled = true;
|
|
487
|
+
return { reconciled: false, reason: "unreadable" };
|
|
488
|
+
},
|
|
489
|
+
})).rejects.toThrow("release failed");
|
|
490
|
+
|
|
491
|
+
expect(reconciled).toBe(true);
|
|
492
|
+
expect(drained).toBe(true);
|
|
493
|
+
} finally {
|
|
494
|
+
await rm(dir, { recursive: true });
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("handleChildExit reports both cleanup errors when slot release and reconciliation fail", async () => {
|
|
499
|
+
const dir = await setupDir("exit-both-fail-");
|
|
500
|
+
try {
|
|
501
|
+
const releaseError = new Error("release failed");
|
|
502
|
+
const reconcileError = new Error("reconcile failed");
|
|
503
|
+
let drained = false;
|
|
504
|
+
|
|
505
|
+
let thrown: unknown;
|
|
506
|
+
try {
|
|
507
|
+
await handleChildExit({
|
|
508
|
+
dataDir: dir,
|
|
509
|
+
jobId: "job-both-fail",
|
|
510
|
+
triggerDrain: () => {
|
|
511
|
+
drained = true;
|
|
512
|
+
},
|
|
513
|
+
lockTimeoutMs: 1000,
|
|
514
|
+
releaseSlot: async () => {
|
|
515
|
+
throw releaseError;
|
|
516
|
+
},
|
|
517
|
+
reconcileJob: async () => {
|
|
518
|
+
throw reconcileError;
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
} catch (err) {
|
|
522
|
+
thrown = err;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
expect(thrown).toBeInstanceOf(AggregateError);
|
|
526
|
+
expect((thrown as AggregateError).message).toBe("child exit cleanup failed");
|
|
527
|
+
expect((thrown as AggregateError).errors).toEqual([releaseError, reconcileError]);
|
|
528
|
+
expect(drained).toBe(true);
|
|
529
|
+
} finally {
|
|
530
|
+
await rm(dir, { recursive: true });
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("AC-3: a mid-job runner exit marks the running task and job failed, releases the slot, and triggers a drain", async () => {
|
|
535
|
+
const dir = await setupDir("exit-reconcile-");
|
|
536
|
+
try {
|
|
537
|
+
const jobId = "job-running";
|
|
538
|
+
const jobDir = join(dir, "current", jobId);
|
|
539
|
+
await mkdir(jobDir, { recursive: true });
|
|
540
|
+
await writeFile(
|
|
541
|
+
join(jobDir, "tasks-status.json"),
|
|
542
|
+
JSON.stringify({
|
|
543
|
+
id: jobId,
|
|
544
|
+
state: "running",
|
|
545
|
+
current: "research",
|
|
546
|
+
currentStage: null,
|
|
547
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
548
|
+
tasks: { research: { state: "running" } },
|
|
549
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
await writeFile(join(jobDir, "runner.pid"), "999999");
|
|
553
|
+
|
|
554
|
+
let drained = false;
|
|
555
|
+
await handleChildExit({
|
|
556
|
+
dataDir: dir,
|
|
557
|
+
jobId,
|
|
558
|
+
triggerDrain: () => {
|
|
559
|
+
drained = true;
|
|
560
|
+
},
|
|
561
|
+
code: 1,
|
|
562
|
+
signal: null,
|
|
563
|
+
lockTimeoutMs: 1000,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
expect(drained).toBe(true);
|
|
567
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(dir);
|
|
568
|
+
expect(existsSync(join(runningJobsDir, `${jobId}.json`))).toBe(false);
|
|
569
|
+
|
|
570
|
+
const snapshot = JSON.parse(await readFile(join(jobDir, "tasks-status.json"), "utf-8")) as Record<string, unknown>;
|
|
571
|
+
expect(snapshot["state"]).toBe("failed");
|
|
572
|
+
const tasks = snapshot["tasks"] as Record<string, Record<string, unknown>>;
|
|
573
|
+
expect(tasks["research"]?.["state"]).toBe("failed");
|
|
574
|
+
expect(tasks["research"]?.["error"]).toBeTruthy();
|
|
575
|
+
const errorContext = tasks["research"]?.["errorContext"] as Record<string, unknown>;
|
|
576
|
+
expect(errorContext?.["kind"]).toBe("runner-interrupted");
|
|
577
|
+
expect(errorContext?.["code"]).toBe(1);
|
|
578
|
+
} finally {
|
|
579
|
+
await rm(dir, { recursive: true });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("AC-10: a job already moved to complete/ (no current/ status) is untouched, and the drain still fires", async () => {
|
|
584
|
+
const dir = await setupDir("exit-complete-");
|
|
585
|
+
try {
|
|
586
|
+
const jobId = "job-gone";
|
|
587
|
+
const statusPath = join(dir, "current", jobId, "tasks-status.json");
|
|
588
|
+
|
|
589
|
+
let drainCount = 0;
|
|
590
|
+
await handleChildExit({
|
|
591
|
+
dataDir: dir,
|
|
592
|
+
jobId,
|
|
593
|
+
triggerDrain: () => {
|
|
594
|
+
drainCount++;
|
|
595
|
+
},
|
|
596
|
+
code: 0,
|
|
597
|
+
signal: null,
|
|
598
|
+
lockTimeoutMs: 1000,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
expect(drainCount).toBe(1);
|
|
602
|
+
expect(existsSync(statusPath)).toBe(false);
|
|
603
|
+
} finally {
|
|
604
|
+
await rm(dir, { recursive: true });
|
|
605
|
+
}
|
|
606
|
+
});
|
|
466
607
|
});
|
|
467
608
|
|
|
468
609
|
describe("drainPendingQueue concurrency", () => {
|
|
@@ -512,3 +653,104 @@ describe("drainPendingQueue concurrency", () => {
|
|
|
512
653
|
}
|
|
513
654
|
});
|
|
514
655
|
});
|
|
656
|
+
|
|
657
|
+
interface FakeWatcher {
|
|
658
|
+
on(event: "add", cb: (path: string) => void): FakeWatcher;
|
|
659
|
+
on(event: "ready", cb: () => void): FakeWatcher;
|
|
660
|
+
on(event: "error", cb: (err: Error) => void): FakeWatcher;
|
|
661
|
+
close(): Promise<void>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function makeFakeWatcherFactory() {
|
|
665
|
+
return (_path: string, _options: Record<string, unknown>): FakeWatcher => {
|
|
666
|
+
const watcher = {
|
|
667
|
+
on(event: "add" | "ready" | "error", cb: () => void): FakeWatcher {
|
|
668
|
+
if (event === "ready") queueMicrotask(() => cb());
|
|
669
|
+
return watcher;
|
|
670
|
+
},
|
|
671
|
+
close(): Promise<void> {
|
|
672
|
+
return Promise.resolve();
|
|
673
|
+
},
|
|
674
|
+
} as unknown as FakeWatcher;
|
|
675
|
+
return watcher;
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function writeRunningJob(
|
|
680
|
+
dataDir: string,
|
|
681
|
+
jobId: string,
|
|
682
|
+
lastUpdated: string,
|
|
683
|
+
runnerPid: number,
|
|
684
|
+
): Promise<void> {
|
|
685
|
+
const jobDir = join(dataDir, "current", jobId);
|
|
686
|
+
await mkdir(jobDir, { recursive: true });
|
|
687
|
+
await writeFile(
|
|
688
|
+
join(jobDir, "tasks-status.json"),
|
|
689
|
+
JSON.stringify({
|
|
690
|
+
id: jobId,
|
|
691
|
+
state: "running",
|
|
692
|
+
current: "research",
|
|
693
|
+
currentStage: null,
|
|
694
|
+
lastUpdated,
|
|
695
|
+
tasks: { research: { state: "running" } },
|
|
696
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
697
|
+
}),
|
|
698
|
+
);
|
|
699
|
+
await writeFile(join(jobDir, "runner.pid"), String(runnerPid));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
describe("startOrchestrator boot sweep (AC-16)", () => {
|
|
703
|
+
test("a stale orphaned job in current/ is marked failed and the handle still resolves", async () => {
|
|
704
|
+
const root = await mkdtemp(join(tmpdir(), "boot-stale-"));
|
|
705
|
+
const dataDir = join(root, "pipeline-data");
|
|
706
|
+
try {
|
|
707
|
+
await writeRunningJob(dataDir, "job-stale", "2020-01-01T00:00:00.000Z", 999999);
|
|
708
|
+
|
|
709
|
+
const handle = await startOrchestrator({
|
|
710
|
+
dataDir,
|
|
711
|
+
watcherFactory: makeFakeWatcherFactory(),
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
expect(handle).toBeDefined();
|
|
715
|
+
expect(typeof handle.stop).toBe("function");
|
|
716
|
+
|
|
717
|
+
const snapshot = JSON.parse(
|
|
718
|
+
await readFile(join(dataDir, "current", "job-stale", "tasks-status.json"), "utf-8"),
|
|
719
|
+
) as Record<string, unknown>;
|
|
720
|
+
expect(snapshot["state"]).toBe("failed");
|
|
721
|
+
const tasks = snapshot["tasks"] as Record<string, Record<string, unknown>>;
|
|
722
|
+
expect(tasks["research"]?.["state"]).toBe("failed");
|
|
723
|
+
|
|
724
|
+
await handle.stop();
|
|
725
|
+
} finally {
|
|
726
|
+
await rm(root, { recursive: true, force: true });
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("a live job in current/ is left running and the handle still resolves", async () => {
|
|
731
|
+
const root = await mkdtemp(join(tmpdir(), "boot-live-"));
|
|
732
|
+
const dataDir = join(root, "pipeline-data");
|
|
733
|
+
try {
|
|
734
|
+
await writeRunningJob(dataDir, "job-live", "2020-01-01T00:00:00.000Z", process.pid);
|
|
735
|
+
|
|
736
|
+
const handle = await startOrchestrator({
|
|
737
|
+
dataDir,
|
|
738
|
+
watcherFactory: makeFakeWatcherFactory(),
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
expect(handle).toBeDefined();
|
|
742
|
+
expect(typeof handle.stop).toBe("function");
|
|
743
|
+
|
|
744
|
+
const snapshot = JSON.parse(
|
|
745
|
+
await readFile(join(dataDir, "current", "job-live", "tasks-status.json"), "utf-8"),
|
|
746
|
+
) as Record<string, unknown>;
|
|
747
|
+
expect(snapshot["state"]).toBe("running");
|
|
748
|
+
const tasks = snapshot["tasks"] as Record<string, Record<string, unknown>>;
|
|
749
|
+
expect(tasks["research"]?.["state"]).toBe("running");
|
|
750
|
+
|
|
751
|
+
await handle.stop();
|
|
752
|
+
} finally {
|
|
753
|
+
await rm(root, { recursive: true, force: true });
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
});
|