@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
package/src/ui/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
/>
|
|
12
12
|
<title>Prompt Pipeline Dashboard</title>
|
|
13
13
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/style-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-BnAqY4_n.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/style-BKG0bHu-.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Auto-generated by scripts/generate-embedded-assets.js — do not edit
|
|
2
2
|
import _asset0 from "./dist/index.html" with { type: "file" };
|
|
3
|
-
import _asset1 from "./dist/assets/style-
|
|
4
|
-
import _asset2 from "./dist/assets/index-
|
|
5
|
-
import _asset3 from "./dist/assets/index-
|
|
3
|
+
import _asset1 from "./dist/assets/style-BKG0bHu-.css" with { type: "file" };
|
|
4
|
+
import _asset2 from "./dist/assets/index-BnAqY4_n.js" with { type: "file" };
|
|
5
|
+
import _asset3 from "./dist/assets/index-BnAqY4_n.js.map" with { type: "file" };
|
|
6
6
|
|
|
7
7
|
export const embeddedAssets = {
|
|
8
8
|
"/index.html": { path: _asset0, mime: "text/html" },
|
|
9
|
-
"/assets/style-
|
|
10
|
-
"/assets/index-
|
|
11
|
-
"/assets/index-
|
|
9
|
+
"/assets/style-BKG0bHu-.css": { path: _asset1, mime: "text/css" },
|
|
10
|
+
"/assets/index-BnAqY4_n.js": { path: _asset2, mime: "application/javascript" },
|
|
11
|
+
"/assets/index-BnAqY4_n.js.map": { path: _asset3, mime: "application/json" }
|
|
12
12
|
};
|
|
@@ -1,14 +1,176 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
3
|
|
|
4
|
+
import { useConcurrencyStatus } from "../client/hooks/useConcurrencyStatus";
|
|
4
5
|
import { useJobListWithUpdates } from "../client/hooks/useJobListWithUpdates";
|
|
5
6
|
import JobTable from "../components/JobTable";
|
|
6
7
|
import Layout from "../components/Layout";
|
|
7
8
|
import type { JobSummary } from "../components/types";
|
|
8
9
|
import { Progress } from "../components/ui/Progress";
|
|
9
10
|
import { HintBanner } from "../components/onboarding";
|
|
11
|
+
import type { JobConcurrencyApiStatus } from "../client/types";
|
|
10
12
|
|
|
11
|
-
type TabKey = "current" | "errors" | "complete";
|
|
13
|
+
type TabKey = "current" | "errors" | "complete" | "concurrency";
|
|
14
|
+
|
|
15
|
+
const STALE_REASON_LABELS: Record<JobConcurrencyApiStatus["staleSlots"][number]["reason"], string> = {
|
|
16
|
+
missing_current_job: "Missing current job directory",
|
|
17
|
+
missing_pid: "Lease missing PID past timeout",
|
|
18
|
+
dead_pid: "Process no longer running",
|
|
19
|
+
invalid_json: "Lease file is not valid JSON",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function formatTimestamp(value: string | null | undefined): string {
|
|
23
|
+
if (!value) return "—";
|
|
24
|
+
const ms = Date.parse(value);
|
|
25
|
+
if (Number.isNaN(ms)) return value;
|
|
26
|
+
return new Date(ms).toLocaleString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function CapacityMetrics({ status }: { status: JobConcurrencyApiStatus }) {
|
|
30
|
+
const cells: Array<{ label: string; value: number }> = [
|
|
31
|
+
{ label: "Limit", value: status.limit },
|
|
32
|
+
{ label: "Running", value: status.runningCount },
|
|
33
|
+
{ label: "Available", value: status.availableSlots },
|
|
34
|
+
{ label: "Queued", value: status.queuedCount },
|
|
35
|
+
];
|
|
36
|
+
return (
|
|
37
|
+
<dl className="mb-6 grid grid-cols-2 gap-px overflow-hidden rounded-md border border-gray-200 bg-gray-200 sm:grid-cols-4">
|
|
38
|
+
{cells.map((cell) => (
|
|
39
|
+
<div key={cell.label} className="bg-white px-4 py-3">
|
|
40
|
+
<dt className="text-xs text-gray-500">{cell.label}</dt>
|
|
41
|
+
<dd className="text-2xl tabular-nums text-gray-900">{cell.value}</dd>
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</dl>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ActiveJobsTable({ jobs }: { jobs: JobConcurrencyApiStatus["activeJobs"] }) {
|
|
49
|
+
return (
|
|
50
|
+
<section className="mb-6">
|
|
51
|
+
<h2 className="mb-2 text-sm font-medium text-gray-700">Active jobs ({jobs.length})</h2>
|
|
52
|
+
<div className="overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
53
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
54
|
+
<thead className="bg-gray-50 text-left text-sm font-medium text-gray-500">
|
|
55
|
+
<tr>
|
|
56
|
+
<th scope="col" className="px-4 py-2">Job ID</th>
|
|
57
|
+
<th scope="col" className="px-4 py-2">Source</th>
|
|
58
|
+
<th scope="col" className="px-4 py-2 text-right">PID</th>
|
|
59
|
+
<th scope="col" className="px-4 py-2">Acquired</th>
|
|
60
|
+
</tr>
|
|
61
|
+
</thead>
|
|
62
|
+
<tbody className="divide-y divide-gray-200 text-sm text-gray-700">
|
|
63
|
+
{jobs.length === 0 ? (
|
|
64
|
+
<tr>
|
|
65
|
+
<td colSpan={4} className="px-4 py-3 text-gray-500">No active jobs.</td>
|
|
66
|
+
</tr>
|
|
67
|
+
) : (
|
|
68
|
+
jobs.map((job) => (
|
|
69
|
+
<tr key={job.jobId}>
|
|
70
|
+
<td className="px-4 py-2 font-mono text-xs text-gray-900">{job.jobId}</td>
|
|
71
|
+
<td className="px-4 py-2">{job.source}</td>
|
|
72
|
+
<td className="px-4 py-2 text-right tabular-nums">{job.pid ?? "—"}</td>
|
|
73
|
+
<td className="px-4 py-2 tabular-nums text-gray-600">{formatTimestamp(job.acquiredAt)}</td>
|
|
74
|
+
</tr>
|
|
75
|
+
))
|
|
76
|
+
)}
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function QueuedJobsTable({ jobs }: { jobs: JobConcurrencyApiStatus["queuedJobs"] }) {
|
|
85
|
+
return (
|
|
86
|
+
<section className="mb-6">
|
|
87
|
+
<h2 className="mb-2 text-sm font-medium text-gray-700">Queued jobs ({jobs.length})</h2>
|
|
88
|
+
<div className="overflow-hidden rounded-md border border-gray-200 bg-white">
|
|
89
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
90
|
+
<thead className="bg-gray-50 text-left text-sm font-medium text-gray-500">
|
|
91
|
+
<tr>
|
|
92
|
+
<th scope="col" className="px-4 py-2">Job ID</th>
|
|
93
|
+
<th scope="col" className="px-4 py-2">Name</th>
|
|
94
|
+
<th scope="col" className="px-4 py-2">Pipeline</th>
|
|
95
|
+
<th scope="col" className="px-4 py-2">Queued</th>
|
|
96
|
+
</tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody className="divide-y divide-gray-200 text-sm text-gray-700">
|
|
99
|
+
{jobs.length === 0 ? (
|
|
100
|
+
<tr>
|
|
101
|
+
<td colSpan={4} className="px-4 py-3 text-gray-500">No queued jobs.</td>
|
|
102
|
+
</tr>
|
|
103
|
+
) : (
|
|
104
|
+
jobs.map((job) => (
|
|
105
|
+
<tr key={job.jobId}>
|
|
106
|
+
<td className="px-4 py-2 font-mono text-xs text-gray-900">{job.jobId}</td>
|
|
107
|
+
<td className="px-4 py-2">{job.name ?? "—"}</td>
|
|
108
|
+
<td className="px-4 py-2">{job.pipeline ?? "—"}</td>
|
|
109
|
+
<td className="px-4 py-2 tabular-nums text-gray-600">{formatTimestamp(job.queuedAt)}</td>
|
|
110
|
+
</tr>
|
|
111
|
+
))
|
|
112
|
+
)}
|
|
113
|
+
</tbody>
|
|
114
|
+
</table>
|
|
115
|
+
</div>
|
|
116
|
+
</section>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function StaleSlotWarnings({ slots }: { slots: JobConcurrencyApiStatus["staleSlots"] }) {
|
|
121
|
+
if (slots.length === 0) return null;
|
|
122
|
+
return (
|
|
123
|
+
<section className="mb-6 rounded-sm border-l-[3px] border-l-yellow-600 bg-yellow-50">
|
|
124
|
+
<h2 className="px-4 pt-3 text-sm font-medium text-yellow-800">Stale slots ({slots.length})</h2>
|
|
125
|
+
<table className="min-w-full text-sm text-yellow-900">
|
|
126
|
+
<thead className="text-left text-xs font-medium text-yellow-700">
|
|
127
|
+
<tr>
|
|
128
|
+
<th scope="col" className="px-4 py-2">Job ID</th>
|
|
129
|
+
<th scope="col" className="px-4 py-2">Reason</th>
|
|
130
|
+
</tr>
|
|
131
|
+
</thead>
|
|
132
|
+
<tbody>
|
|
133
|
+
{slots.map((slot) => (
|
|
134
|
+
<tr key={slot.jobId}>
|
|
135
|
+
<td className="px-4 py-2 font-mono text-xs">{slot.jobId}</td>
|
|
136
|
+
<td className="px-4 py-2">{STALE_REASON_LABELS[slot.reason]}</td>
|
|
137
|
+
</tr>
|
|
138
|
+
))}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</section>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function ConcurrencyPanel() {
|
|
146
|
+
const { data, error, loading } = useConcurrencyStatus();
|
|
147
|
+
|
|
148
|
+
if (error && !data) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="rounded-sm border-l-[3px] border-l-yellow-600 bg-yellow-100 p-3 text-sm text-yellow-700">
|
|
151
|
+
Unable to load concurrency status: {error.message}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (loading && !data) {
|
|
156
|
+
return <div className="text-sm text-gray-500">Loading concurrency status…</div>;
|
|
157
|
+
}
|
|
158
|
+
if (!data) return null;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div>
|
|
162
|
+
{error ? (
|
|
163
|
+
<div className="mb-4 rounded-sm border-l-[3px] border-l-yellow-600 bg-yellow-50 p-3 text-sm text-yellow-700">
|
|
164
|
+
Showing last known concurrency status: {error.message}
|
|
165
|
+
</div>
|
|
166
|
+
) : null}
|
|
167
|
+
<CapacityMetrics status={data} />
|
|
168
|
+
<StaleSlotWarnings slots={data.staleSlots} />
|
|
169
|
+
<ActiveJobsTable jobs={data.activeJobs} />
|
|
170
|
+
<QueuedJobsTable jobs={data.queuedJobs} />
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
12
174
|
|
|
13
175
|
export default function PromptPipelineDashboard() {
|
|
14
176
|
const navigate = useNavigate();
|
|
@@ -35,6 +197,22 @@ export default function PromptPipelineDashboard() {
|
|
|
35
197
|
const aggregateProgress =
|
|
36
198
|
runningJobs.length === 0 ? 0 : Math.round(runningJobs.reduce((sum, job) => sum + job.progress, 0) / runningJobs.length);
|
|
37
199
|
|
|
200
|
+
const tabLabels: Record<TabKey, string> = {
|
|
201
|
+
current: "Current",
|
|
202
|
+
errors: "Errors",
|
|
203
|
+
complete: "Complete",
|
|
204
|
+
concurrency: "Concurrency",
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const tabCounts: Record<TabKey, number | null> = {
|
|
208
|
+
current: grouped.current.length,
|
|
209
|
+
errors: grouped.errors.length,
|
|
210
|
+
complete: grouped.complete.length,
|
|
211
|
+
concurrency: null,
|
|
212
|
+
};
|
|
213
|
+
const tabText = (tab: TabKey): string =>
|
|
214
|
+
`${tabLabels[tab]}${tabCounts[tab] === null ? "" : ` (${tabCounts[tab]})`}`;
|
|
215
|
+
|
|
38
216
|
return (
|
|
39
217
|
<Layout
|
|
40
218
|
pageTitle="Prompt Pipeline"
|
|
@@ -53,18 +231,22 @@ export default function PromptPipelineDashboard() {
|
|
|
53
231
|
{error ? <div className="mb-4 rounded-sm border-l-[3px] border-l-yellow-600 bg-yellow-100 p-3 text-sm text-yellow-700">Unable to load jobs from the server</div> : null}
|
|
54
232
|
<HintBanner storageKey="dashboard-hint" title="Upload a seed file to get started." variant="action">Your first pipeline takes about 2 minutes to complete.</HintBanner>
|
|
55
233
|
<div className="mb-4 flex flex-wrap gap-2">
|
|
56
|
-
{(["current", "errors", "complete"] as const).map((tab) => (
|
|
234
|
+
{(["current", "errors", "complete", "concurrency"] as const).map((tab) => (
|
|
57
235
|
<button
|
|
58
236
|
key={tab}
|
|
59
237
|
type="button"
|
|
60
238
|
className={`px-4 py-2 text-sm ${activeTab === tab ? "text-[#6d28d9] font-medium border-b-2 border-[#6d28d9]" : "text-gray-500 hover:text-gray-900"}`}
|
|
61
239
|
onClick={() => setActiveTab(tab)}
|
|
62
240
|
>
|
|
63
|
-
{
|
|
241
|
+
{tabText(tab)}
|
|
64
242
|
</button>
|
|
65
243
|
))}
|
|
66
244
|
</div>
|
|
67
|
-
|
|
245
|
+
{activeTab === "concurrency" ? (
|
|
246
|
+
<ConcurrencyPanel />
|
|
247
|
+
) : (
|
|
248
|
+
<JobTable jobs={grouped[activeTab]} pipeline={null} onOpenJob={(jobId) => navigate(`/pipeline/${jobId}`)} />
|
|
249
|
+
)}
|
|
68
250
|
</Layout>
|
|
69
251
|
);
|
|
70
252
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import "../../components/__tests__/test-dom";
|
|
2
2
|
|
|
3
|
-
import { render } from "@testing-library/react";
|
|
3
|
+
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
|
4
4
|
import { MemoryRouter } from "react-router-dom";
|
|
5
5
|
import { afterEach, beforeEach, expect, mock, test } from "bun:test";
|
|
6
6
|
|
|
7
|
+
import type { JobConcurrencyApiStatus } from "../../client/types";
|
|
8
|
+
|
|
7
9
|
const originalLocalStorage = globalThis.localStorage;
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
const originalEventSource = globalThis.EventSource;
|
|
8
12
|
|
|
9
13
|
mock.module("../../client/hooks/useJobListWithUpdates", () => ({
|
|
10
14
|
useJobListWithUpdates: () => ({
|
|
@@ -40,6 +44,59 @@ mock.module("../../client/hooks/useJobListWithUpdates", () => ({
|
|
|
40
44
|
}),
|
|
41
45
|
}));
|
|
42
46
|
|
|
47
|
+
let concurrencyStatus: JobConcurrencyApiStatus | null = null;
|
|
48
|
+
function setConcurrency(status: JobConcurrencyApiStatus | null) {
|
|
49
|
+
concurrencyStatus = status;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function emptyStatus(): JobConcurrencyApiStatus {
|
|
53
|
+
return {
|
|
54
|
+
limit: 3,
|
|
55
|
+
runningCount: 0,
|
|
56
|
+
availableSlots: 3,
|
|
57
|
+
queuedCount: 0,
|
|
58
|
+
activeJobs: [],
|
|
59
|
+
queuedJobs: [],
|
|
60
|
+
staleSlots: [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class MockEventSource {
|
|
65
|
+
static instances: MockEventSource[] = [];
|
|
66
|
+
|
|
67
|
+
public onerror: ((event: Event) => void) | null = null;
|
|
68
|
+
private readonly listeners = new Map<string, Array<(event: MessageEvent<string>) => void>>();
|
|
69
|
+
|
|
70
|
+
constructor() {
|
|
71
|
+
MockEventSource.instances.push(this);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
addEventListener(type: string, listener: (event: MessageEvent<string>) => void): void {
|
|
75
|
+
const listeners = this.listeners.get(type) ?? [];
|
|
76
|
+
listeners.push(listener);
|
|
77
|
+
this.listeners.set(type, listeners);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
close(): void {}
|
|
81
|
+
|
|
82
|
+
emit(type: string, data: unknown): void {
|
|
83
|
+
for (const listener of this.listeners.get(type) ?? []) {
|
|
84
|
+
listener(new MessageEvent(type, { data: JSON.stringify(data) }));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static reset(): void {
|
|
89
|
+
MockEventSource.instances = [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function installConcurrencyFetch(): void {
|
|
94
|
+
globalThis.fetch = mock(() =>
|
|
95
|
+
Promise.resolve(new Response(JSON.stringify({ ok: true, data: concurrencyStatus }))),
|
|
96
|
+
) as unknown as typeof fetch;
|
|
97
|
+
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
|
98
|
+
}
|
|
99
|
+
|
|
43
100
|
beforeEach(() => {
|
|
44
101
|
const store: Record<string, string> = {};
|
|
45
102
|
globalThis.localStorage = {
|
|
@@ -50,11 +107,17 @@ beforeEach(() => {
|
|
|
50
107
|
get length() { return Object.keys(store).length; },
|
|
51
108
|
key: (i: number) => Object.keys(store)[i] ?? null,
|
|
52
109
|
} as Storage;
|
|
110
|
+
setConcurrency(emptyStatus());
|
|
111
|
+
MockEventSource.reset();
|
|
112
|
+
installConcurrencyFetch();
|
|
53
113
|
});
|
|
54
114
|
|
|
55
115
|
afterEach(() => {
|
|
56
116
|
document.body.innerHTML = "";
|
|
57
117
|
globalThis.localStorage = originalLocalStorage;
|
|
118
|
+
globalThis.fetch = originalFetch;
|
|
119
|
+
globalThis.EventSource = originalEventSource;
|
|
120
|
+
MockEventSource.reset();
|
|
58
121
|
});
|
|
59
122
|
|
|
60
123
|
test("PromptPipelineDashboard renders tabs", async () => {
|
|
@@ -69,3 +132,211 @@ test("PromptPipelineDashboard renders tabs", async () => {
|
|
|
69
132
|
expect(view.getAllByText("Errors (1)")[0]).toBeTruthy();
|
|
70
133
|
expect(view.getByText("Job One")).toBeTruthy();
|
|
71
134
|
});
|
|
135
|
+
|
|
136
|
+
test("PromptPipelineDashboard shows the Concurrency tab", async () => {
|
|
137
|
+
const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
|
|
138
|
+
const view = render(
|
|
139
|
+
<MemoryRouter>
|
|
140
|
+
<PromptPipelineDashboard />
|
|
141
|
+
</MemoryRouter>,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(view.getByText("Concurrency")).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("Concurrency tab renders capacity metrics from the hook", async () => {
|
|
148
|
+
setConcurrency({
|
|
149
|
+
limit: 7,
|
|
150
|
+
runningCount: 4,
|
|
151
|
+
availableSlots: 3,
|
|
152
|
+
queuedCount: 5,
|
|
153
|
+
activeJobs: [],
|
|
154
|
+
queuedJobs: [],
|
|
155
|
+
staleSlots: [],
|
|
156
|
+
});
|
|
157
|
+
const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
|
|
158
|
+
const view = render(
|
|
159
|
+
<MemoryRouter>
|
|
160
|
+
<PromptPipelineDashboard />
|
|
161
|
+
</MemoryRouter>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
fireEvent.click(view.getByText("Concurrency"));
|
|
165
|
+
|
|
166
|
+
function metricValue(label: string): string | null {
|
|
167
|
+
const dt = view.getAllByText(label).find((node) => node.tagName === "DT");
|
|
168
|
+
return dt?.parentElement?.querySelector("dd")?.textContent ?? null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await waitFor(() => expect(metricValue("Limit")).toBe("7"));
|
|
172
|
+
expect(metricValue("Running")).toBe("4");
|
|
173
|
+
expect(metricValue("Available")).toBe("3");
|
|
174
|
+
expect(metricValue("Queued")).toBe("5");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("Concurrency tab renders active jobs when present", async () => {
|
|
178
|
+
setConcurrency({
|
|
179
|
+
limit: 2,
|
|
180
|
+
runningCount: 1,
|
|
181
|
+
availableSlots: 1,
|
|
182
|
+
queuedCount: 0,
|
|
183
|
+
activeJobs: [
|
|
184
|
+
{
|
|
185
|
+
jobId: "active-1",
|
|
186
|
+
pid: 4321,
|
|
187
|
+
acquiredAt: "2026-05-05T12:00:00.000Z",
|
|
188
|
+
source: "orchestrator",
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
queuedJobs: [],
|
|
192
|
+
staleSlots: [],
|
|
193
|
+
});
|
|
194
|
+
const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
|
|
195
|
+
const view = render(
|
|
196
|
+
<MemoryRouter>
|
|
197
|
+
<PromptPipelineDashboard />
|
|
198
|
+
</MemoryRouter>,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
fireEvent.click(view.getByText("Concurrency"));
|
|
202
|
+
|
|
203
|
+
await waitFor(() => expect(view.getByText("active-1")).toBeTruthy());
|
|
204
|
+
expect(view.getByText("orchestrator")).toBeTruthy();
|
|
205
|
+
expect(view.getByText("4321")).toBeTruthy();
|
|
206
|
+
expect(view.getByText(/Active jobs \(1\)/)).toBeTruthy();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("Concurrency tab renders queued jobs when present", async () => {
|
|
210
|
+
setConcurrency({
|
|
211
|
+
limit: 2,
|
|
212
|
+
runningCount: 0,
|
|
213
|
+
availableSlots: 2,
|
|
214
|
+
queuedCount: 1,
|
|
215
|
+
activeJobs: [],
|
|
216
|
+
queuedJobs: [
|
|
217
|
+
{
|
|
218
|
+
jobId: "queued-1",
|
|
219
|
+
queuedAt: "2026-05-05T11:00:00.000Z",
|
|
220
|
+
name: "Queued Job",
|
|
221
|
+
pipeline: "demo-pipeline",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
staleSlots: [],
|
|
225
|
+
});
|
|
226
|
+
const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
|
|
227
|
+
const view = render(
|
|
228
|
+
<MemoryRouter>
|
|
229
|
+
<PromptPipelineDashboard />
|
|
230
|
+
</MemoryRouter>,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
fireEvent.click(view.getByText("Concurrency"));
|
|
234
|
+
|
|
235
|
+
await waitFor(() => expect(view.getByText("queued-1")).toBeTruthy());
|
|
236
|
+
expect(view.getByText("Queued Job")).toBeTruthy();
|
|
237
|
+
expect(view.getByText("demo-pipeline")).toBeTruthy();
|
|
238
|
+
expect(view.getByText(/Queued jobs \(1\)/)).toBeTruthy();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("Concurrency tab renders stale slot warnings when present", async () => {
|
|
242
|
+
setConcurrency({
|
|
243
|
+
limit: 2,
|
|
244
|
+
runningCount: 1,
|
|
245
|
+
availableSlots: 1,
|
|
246
|
+
queuedCount: 0,
|
|
247
|
+
activeJobs: [],
|
|
248
|
+
queuedJobs: [],
|
|
249
|
+
staleSlots: [
|
|
250
|
+
{ jobId: "stale-1", reason: "dead_pid" },
|
|
251
|
+
{ jobId: "stale-2", reason: "invalid_json" },
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
|
|
255
|
+
const view = render(
|
|
256
|
+
<MemoryRouter>
|
|
257
|
+
<PromptPipelineDashboard />
|
|
258
|
+
</MemoryRouter>,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
fireEvent.click(view.getByText("Concurrency"));
|
|
262
|
+
|
|
263
|
+
await waitFor(() => expect(view.getByText(/Stale slots \(2\)/)).toBeTruthy());
|
|
264
|
+
expect(view.getByText("stale-1")).toBeTruthy();
|
|
265
|
+
expect(view.getByText("Process no longer running")).toBeTruthy();
|
|
266
|
+
expect(view.getByText("stale-2")).toBeTruthy();
|
|
267
|
+
expect(view.getByText("Lease file is not valid JSON")).toBeTruthy();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("Concurrency tab updates after an SSE refresh without changing tabs", async () => {
|
|
271
|
+
setConcurrency(emptyStatus());
|
|
272
|
+
const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
|
|
273
|
+
const view = render(
|
|
274
|
+
<MemoryRouter>
|
|
275
|
+
<PromptPipelineDashboard />
|
|
276
|
+
</MemoryRouter>,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
fireEvent.click(view.getByText("Concurrency"));
|
|
280
|
+
await waitFor(() => expect(view.getByText("No queued jobs.")).toBeTruthy());
|
|
281
|
+
|
|
282
|
+
setConcurrency({
|
|
283
|
+
limit: 3,
|
|
284
|
+
runningCount: 0,
|
|
285
|
+
availableSlots: 3,
|
|
286
|
+
queuedCount: 1,
|
|
287
|
+
activeJobs: [],
|
|
288
|
+
queuedJobs: [
|
|
289
|
+
{
|
|
290
|
+
jobId: "queued-after-sse",
|
|
291
|
+
queuedAt: "2026-05-05T11:30:00.000Z",
|
|
292
|
+
name: "Queued After SSE",
|
|
293
|
+
pipeline: "demo-pipeline",
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
staleSlots: [],
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
act(() => {
|
|
300
|
+
MockEventSource.instances[0]?.emit("state:summary", { ok: true });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await waitFor(() => expect(view.getByText("queued-after-sse")).toBeTruthy());
|
|
304
|
+
expect(view.getByText(/Queued jobs \(1\)/)).toBeTruthy();
|
|
305
|
+
expect(view.getByText("Concurrency")).toBeTruthy();
|
|
306
|
+
expect(view.queryByText("Job One")).toBeNull();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("Concurrency tab shows stale data when live updates disconnect", async () => {
|
|
310
|
+
setConcurrency({
|
|
311
|
+
limit: 3,
|
|
312
|
+
runningCount: 1,
|
|
313
|
+
availableSlots: 2,
|
|
314
|
+
queuedCount: 0,
|
|
315
|
+
activeJobs: [
|
|
316
|
+
{
|
|
317
|
+
jobId: "active-before-error",
|
|
318
|
+
pid: 2468,
|
|
319
|
+
acquiredAt: "2026-05-05T12:00:00.000Z",
|
|
320
|
+
source: "orchestrator",
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
queuedJobs: [],
|
|
324
|
+
staleSlots: [],
|
|
325
|
+
});
|
|
326
|
+
const { default: PromptPipelineDashboard } = await import("../PromptPipelineDashboard");
|
|
327
|
+
const view = render(
|
|
328
|
+
<MemoryRouter>
|
|
329
|
+
<PromptPipelineDashboard />
|
|
330
|
+
</MemoryRouter>,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
fireEvent.click(view.getByText("Concurrency"));
|
|
334
|
+
await waitFor(() => expect(view.getByText("active-before-error")).toBeTruthy());
|
|
335
|
+
|
|
336
|
+
act(() => {
|
|
337
|
+
MockEventSource.instances[0]?.onerror?.(new Event("error"));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await waitFor(() => expect(view.getByText(/Showing last known concurrency status/)).toBeTruthy());
|
|
341
|
+
expect(view.getByText("active-before-error")).toBeTruthy();
|
|
342
|
+
});
|