@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.4.0
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/README.md +415 -24
- package/package.json +46 -8
- package/src/api/files.js +48 -0
- package/src/api/index.js +149 -53
- package/src/api/validators/seed.js +141 -0
- package/src/cli/index.js +444 -29
- package/src/cli/run-orchestrator.js +39 -0
- package/src/cli/update-pipeline-json.js +47 -0
- package/src/components/DAGGrid.jsx +649 -0
- package/src/components/JobCard.jsx +96 -0
- package/src/components/JobDetail.jsx +159 -0
- package/src/components/JobTable.jsx +202 -0
- package/src/components/Layout.jsx +134 -0
- package/src/components/TaskFilePane.jsx +570 -0
- package/src/components/UploadSeed.jsx +239 -0
- package/src/components/ui/badge.jsx +20 -0
- package/src/components/ui/button.jsx +43 -0
- package/src/components/ui/card.jsx +20 -0
- package/src/components/ui/focus-styles.css +60 -0
- package/src/components/ui/progress.jsx +26 -0
- package/src/components/ui/select.jsx +27 -0
- package/src/components/ui/separator.jsx +6 -0
- package/src/config/paths.js +99 -0
- package/src/core/config.js +270 -9
- package/src/core/file-io.js +202 -0
- package/src/core/module-loader.js +157 -0
- package/src/core/orchestrator.js +275 -294
- package/src/core/pipeline-runner.js +95 -41
- package/src/core/progress.js +66 -0
- package/src/core/status-writer.js +331 -0
- package/src/core/task-runner.js +719 -73
- package/src/core/validation.js +120 -1
- package/src/lib/utils.js +6 -0
- package/src/llm/README.md +139 -30
- package/src/llm/index.js +222 -72
- package/src/pages/PipelineDetail.jsx +111 -0
- package/src/pages/PromptPipelineDashboard.jsx +223 -0
- package/src/providers/deepseek.js +3 -15
- package/src/ui/client/adapters/job-adapter.js +258 -0
- package/src/ui/client/bootstrap.js +120 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
- package/src/ui/client/hooks/useJobList.js +50 -0
- package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
- package/src/ui/client/hooks/useTicker.js +26 -0
- package/src/ui/client/index.css +31 -0
- package/src/ui/client/index.html +18 -0
- package/src/ui/client/main.jsx +38 -0
- package/src/ui/config-bridge.browser.js +149 -0
- package/src/ui/config-bridge.js +149 -0
- package/src/ui/config-bridge.node.js +310 -0
- package/src/ui/dist/assets/index-CxcrauYR.js +22702 -0
- package/src/ui/dist/assets/style-D6K_oQ12.css +62 -0
- package/src/ui/dist/index.html +19 -0
- package/src/ui/endpoints/job-endpoints.js +300 -0
- package/src/ui/file-reader.js +216 -0
- package/src/ui/job-change-detector.js +83 -0
- package/src/ui/job-index.js +231 -0
- package/src/ui/job-reader.js +274 -0
- package/src/ui/job-scanner.js +188 -0
- package/src/ui/public/app.js +3 -1
- package/src/ui/server.js +1636 -59
- package/src/ui/sse-enhancer.js +149 -0
- package/src/ui/sse.js +204 -0
- package/src/ui/state-snapshot.js +252 -0
- package/src/ui/transformers/list-transformer.js +347 -0
- package/src/ui/transformers/status-transformer.js +307 -0
- package/src/ui/watcher.js +61 -7
- package/src/utils/dag.js +101 -0
- package/src/utils/duration.js +126 -0
- package/src/utils/id-generator.js +30 -0
- package/src/utils/jobs.js +7 -0
- package/src/utils/pipelines.js +44 -0
- package/src/utils/task-files.js +271 -0
- package/src/utils/ui.jsx +76 -0
- package/src/ui/public/index.html +0 -53
- package/src/ui/public/style.css +0 -341
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { useJobList } from "./useJobList.js";
|
|
3
|
+
import { sortJobs } from "../../transformers/list-transformer.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useJobListWithUpdates
|
|
7
|
+
*
|
|
8
|
+
* - Uses useJobList to fetch initial data (or a snapshot)
|
|
9
|
+
* - Hydrates local state from base data, then listens for SSE incremental events
|
|
10
|
+
* - Applies SSE events idempotently via pure reducer functions
|
|
11
|
+
* - Maintains connectionStatus derived from EventSource.readyState
|
|
12
|
+
* - Queues events received before hydration completes and applies them after hydrate
|
|
13
|
+
*
|
|
14
|
+
* Note: SSE payloads (job:created, job:updated) match the canonical list item shape
|
|
15
|
+
* from /api/jobs to ensure consistent updates without page refresh.
|
|
16
|
+
*/
|
|
17
|
+
function applyJobEvent(prev = [], event) {
|
|
18
|
+
// prev: Array of jobs (treated immutably)
|
|
19
|
+
// event: { type: string, payload: object }
|
|
20
|
+
const list = Array.isArray(prev) ? prev.slice() : [];
|
|
21
|
+
|
|
22
|
+
if (!event || !event.type) return list;
|
|
23
|
+
|
|
24
|
+
const p = event.payload || {};
|
|
25
|
+
switch (event.type) {
|
|
26
|
+
case "job:created": {
|
|
27
|
+
if (!p.jobId) return list;
|
|
28
|
+
const idx = list.findIndex((j) => j.jobId === p.jobId);
|
|
29
|
+
if (idx === -1) {
|
|
30
|
+
// New job: add and sort
|
|
31
|
+
list.push(p);
|
|
32
|
+
return sortJobs(list);
|
|
33
|
+
} else {
|
|
34
|
+
// Merge with existing; if no effective change, return prev to avoid unnecessary updates
|
|
35
|
+
const merged = { ...list[idx], ...p };
|
|
36
|
+
try {
|
|
37
|
+
if (JSON.stringify(merged) === JSON.stringify(list[idx])) return prev;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// Fall back to returning merged result if JSON stringify fails
|
|
40
|
+
}
|
|
41
|
+
list[idx] = merged;
|
|
42
|
+
return sortJobs(list);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case "job:updated": {
|
|
47
|
+
if (!p.jobId) return list;
|
|
48
|
+
const idx = list.findIndex((j) => j.jobId === p.jobId);
|
|
49
|
+
if (idx === -1) {
|
|
50
|
+
// If we don't have it yet, add it
|
|
51
|
+
list.push(p);
|
|
52
|
+
return sortJobs(list);
|
|
53
|
+
} else {
|
|
54
|
+
const merged = { ...list[idx], ...p };
|
|
55
|
+
try {
|
|
56
|
+
if (JSON.stringify(merged) === JSON.stringify(list[idx])) return prev;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// ignore stringify errors
|
|
59
|
+
}
|
|
60
|
+
list[idx] = merged;
|
|
61
|
+
return sortJobs(list);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case "job:removed": {
|
|
66
|
+
if (!p.jobId) return list;
|
|
67
|
+
const filtered = list.filter((j) => j.jobId !== p.jobId);
|
|
68
|
+
// If nothing removed, return prev
|
|
69
|
+
try {
|
|
70
|
+
if (JSON.stringify(filtered) === JSON.stringify(prev)) return prev;
|
|
71
|
+
} catch (e) {}
|
|
72
|
+
return filtered;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "status:changed": {
|
|
76
|
+
if (!p.jobId) return list;
|
|
77
|
+
const mapped = list.map((j) =>
|
|
78
|
+
j.jobId === p.jobId ? { ...j, status: p.status } : j
|
|
79
|
+
);
|
|
80
|
+
try {
|
|
81
|
+
if (JSON.stringify(mapped) === JSON.stringify(prev)) return prev;
|
|
82
|
+
} catch (e) {}
|
|
83
|
+
return mapped;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
default:
|
|
87
|
+
return list;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function useJobListWithUpdates() {
|
|
92
|
+
const base = useJobList();
|
|
93
|
+
const { loading, data, error, refetch } = base;
|
|
94
|
+
|
|
95
|
+
const [localData, setLocalData] = useState(data || null);
|
|
96
|
+
const [connectionStatus, setConnectionStatus] = useState("disconnected");
|
|
97
|
+
const esRef = useRef(null);
|
|
98
|
+
const reconnectTimer = useRef(null);
|
|
99
|
+
// Debounce timer for refetch triggered by seed uploads or state-level events
|
|
100
|
+
const refetchDebounceRef = useRef(null);
|
|
101
|
+
const REFETCH_DEBOUNCE_MS = 300;
|
|
102
|
+
|
|
103
|
+
// Hydration guard and event queue for events that arrive before hydration completes.
|
|
104
|
+
const hydratedRef = useRef(false);
|
|
105
|
+
const eventQueue = useRef([]);
|
|
106
|
+
|
|
107
|
+
// Keep localData in sync when base data changes (this is the hydration point).
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
setLocalData(data || null);
|
|
110
|
+
|
|
111
|
+
if (data && Array.isArray(data)) {
|
|
112
|
+
// Compute hydrated base from incoming data, then apply any queued events deterministically
|
|
113
|
+
const base = Array.isArray(data) ? data.slice() : [];
|
|
114
|
+
if (eventQueue.current.length > 0) {
|
|
115
|
+
try {
|
|
116
|
+
const applied = eventQueue.current.reduce(
|
|
117
|
+
(acc, ev) => applyJobEvent(acc, ev),
|
|
118
|
+
base
|
|
119
|
+
);
|
|
120
|
+
// Debug: show applied array content for test troubleshooting
|
|
121
|
+
setLocalData(applied);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// eslint-disable-next-line no-console
|
|
124
|
+
console.error(
|
|
125
|
+
"[useJobListWithUpdates] failed applying queued events",
|
|
126
|
+
e
|
|
127
|
+
);
|
|
128
|
+
// Fallback: set base as localData
|
|
129
|
+
setLocalData(base);
|
|
130
|
+
}
|
|
131
|
+
eventQueue.current = [];
|
|
132
|
+
hydratedRef.current = true;
|
|
133
|
+
} else {
|
|
134
|
+
// No queued events: just hydrate to base
|
|
135
|
+
setLocalData(base);
|
|
136
|
+
hydratedRef.current = true;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
hydratedRef.current = false;
|
|
140
|
+
}
|
|
141
|
+
}, [data]);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
// Only create one EventSource per mounted hook instance
|
|
145
|
+
if (esRef.current) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Helper to attach listeners to a given EventSource instance
|
|
150
|
+
const attachListeners = (es) => {
|
|
151
|
+
const onOpen = () => {
|
|
152
|
+
setConnectionStatus("connected");
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const onError = () => {
|
|
156
|
+
// Derive state from readyState when possible
|
|
157
|
+
try {
|
|
158
|
+
const rs = esRef.current?.readyState;
|
|
159
|
+
if (rs === 0) {
|
|
160
|
+
// connecting
|
|
161
|
+
setConnectionStatus("disconnected");
|
|
162
|
+
} else if (rs === 1) {
|
|
163
|
+
setConnectionStatus("connected");
|
|
164
|
+
} else if (rs === 2) {
|
|
165
|
+
setConnectionStatus("disconnected");
|
|
166
|
+
} else {
|
|
167
|
+
setConnectionStatus("disconnected");
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
setConnectionStatus("disconnected");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Attempt reconnect after 2s if closed
|
|
174
|
+
if (esRef.current && esRef.current.readyState === 2) {
|
|
175
|
+
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
|
176
|
+
reconnectTimer.current = setTimeout(() => {
|
|
177
|
+
try {
|
|
178
|
+
// Close existing reference if any
|
|
179
|
+
try {
|
|
180
|
+
esRef.current?.close();
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// ignore
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Create a fresh EventSource and attach the same listeners so reconnect works
|
|
186
|
+
const newEs = new EventSource("/api/events");
|
|
187
|
+
newEs.addEventListener("open", onOpen);
|
|
188
|
+
newEs.addEventListener("job:updated", onJobUpdated);
|
|
189
|
+
newEs.addEventListener("job:created", onJobCreated);
|
|
190
|
+
newEs.addEventListener("job:removed", onJobRemoved);
|
|
191
|
+
newEs.addEventListener("status:changed", onStatusChanged);
|
|
192
|
+
newEs.addEventListener("error", onError);
|
|
193
|
+
|
|
194
|
+
esRef.current = newEs;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// ignore
|
|
197
|
+
}
|
|
198
|
+
}, 2000);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const handleIncomingEvent = (type, evt) => {
|
|
203
|
+
try {
|
|
204
|
+
const payload = evt && evt.data ? JSON.parse(evt.data) : null;
|
|
205
|
+
const eventObj = { type, payload };
|
|
206
|
+
|
|
207
|
+
if (!hydratedRef.current) {
|
|
208
|
+
// Queue events functionally (avoid mutating existing array in place)
|
|
209
|
+
eventQueue.current = (eventQueue.current || []).concat(eventObj);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Apply event using pure reducer. If reducer returns an unchanged value,
|
|
214
|
+
// return prev to avoid unnecessary re-renders.
|
|
215
|
+
setLocalData((prev) => {
|
|
216
|
+
const next = applyJobEvent(prev, eventObj);
|
|
217
|
+
try {
|
|
218
|
+
if (JSON.stringify(prev) === JSON.stringify(next)) {
|
|
219
|
+
return prev;
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
// If stringify fails, fall back to returning next
|
|
223
|
+
}
|
|
224
|
+
return next;
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Non-fatal: keep queue intact and continue
|
|
228
|
+
// Logging for visibility in dev; tests should mock console if asserting logs
|
|
229
|
+
// eslint-disable-next-line no-console
|
|
230
|
+
if (type === "job:updated") {
|
|
231
|
+
console.error("Failed to parse job update event:", err);
|
|
232
|
+
} else {
|
|
233
|
+
console.error("Failed to handle SSE event:", err);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const onJobUpdated = (evt) => handleIncomingEvent("job:updated", evt);
|
|
239
|
+
const onJobCreated = (evt) => handleIncomingEvent("job:created", evt);
|
|
240
|
+
const onJobRemoved = (evt) => handleIncomingEvent("job:removed", evt);
|
|
241
|
+
const onStatusChanged = (evt) =>
|
|
242
|
+
handleIncomingEvent("status:changed", evt);
|
|
243
|
+
|
|
244
|
+
const scheduleRefetch = () => {
|
|
245
|
+
if (refetchDebounceRef.current)
|
|
246
|
+
clearTimeout(refetchDebounceRef.current);
|
|
247
|
+
refetchDebounceRef.current = setTimeout(() => {
|
|
248
|
+
try {
|
|
249
|
+
if (typeof refetch === "function") {
|
|
250
|
+
refetch();
|
|
251
|
+
}
|
|
252
|
+
} catch (e) {
|
|
253
|
+
// ignore refetch failures
|
|
254
|
+
} finally {
|
|
255
|
+
refetchDebounceRef.current = null;
|
|
256
|
+
}
|
|
257
|
+
}, REFETCH_DEBOUNCE_MS);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const onSeedUploaded = (evt) => {
|
|
261
|
+
// Minimal parsing - we don't need payload for refetching
|
|
262
|
+
try {
|
|
263
|
+
// eslint-disable-next-line no-unused-vars
|
|
264
|
+
const payload = evt && evt.data ? JSON.parse(evt.data) : null;
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// ignore parse errors
|
|
267
|
+
}
|
|
268
|
+
scheduleRefetch();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const onStateChange = (evt) => {
|
|
272
|
+
// Treat state-level changes as a hint to refetch the jobs list (debounced)
|
|
273
|
+
scheduleRefetch();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
es.addEventListener("open", onOpen);
|
|
277
|
+
es.addEventListener("job:updated", onJobUpdated);
|
|
278
|
+
es.addEventListener("job:created", onJobCreated);
|
|
279
|
+
es.addEventListener("job:removed", onJobRemoved);
|
|
280
|
+
es.addEventListener("status:changed", onStatusChanged);
|
|
281
|
+
es.addEventListener("seed:uploaded", onSeedUploaded);
|
|
282
|
+
es.addEventListener("state:change", onStateChange);
|
|
283
|
+
es.addEventListener("state:summary", onStateChange);
|
|
284
|
+
es.addEventListener("error", onError);
|
|
285
|
+
|
|
286
|
+
// Set connection status from readyState when possible
|
|
287
|
+
if (es.readyState === 1) setConnectionStatus("connected");
|
|
288
|
+
else if (es.readyState === 0) setConnectionStatus("disconnected");
|
|
289
|
+
|
|
290
|
+
return () => {
|
|
291
|
+
try {
|
|
292
|
+
es.removeEventListener("open", onOpen);
|
|
293
|
+
es.removeEventListener("job:updated", onJobUpdated);
|
|
294
|
+
es.removeEventListener("job:created", onJobCreated);
|
|
295
|
+
es.removeEventListener("job:removed", onJobRemoved);
|
|
296
|
+
es.removeEventListener("status:changed", onStatusChanged);
|
|
297
|
+
es.removeEventListener("seed:uploaded", onSeedUploaded);
|
|
298
|
+
es.removeEventListener("state:change", onStateChange);
|
|
299
|
+
es.removeEventListener("state:summary", onStateChange);
|
|
300
|
+
es.removeEventListener("error", onError);
|
|
301
|
+
es.close();
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// ignore
|
|
304
|
+
}
|
|
305
|
+
if (reconnectTimer.current) {
|
|
306
|
+
clearTimeout(reconnectTimer.current);
|
|
307
|
+
reconnectTimer.current = null;
|
|
308
|
+
}
|
|
309
|
+
esRef.current = null;
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Create EventSource on mount regardless of base snapshot presence
|
|
314
|
+
try {
|
|
315
|
+
const es = new EventSource("/api/events");
|
|
316
|
+
esRef.current = es;
|
|
317
|
+
|
|
318
|
+
// attach listeners and return the cleanup function
|
|
319
|
+
return attachListeners(es);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
// eslint-disable-next-line no-console
|
|
322
|
+
console.error("Failed to create SSE connection:", err);
|
|
323
|
+
setConnectionStatus("error");
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
}, []);
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
loading,
|
|
330
|
+
data: localData,
|
|
331
|
+
error,
|
|
332
|
+
refetch,
|
|
333
|
+
connectionStatus,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reactive ticker hook that provides updating timestamp
|
|
5
|
+
* @param {number} intervalMs - Update interval in milliseconds (default: 1000)
|
|
6
|
+
* @returns {number} Current timestamp that updates on interval
|
|
7
|
+
*/
|
|
8
|
+
export function useTicker(intervalMs = 1000) {
|
|
9
|
+
const [now, setNow] = useState(() => Date.now());
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
// Set up interval to update timestamp
|
|
13
|
+
const intervalId = setInterval(() => {
|
|
14
|
+
setNow(Date.now());
|
|
15
|
+
}, intervalMs);
|
|
16
|
+
|
|
17
|
+
// Cleanup interval on unmount
|
|
18
|
+
return () => {
|
|
19
|
+
clearInterval(intervalId);
|
|
20
|
+
};
|
|
21
|
+
}, [intervalMs]);
|
|
22
|
+
|
|
23
|
+
return now;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default useTicker;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* src/ui/client/index.css */
|
|
2
|
+
@import "@radix-ui/themes/styles.css" layer(radix);
|
|
3
|
+
@import "tailwindcss";
|
|
4
|
+
|
|
5
|
+
/* Reset and base styles */
|
|
6
|
+
@layer base {
|
|
7
|
+
* {
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
html {
|
|
12
|
+
font-family: system-ui, sans-serif;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
margin: 0;
|
|
17
|
+
line-height: inherit;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
font-family: "Inter", sans-serif;
|
|
23
|
+
line-height: 1.6;
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.container {
|
|
28
|
+
max-width: 1200px;
|
|
29
|
+
margin: 0 auto;
|
|
30
|
+
padding: 2rem;
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
7
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
8
|
+
<link
|
|
9
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
|
|
10
|
+
rel="stylesheet"
|
|
11
|
+
/>
|
|
12
|
+
<title>Prompt Pipeline Dashboard</title>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="root"></div>
|
|
16
|
+
<script type="module" src="./main.jsx"></script>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// import React from "react";
|
|
2
|
+
// import ReactDOM from "react-dom/client";
|
|
3
|
+
// import { Layout } from "../components/Layout.jsx";
|
|
4
|
+
// import "../styles/index.css";
|
|
5
|
+
|
|
6
|
+
// ReactDOM.createRoot(document.getElementById("root")).render(
|
|
7
|
+
// <React.StrictMode>
|
|
8
|
+
// <Layout />
|
|
9
|
+
// </React.StrictMode>
|
|
10
|
+
// );
|
|
11
|
+
|
|
12
|
+
import "./index.css"; // Tailwind + tokens + base
|
|
13
|
+
|
|
14
|
+
import React from "react";
|
|
15
|
+
import ReactDOM from "react-dom/client";
|
|
16
|
+
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|
17
|
+
import PromptPipelineDashboard from "@/pages/PromptPipelineDashboard.jsx";
|
|
18
|
+
import PipelineDetail from "@/pages/PipelineDetail.jsx";
|
|
19
|
+
import { Theme } from "@radix-ui/themes";
|
|
20
|
+
|
|
21
|
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
22
|
+
<React.StrictMode>
|
|
23
|
+
<Theme
|
|
24
|
+
accentColor="iris"
|
|
25
|
+
grayColor="gray"
|
|
26
|
+
panelBackground="solid"
|
|
27
|
+
scaling="100%"
|
|
28
|
+
radius="full"
|
|
29
|
+
>
|
|
30
|
+
<BrowserRouter>
|
|
31
|
+
<Routes>
|
|
32
|
+
<Route path="/" element={<PromptPipelineDashboard />} />
|
|
33
|
+
<Route path="/pipeline/:jobId" element={<PipelineDetail />} />
|
|
34
|
+
</Routes>
|
|
35
|
+
</BrowserRouter>
|
|
36
|
+
</Theme>
|
|
37
|
+
</React.StrictMode>
|
|
38
|
+
);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-specific configuration bridge for UI helpers.
|
|
3
|
+
* Contains only browser-safe utilities (no Node APIs).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Global constants and contracts for project data display system
|
|
8
|
+
* @namespace Constants
|
|
9
|
+
*/
|
|
10
|
+
export const Constants = {
|
|
11
|
+
/**
|
|
12
|
+
* Job ID validation regex
|
|
13
|
+
* @type {RegExp}
|
|
14
|
+
*/
|
|
15
|
+
JOB_ID_REGEX: /^[A-Za-z0-9-_]+$/,
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Valid task states
|
|
19
|
+
* @type {string[]}
|
|
20
|
+
*/
|
|
21
|
+
TASK_STATES: ["pending", "running", "done", "failed"],
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Valid job locations
|
|
25
|
+
* @type {string[]}
|
|
26
|
+
*/
|
|
27
|
+
JOB_LOCATIONS: ["current", "complete"],
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Status sort order (descending priority)
|
|
31
|
+
* @type {string[]}
|
|
32
|
+
*/
|
|
33
|
+
STATUS_ORDER: ["running", "failed", "pending", "complete"],
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* File size limits for reading
|
|
37
|
+
* @type {Object}
|
|
38
|
+
*/
|
|
39
|
+
FILE_LIMITS: {
|
|
40
|
+
MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Retry configuration for atomic reads
|
|
45
|
+
* @type {Object}
|
|
46
|
+
*/
|
|
47
|
+
RETRY_CONFIG: {
|
|
48
|
+
MAX_ATTEMPTS: 3,
|
|
49
|
+
DELAY_MS: 1000,
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* SSE debounce configuration
|
|
54
|
+
* @type {Object}
|
|
55
|
+
*/
|
|
56
|
+
SSE_CONFIG: {
|
|
57
|
+
DEBOUNCE_MS: 200,
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Error codes for structured error responses
|
|
62
|
+
* @type {Object}
|
|
63
|
+
*/
|
|
64
|
+
ERROR_CODES: {
|
|
65
|
+
NOT_FOUND: "not_found",
|
|
66
|
+
INVALID_JSON: "invalid_json",
|
|
67
|
+
FS_ERROR: "fs_error",
|
|
68
|
+
JOB_NOT_FOUND: "job_not_found",
|
|
69
|
+
BAD_REQUEST: "bad_request",
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validates a job ID against the global contract
|
|
75
|
+
* @param {string} jobId - Job ID to validate
|
|
76
|
+
* @returns {boolean} True if valid
|
|
77
|
+
*/
|
|
78
|
+
export function validateJobId(jobId) {
|
|
79
|
+
return Constants.JOB_ID_REGEX.test(jobId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validates a task state against the global contract
|
|
84
|
+
* @param {string} state - Task state to validate
|
|
85
|
+
* @returns {boolean} True if valid
|
|
86
|
+
*/
|
|
87
|
+
export function validateTaskState(state) {
|
|
88
|
+
return Constants.TASK_STATES.includes(state);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gets the status sort priority for a job status
|
|
93
|
+
* @param {string} status - Job status
|
|
94
|
+
* @returns {number} Sort priority (lower number = higher priority)
|
|
95
|
+
*/
|
|
96
|
+
export function getStatusPriority(status) {
|
|
97
|
+
const index = Constants.STATUS_ORDER.indexOf(status);
|
|
98
|
+
return index === -1 ? Constants.STATUS_ORDER.length : index;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Determines job status based on task states
|
|
103
|
+
* @param {Object} tasks - Tasks object from tasks-status.json
|
|
104
|
+
* @returns {string} Job status
|
|
105
|
+
*/
|
|
106
|
+
export function determineJobStatus(tasks = {}) {
|
|
107
|
+
const taskEntries = Object.entries(tasks);
|
|
108
|
+
|
|
109
|
+
if (taskEntries.length === 0) {
|
|
110
|
+
return "pending";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const taskStates = taskEntries.map(([_, task]) => task.state);
|
|
114
|
+
|
|
115
|
+
if (taskStates.includes("failed")) {
|
|
116
|
+
return "failed";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (taskStates.includes("running")) {
|
|
120
|
+
return "running";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (taskStates.every((state) => state === "done")) {
|
|
124
|
+
return "complete";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return "pending";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Creates a structured error response
|
|
132
|
+
* @param {string} code - Error code
|
|
133
|
+
* @param {string} message - Error message
|
|
134
|
+
* @param {string} [path] - Optional file path
|
|
135
|
+
* @returns {Object} Structured error object
|
|
136
|
+
*/
|
|
137
|
+
export function createErrorResponse(code, message, path = null) {
|
|
138
|
+
const error = {
|
|
139
|
+
ok: false,
|
|
140
|
+
code,
|
|
141
|
+
message,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (path) {
|
|
145
|
+
error.path = path;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return error;
|
|
149
|
+
}
|