@ryanfw/prompt-orchestration-pipeline 1.2.7 → 1.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config/__tests__/models.test.ts +31 -1
- package/src/config/models.ts +81 -35
- package/src/config/paths.ts +13 -8
- package/src/core/__tests__/config.test.ts +121 -0
- package/src/core/__tests__/job-concurrency.test.ts +554 -0
- package/src/core/__tests__/orchestrator.test.ts +353 -0
- package/src/core/__tests__/pipeline-runner.test.ts +430 -2
- package/src/core/__tests__/task-runner.test.ts +1 -2
- package/src/core/config.ts +48 -1
- package/src/core/job-concurrency.ts +462 -0
- package/src/core/orchestrator.ts +370 -57
- package/src/core/pipeline-runner.ts +79 -15
- package/src/core/status-writer.ts +4 -0
- package/src/core/task-runner.ts +1 -1
- package/src/providers/__tests__/base.test.ts +1 -1
- package/src/ui/client/__tests__/api.test.ts +101 -1
- package/src/ui/client/__tests__/job-adapter.test.ts +12 -0
- package/src/ui/client/__tests__/useConcurrencyStatus.test.ts +126 -0
- package/src/ui/client/adapters/job-adapter.ts +1 -0
- package/src/ui/client/api.ts +77 -7
- package/src/ui/client/hooks/useConcurrencyStatus.ts +102 -0
- package/src/ui/client/types.ts +34 -1
- package/src/ui/components/DAGGrid.tsx +11 -1
- package/src/ui/components/JobDetail.tsx +2 -1
- package/src/ui/components/__tests__/DAGGrid.test.tsx +92 -0
- package/src/ui/components/__tests__/JobDetail.test.tsx +62 -0
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index-SKy2shWc.js → index-BnAqY4_n.js} +336 -52
- package/src/ui/dist/assets/index-BnAqY4_n.js.map +1 -0
- package/src/ui/dist/assets/style-BKG0bHu-.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/pages/PromptPipelineDashboard.tsx +186 -4
- package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +272 -1
- package/src/ui/server/__tests__/concurrency-endpoint.test.ts +190 -0
- package/src/ui/server/__tests__/index.test.ts +92 -3
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +660 -3
- package/src/ui/server/endpoints/concurrency-endpoint.ts +72 -0
- package/src/ui/server/endpoints/job-control-endpoints.ts +248 -37
- package/src/ui/server/index.ts +21 -2
- package/src/ui/server/router.ts +2 -0
- package/src/ui/state/__tests__/watcher.test.ts +31 -0
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +15 -0
- package/src/ui/state/transformers/status-transformer.ts +1 -0
- package/src/ui/state/types.ts +3 -0
- package/src/ui/state/watcher.ts +9 -1
- package/src/utils/__tests__/dag.test.ts +35 -0
- package/src/utils/dag.ts +1 -0
- package/src/ui/dist/assets/index-SKy2shWc.js.map +0 -1
- package/src/ui/dist/assets/style-DA1Ma4YS.css +0 -2
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { handleConcurrencyStatus } from "../endpoints/concurrency-endpoint";
|
|
8
|
+
import { resetConfig } from "../../../core/config";
|
|
9
|
+
import {
|
|
10
|
+
getConcurrencyRuntimePaths,
|
|
11
|
+
type JobSlotLease,
|
|
12
|
+
} from "../../../core/job-concurrency";
|
|
13
|
+
|
|
14
|
+
const tempRoots: string[] = [];
|
|
15
|
+
|
|
16
|
+
async function makeTempRoot(): Promise<string> {
|
|
17
|
+
const root = await Bun.$`mktemp -d ${path.join(os.tmpdir(), "concurrency-endpoint-XXXXXX")}`.text();
|
|
18
|
+
const trimmed = root.trim();
|
|
19
|
+
tempRoots.push(trimmed);
|
|
20
|
+
return trimmed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setMaxConcurrentJobs(value: number): void {
|
|
24
|
+
process.env["PO_MAX_RUNNING_JOBS"] = String(value);
|
|
25
|
+
resetConfig();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function writeLeaseFile(root: string, jobId: string, lease: JobSlotLease): Promise<void> {
|
|
29
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
|
|
30
|
+
await mkdir(runningJobsDir, { recursive: true });
|
|
31
|
+
await writeFile(path.join(runningJobsDir, `${jobId}.json`), JSON.stringify(lease));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function writeRawLeaseFile(root: string, fileName: string, contents: string): Promise<void> {
|
|
35
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
|
|
36
|
+
await mkdir(runningJobsDir, { recursive: true });
|
|
37
|
+
await writeFile(path.join(runningJobsDir, fileName), contents);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function writeCurrentJob(root: string, jobId: string): Promise<void> {
|
|
41
|
+
await mkdir(path.join(root, "pipeline-data", "current", jobId), { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writePendingSeed(root: string, jobId: string, seed: Record<string, unknown>): Promise<void> {
|
|
45
|
+
const pendingDir = path.join(root, "pipeline-data", "pending");
|
|
46
|
+
await mkdir(pendingDir, { recursive: true });
|
|
47
|
+
await writeFile(path.join(pendingDir, `${jobId}-seed.json`), JSON.stringify(seed));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
delete process.env["PO_MAX_RUNNING_JOBS"];
|
|
52
|
+
resetConfig();
|
|
53
|
+
await Promise.all(tempRoots.splice(0).map((root) => Bun.$`rm -rf ${root}`));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("handleConcurrencyStatus", () => {
|
|
57
|
+
it("returns 200 with application/json content type", async () => {
|
|
58
|
+
const root = await makeTempRoot();
|
|
59
|
+
setMaxConcurrentJobs(3);
|
|
60
|
+
|
|
61
|
+
const res = await handleConcurrencyStatus(root);
|
|
62
|
+
|
|
63
|
+
expect(res.status).toBe(200);
|
|
64
|
+
expect(res.headers.get("Content-Type")).toBe("application/json");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reports an empty state when no leases or seeds exist", async () => {
|
|
68
|
+
const root = await makeTempRoot();
|
|
69
|
+
setMaxConcurrentJobs(4);
|
|
70
|
+
|
|
71
|
+
const res = await handleConcurrencyStatus(root);
|
|
72
|
+
const body = await res.json() as Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
expect(body).toEqual({
|
|
75
|
+
ok: true,
|
|
76
|
+
data: {
|
|
77
|
+
limit: 4,
|
|
78
|
+
runningCount: 0,
|
|
79
|
+
availableSlots: 4,
|
|
80
|
+
queuedCount: 0,
|
|
81
|
+
activeJobs: [],
|
|
82
|
+
queuedJobs: [],
|
|
83
|
+
staleSlots: [],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("lists active jobs without slotPath", async () => {
|
|
89
|
+
const root = await makeTempRoot();
|
|
90
|
+
setMaxConcurrentJobs(2);
|
|
91
|
+
|
|
92
|
+
await writeCurrentJob(root, "job-active");
|
|
93
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
|
|
94
|
+
await writeLeaseFile(root, "job-active", {
|
|
95
|
+
jobId: "job-active",
|
|
96
|
+
pid: process.pid,
|
|
97
|
+
acquiredAt: "2026-05-05T12:00:00.000Z",
|
|
98
|
+
source: "orchestrator",
|
|
99
|
+
slotPath: path.join(runningJobsDir, "job-active.json"),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const res = await handleConcurrencyStatus(root);
|
|
103
|
+
const body = await res.json() as { ok: boolean; data: Record<string, unknown> };
|
|
104
|
+
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
expect(body.ok).toBe(true);
|
|
107
|
+
expect(body.data["runningCount"]).toBe(1);
|
|
108
|
+
expect(body.data["availableSlots"]).toBe(1);
|
|
109
|
+
const activeJobs = body.data["activeJobs"] as Array<Record<string, unknown>>;
|
|
110
|
+
expect(activeJobs).toHaveLength(1);
|
|
111
|
+
expect(activeJobs[0]).toEqual({
|
|
112
|
+
jobId: "job-active",
|
|
113
|
+
pid: process.pid,
|
|
114
|
+
acquiredAt: "2026-05-05T12:00:00.000Z",
|
|
115
|
+
source: "orchestrator",
|
|
116
|
+
});
|
|
117
|
+
for (const entry of activeJobs) {
|
|
118
|
+
expect(entry).not.toHaveProperty("slotPath");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("lists queued seeds with metadata", async () => {
|
|
123
|
+
const root = await makeTempRoot();
|
|
124
|
+
setMaxConcurrentJobs(1);
|
|
125
|
+
|
|
126
|
+
await writePendingSeed(root, "queued-1", { name: "First Job", pipeline: "alpha" });
|
|
127
|
+
await writePendingSeed(root, "queued-2", { name: "Second Job", pipeline: "beta" });
|
|
128
|
+
|
|
129
|
+
const res = await handleConcurrencyStatus(root);
|
|
130
|
+
const body = await res.json() as { ok: boolean; data: Record<string, unknown> };
|
|
131
|
+
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
expect(body.data["queuedCount"]).toBe(2);
|
|
134
|
+
const queuedJobs = body.data["queuedJobs"] as Array<Record<string, unknown>>;
|
|
135
|
+
expect(queuedJobs).toHaveLength(2);
|
|
136
|
+
const ids = queuedJobs.map((entry) => entry["jobId"]);
|
|
137
|
+
expect(ids).toContain("queued-1");
|
|
138
|
+
expect(ids).toContain("queued-2");
|
|
139
|
+
const first = queuedJobs.find((entry) => entry["jobId"] === "queued-1")!;
|
|
140
|
+
expect(first["name"]).toBe("First Job");
|
|
141
|
+
expect(first["pipeline"]).toBe("alpha");
|
|
142
|
+
expect(typeof first["queuedAt"]).toBe("string");
|
|
143
|
+
expect(first).not.toHaveProperty("seedPath");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("reports stale slots without slotPath", async () => {
|
|
147
|
+
const root = await makeTempRoot();
|
|
148
|
+
setMaxConcurrentJobs(2);
|
|
149
|
+
|
|
150
|
+
await writeRawLeaseFile(root, "stale-job.json", "{ this is not json");
|
|
151
|
+
|
|
152
|
+
const res = await handleConcurrencyStatus(root);
|
|
153
|
+
const body = await res.json() as { ok: boolean; data: Record<string, unknown> };
|
|
154
|
+
|
|
155
|
+
const staleSlots = body.data["staleSlots"] as Array<Record<string, unknown>>;
|
|
156
|
+
expect(staleSlots).toHaveLength(1);
|
|
157
|
+
expect(staleSlots[0]).toEqual({
|
|
158
|
+
jobId: "stale-job",
|
|
159
|
+
reason: "invalid_json",
|
|
160
|
+
});
|
|
161
|
+
for (const entry of staleSlots) {
|
|
162
|
+
expect(entry).not.toHaveProperty("slotPath");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
|
|
166
|
+
const stalePath = path.join(runningJobsDir, "stale-job.json");
|
|
167
|
+
expect(await Bun.file(stalePath).exists()).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("never exposes slotPath in activeJobs or staleSlots", async () => {
|
|
171
|
+
const root = await makeTempRoot();
|
|
172
|
+
setMaxConcurrentJobs(2);
|
|
173
|
+
|
|
174
|
+
await writeCurrentJob(root, "job-active");
|
|
175
|
+
const { runningJobsDir } = getConcurrencyRuntimePaths(path.join(root, "pipeline-data"));
|
|
176
|
+
await writeLeaseFile(root, "job-active", {
|
|
177
|
+
jobId: "job-active",
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
acquiredAt: "2026-05-05T12:00:00.000Z",
|
|
180
|
+
source: "orchestrator",
|
|
181
|
+
slotPath: path.join(runningJobsDir, "job-active.json"),
|
|
182
|
+
});
|
|
183
|
+
await writeRawLeaseFile(root, "broken-job.json", "not-json");
|
|
184
|
+
|
|
185
|
+
const res = await handleConcurrencyStatus(root);
|
|
186
|
+
const raw = await res.text();
|
|
187
|
+
|
|
188
|
+
expect(raw).not.toContain("slotPath");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { resetConfig } from "../../../core/config";
|
|
8
|
+
import { initializeWatcher, startServer } from "../index";
|
|
9
|
+
import { sseRegistry } from "../sse-registry";
|
|
8
10
|
|
|
9
11
|
const tempRoots: string[] = [];
|
|
10
12
|
|
|
@@ -14,6 +16,10 @@ async function makeTempRoot(): Promise<string> {
|
|
|
14
16
|
tempRoots.push(trimmed);
|
|
15
17
|
await mkdir(path.join(trimmed, "pipeline-data", "current", "job-1"), { recursive: true });
|
|
16
18
|
await writeFile(path.join(trimmed, "pipeline-data", "current", "job-1", "tasks-status.json"), '{"id":"job-1","tasks":{}}');
|
|
19
|
+
await mkdir(path.join(trimmed, "pipeline-data", "complete"), { recursive: true });
|
|
20
|
+
await mkdir(path.join(trimmed, "pipeline-data", "pending"), { recursive: true });
|
|
21
|
+
await mkdir(path.join(trimmed, "pipeline-data", "runtime", "running-jobs"), { recursive: true });
|
|
22
|
+
await mkdir(path.join(trimmed, "pipeline-data", "runtime", "lock"), { recursive: true });
|
|
17
23
|
await mkdir(path.join(trimmed, "dist"), { recursive: true });
|
|
18
24
|
await writeFile(path.join(trimmed, "dist", "index.html"), "<html>ok</html>");
|
|
19
25
|
return trimmed;
|
|
@@ -23,6 +29,19 @@ afterEach(async () => {
|
|
|
23
29
|
await Promise.all(tempRoots.splice(0).map((root) => Bun.$`rm -rf ${root}`));
|
|
24
30
|
});
|
|
25
31
|
|
|
32
|
+
async function waitForBroadcast(
|
|
33
|
+
spy: ReturnType<typeof vi.spyOn>,
|
|
34
|
+
predicate: (call: unknown[]) => boolean,
|
|
35
|
+
timeoutMs = 3_000,
|
|
36
|
+
): Promise<boolean> {
|
|
37
|
+
const start = Date.now();
|
|
38
|
+
while (Date.now() - start < timeoutMs) {
|
|
39
|
+
if (spy.mock.calls.some((call) => predicate(call as unknown[]))) return true;
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
describe("server index", () => {
|
|
27
46
|
it("starts and responds to requests", async () => {
|
|
28
47
|
const root = await makeTempRoot();
|
|
@@ -34,3 +53,73 @@ describe("server index", () => {
|
|
|
34
53
|
await handle.close();
|
|
35
54
|
});
|
|
36
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("initializeWatcher concurrency paths", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
resetConfig();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("broadcasts when a pending seed is added", async () => {
|
|
63
|
+
const root = await makeTempRoot();
|
|
64
|
+
process.env["PO_ROOT"] = root;
|
|
65
|
+
const spy = vi.spyOn(sseRegistry, "broadcast").mockImplementation(() => undefined);
|
|
66
|
+
|
|
67
|
+
await initializeWatcher(root);
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
69
|
+
spy.mockClear();
|
|
70
|
+
|
|
71
|
+
await writeFile(
|
|
72
|
+
path.join(root, "pipeline-data", "pending", "job-new-seed.json"),
|
|
73
|
+
JSON.stringify({ name: "job-new", pipeline: "demo" }),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const seen = await waitForBroadcast(spy, (call) => call[0] === "state:summary" || call[0] === "state:change");
|
|
77
|
+
expect(seen).toBe(true);
|
|
78
|
+
|
|
79
|
+
spy.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("broadcasts when a running-jobs lease file is added", async () => {
|
|
83
|
+
const root = await makeTempRoot();
|
|
84
|
+
process.env["PO_ROOT"] = root;
|
|
85
|
+
const spy = vi.spyOn(sseRegistry, "broadcast").mockImplementation(() => undefined);
|
|
86
|
+
|
|
87
|
+
await initializeWatcher(root);
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
89
|
+
spy.mockClear();
|
|
90
|
+
|
|
91
|
+
await writeFile(
|
|
92
|
+
path.join(root, "pipeline-data", "runtime", "running-jobs", "job-active.json"),
|
|
93
|
+
JSON.stringify({ jobId: "job-active", pid: 1234, acquiredAt: new Date().toISOString(), source: "orchestrator" }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const seen = await waitForBroadcast(spy, (call) => call[0] === "state:summary" || call[0] === "state:change");
|
|
97
|
+
expect(seen).toBe(true);
|
|
98
|
+
|
|
99
|
+
spy.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not broadcast on lock-directory churn", async () => {
|
|
103
|
+
const root = await makeTempRoot();
|
|
104
|
+
process.env["PO_ROOT"] = root;
|
|
105
|
+
const spy = vi.spyOn(sseRegistry, "broadcast").mockImplementation(() => undefined);
|
|
106
|
+
|
|
107
|
+
await initializeWatcher(root);
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
109
|
+
spy.mockClear();
|
|
110
|
+
|
|
111
|
+
const lockDir = path.join(root, "pipeline-data", "runtime", "lock");
|
|
112
|
+
for (let i = 0; i < 5; i++) {
|
|
113
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
114
|
+
await mkdir(lockDir, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
117
|
+
|
|
118
|
+
const stateBroadcasts = spy.mock.calls.filter(
|
|
119
|
+
(call) => call[0] === "state:summary" || call[0] === "state:change",
|
|
120
|
+
);
|
|
121
|
+
expect(stateBroadcasts).toHaveLength(0);
|
|
122
|
+
|
|
123
|
+
spy.mockRestore();
|
|
124
|
+
});
|
|
125
|
+
});
|