@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.
Files changed (76) hide show
  1. package/README.md +415 -24
  2. package/package.json +46 -8
  3. package/src/api/files.js +48 -0
  4. package/src/api/index.js +149 -53
  5. package/src/api/validators/seed.js +141 -0
  6. package/src/cli/index.js +444 -29
  7. package/src/cli/run-orchestrator.js +39 -0
  8. package/src/cli/update-pipeline-json.js +47 -0
  9. package/src/components/DAGGrid.jsx +649 -0
  10. package/src/components/JobCard.jsx +96 -0
  11. package/src/components/JobDetail.jsx +159 -0
  12. package/src/components/JobTable.jsx +202 -0
  13. package/src/components/Layout.jsx +134 -0
  14. package/src/components/TaskFilePane.jsx +570 -0
  15. package/src/components/UploadSeed.jsx +239 -0
  16. package/src/components/ui/badge.jsx +20 -0
  17. package/src/components/ui/button.jsx +43 -0
  18. package/src/components/ui/card.jsx +20 -0
  19. package/src/components/ui/focus-styles.css +60 -0
  20. package/src/components/ui/progress.jsx +26 -0
  21. package/src/components/ui/select.jsx +27 -0
  22. package/src/components/ui/separator.jsx +6 -0
  23. package/src/config/paths.js +99 -0
  24. package/src/core/config.js +270 -9
  25. package/src/core/file-io.js +202 -0
  26. package/src/core/module-loader.js +157 -0
  27. package/src/core/orchestrator.js +275 -294
  28. package/src/core/pipeline-runner.js +95 -41
  29. package/src/core/progress.js +66 -0
  30. package/src/core/status-writer.js +331 -0
  31. package/src/core/task-runner.js +719 -73
  32. package/src/core/validation.js +120 -1
  33. package/src/lib/utils.js +6 -0
  34. package/src/llm/README.md +139 -30
  35. package/src/llm/index.js +222 -72
  36. package/src/pages/PipelineDetail.jsx +111 -0
  37. package/src/pages/PromptPipelineDashboard.jsx +223 -0
  38. package/src/providers/deepseek.js +3 -15
  39. package/src/ui/client/adapters/job-adapter.js +258 -0
  40. package/src/ui/client/bootstrap.js +120 -0
  41. package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
  42. package/src/ui/client/hooks/useJobList.js +50 -0
  43. package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
  44. package/src/ui/client/hooks/useTicker.js +26 -0
  45. package/src/ui/client/index.css +31 -0
  46. package/src/ui/client/index.html +18 -0
  47. package/src/ui/client/main.jsx +38 -0
  48. package/src/ui/config-bridge.browser.js +149 -0
  49. package/src/ui/config-bridge.js +149 -0
  50. package/src/ui/config-bridge.node.js +310 -0
  51. package/src/ui/dist/assets/index-CxcrauYR.js +22702 -0
  52. package/src/ui/dist/assets/style-D6K_oQ12.css +62 -0
  53. package/src/ui/dist/index.html +19 -0
  54. package/src/ui/endpoints/job-endpoints.js +300 -0
  55. package/src/ui/file-reader.js +216 -0
  56. package/src/ui/job-change-detector.js +83 -0
  57. package/src/ui/job-index.js +231 -0
  58. package/src/ui/job-reader.js +274 -0
  59. package/src/ui/job-scanner.js +188 -0
  60. package/src/ui/public/app.js +3 -1
  61. package/src/ui/server.js +1636 -59
  62. package/src/ui/sse-enhancer.js +149 -0
  63. package/src/ui/sse.js +204 -0
  64. package/src/ui/state-snapshot.js +252 -0
  65. package/src/ui/transformers/list-transformer.js +347 -0
  66. package/src/ui/transformers/status-transformer.js +307 -0
  67. package/src/ui/watcher.js +61 -7
  68. package/src/utils/dag.js +101 -0
  69. package/src/utils/duration.js +126 -0
  70. package/src/utils/id-generator.js +30 -0
  71. package/src/utils/jobs.js +7 -0
  72. package/src/utils/pipelines.js +44 -0
  73. package/src/utils/task-files.js +271 -0
  74. package/src/utils/ui.jsx +76 -0
  75. package/src/ui/public/index.html +0 -53
  76. package/src/ui/public/style.css +0 -341
@@ -0,0 +1,619 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
+ import { adaptJobDetail } from "../adapters/job-adapter.js";
3
+
4
+ // Export debounce constant for tests
5
+ export const REFRESH_DEBOUNCE_MS = 200;
6
+
7
+ // Instrumentation helper for useJobDetailWithUpdates
8
+ const createHookLogger = (jobId) => {
9
+ const prefix = `[useJobDetailWithUpdates:${jobId || "unknown"}]`;
10
+ return {
11
+ log: (message, data = null) => {
12
+ console.log(`${prefix} ${message}`, data ? data : "");
13
+ },
14
+ warn: (message, data = null) => {
15
+ console.warn(`${prefix} ${message}`, data ? data : "");
16
+ },
17
+ error: (message, data = null) => {
18
+ console.error(`${prefix} ${message}`, data ? data : "");
19
+ },
20
+ group: (label) => console.group(`${prefix} ${label}`),
21
+ groupEnd: () => console.groupEnd(),
22
+ table: (data, title) => {
23
+ console.log(`${prefix} ${title}:`);
24
+ console.table(data);
25
+ },
26
+ sse: (eventType, eventData) => {
27
+ console.log(
28
+ `%c${prefix} SSE Event: ${eventType}`,
29
+ "color: #0066cc; font-weight: bold;",
30
+ eventData
31
+ );
32
+ },
33
+ state: (stateName, value) => {
34
+ console.log(
35
+ `%c${prefix} State Change: ${stateName}`,
36
+ "color: #006600; font-weight: bold;",
37
+ value
38
+ );
39
+ },
40
+ };
41
+ };
42
+
43
+ /**
44
+ * fetchJobDetail - Extracted fetch logic for job details
45
+ *
46
+ * @param {string} jobId - The job ID to fetch
47
+ * @param {Object} options - Options object
48
+ * @param {AbortSignal} options.signal - Optional abort signal
49
+ * @returns {Promise<Object>} Job data
50
+ */
51
+ async function fetchJobDetail(jobId, { signal } = {}) {
52
+ const response = await fetch(`/api/jobs/${jobId}`, { signal });
53
+
54
+ if (!response.ok) {
55
+ const errorData = await response.json().catch(() => ({}));
56
+ throw new Error(errorData.message || `HTTP ${response.status}`);
57
+ }
58
+
59
+ const result = await response.json();
60
+
61
+ if (!result.ok) {
62
+ throw new Error(result.message || "Failed to load job");
63
+ }
64
+
65
+ return adaptJobDetail(result.data);
66
+ }
67
+
68
+ /**
69
+ * applyJobEvent - Reducer function to apply SSE events to a single job
70
+ * (pure, no side effects)
71
+ *
72
+ * @param {Object} prev - Previous job state
73
+ * @param {Object} event - SSE event with type and payload
74
+ * @param {string} jobId - Current job ID for filtering
75
+ * @returns {Object} Updated job state
76
+ */
77
+ function applyJobEvent(prev = null, event, jobId) {
78
+ if (!event || !event.type) return prev;
79
+
80
+ const p = event.payload || {};
81
+
82
+ // If this event is for a different job, return unchanged
83
+ if (p.jobId && prev && p.jobId !== prev.jobId) {
84
+ return prev;
85
+ }
86
+
87
+ switch (event.type) {
88
+ case "job:created": {
89
+ if (!p.jobId) return prev;
90
+ // If we don't have a job yet, or this matches our job, use it
91
+ if (!prev || prev.jobId === p.jobId) {
92
+ return { ...p };
93
+ }
94
+ return prev;
95
+ }
96
+
97
+ case "job:updated": {
98
+ if (!p.jobId) return prev;
99
+ // Only update if this matches our job
100
+ if (!prev || prev.jobId !== p.jobId) {
101
+ return prev;
102
+ }
103
+ const merged = { ...prev, ...p };
104
+ try {
105
+ if (JSON.stringify(merged) === JSON.stringify(prev)) return prev;
106
+ } catch (e) {}
107
+ return merged;
108
+ }
109
+
110
+ case "job:removed": {
111
+ if (!p.jobId) return prev;
112
+ // If this is our job, return null to indicate it was removed
113
+ if (prev && prev.jobId === p.jobId) {
114
+ return null;
115
+ }
116
+ return prev;
117
+ }
118
+
119
+ case "status:changed": {
120
+ if (!p.jobId) return prev;
121
+ if (!prev || prev.jobId !== p.jobId) return prev;
122
+ const updated = { ...prev, status: p.status };
123
+ try {
124
+ if (JSON.stringify(updated) === JSON.stringify(prev)) return prev;
125
+ } catch (e) {}
126
+ return updated;
127
+ }
128
+
129
+ case "state:change": {
130
+ // Direct-apply only (jobId-present). Path-only handling is done by event handler
131
+ const data = p.data || p;
132
+ if (
133
+ data.jobId &&
134
+ data.jobId === jobId &&
135
+ prev &&
136
+ prev.jobId === data.jobId
137
+ ) {
138
+ const merged = { ...prev, ...data };
139
+ try {
140
+ if (JSON.stringify(merged) === JSON.stringify(prev)) return prev;
141
+ } catch (e) {}
142
+ return merged;
143
+ }
144
+ return prev;
145
+ }
146
+
147
+ default:
148
+ console.log("XXX: Unknown event type:", event.type);
149
+ return prev;
150
+ }
151
+ }
152
+
153
+ function matchesJobTasksStatusPath(path, jobId) {
154
+ try {
155
+ // Normalize path: convert backslashes to "/", trim whitespace
156
+ const normalizedPath = path.replace(/\\/g, "/").trim();
157
+ const re = new RegExp(
158
+ `(?:^|/)pipeline-data/(current|complete|pending|rejected)/${jobId}/`
159
+ );
160
+ return re.test(normalizedPath);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * useJobDetailWithUpdates
168
+ *
169
+ * Hook for fetching and maintaining a single job's state with SSE updates.
170
+ * Filters SSE events to only apply to the specified jobId.
171
+ *
172
+ * @param {string} jobId - The job ID to fetch and monitor
173
+ * @returns {Object} { data, loading, error, connectionStatus }
174
+ */
175
+ export function useJobDetailWithUpdates(jobId) {
176
+ const logger = useMemo(() => createHookLogger(jobId), [jobId]);
177
+
178
+ const [data, setData] = useState(null);
179
+ const [loading, setLoading] = useState(true);
180
+ const [error, setError] = useState(null);
181
+ const [connectionStatus, setConnectionStatus] = useState("disconnected");
182
+
183
+ const esRef = useRef(null);
184
+ const reconnectTimer = useRef(null);
185
+ const hydratedRef = useRef(false);
186
+ const eventQueue = useRef([]);
187
+ const mountedRef = useRef(true);
188
+ const refetchTimerRef = useRef(null);
189
+
190
+ // Log hook initialization and state changes
191
+ useEffect(() => {
192
+ logger.group("Hook Initialization");
193
+ logger.log("Job ID:", jobId);
194
+ logger.log("Initial state:", { data, loading, error, connectionStatus });
195
+ logger.groupEnd();
196
+ }, [jobId, logger]);
197
+
198
+ useEffect(() => {
199
+ logger.state("data", data);
200
+ }, [data, logger]);
201
+
202
+ useEffect(() => {
203
+ logger.state("loading", loading);
204
+ }, [loading, logger]);
205
+
206
+ useEffect(() => {
207
+ logger.state("error", error);
208
+ }, [error, logger]);
209
+
210
+ useEffect(() => {
211
+ logger.state("connectionStatus", connectionStatus);
212
+ }, [connectionStatus, logger]);
213
+
214
+ // Debounced refetch helper (called directly from handlers)
215
+ const scheduleDebouncedRefetch = useCallback(
216
+ (context = {}) => {
217
+ logger.group("Debounced Refetch Request");
218
+ logger.log("Request context:", context);
219
+ logger.log("Scheduling debounced refetch");
220
+ if (refetchTimerRef.current) {
221
+ logger.log("Clearing existing refetch timer");
222
+ clearTimeout(refetchTimerRef.current);
223
+ }
224
+ refetchTimerRef.current = setTimeout(async () => {
225
+ if (!mountedRef.current || !hydratedRef.current) {
226
+ logger.warn(
227
+ "Refetch aborted - component not mounted or not hydrated",
228
+ { mounted: mountedRef.current, hydrated: hydratedRef.current }
229
+ );
230
+ logger.groupEnd();
231
+ return;
232
+ }
233
+ logger.log("Executing debounced refetch");
234
+ logger.log("Refetch jobId:", jobId);
235
+ const abortController = new AbortController();
236
+ try {
237
+ const jobData = await fetchJobDetail(jobId, {
238
+ signal: abortController.signal,
239
+ });
240
+ logger.log("Refetch response received");
241
+ logger.log("Refetch job data preview:", {
242
+ status: jobData?.status,
243
+ hasTasks: !!jobData?.tasks,
244
+ taskKeys: jobData?.tasks ? Object.keys(jobData.tasks) : [],
245
+ hasTasksStatus: !!jobData?.tasksStatus,
246
+ });
247
+ if (mountedRef.current) {
248
+ logger.log("Refetch successful, updating data");
249
+ setData(jobData);
250
+ setError(null);
251
+ } else {
252
+ logger.warn("Refetch completed but component is unmounted");
253
+ }
254
+ } catch (err) {
255
+ logger.error("Failed to refetch job detail:", err);
256
+ if (mountedRef.current) setError(err.message);
257
+ } finally {
258
+ refetchTimerRef.current = null;
259
+ logger.groupEnd();
260
+ }
261
+ }, REFRESH_DEBOUNCE_MS);
262
+ },
263
+ [jobId, logger]
264
+ );
265
+
266
+ // Reset state when jobId changes
267
+ useEffect(() => {
268
+ setData(null);
269
+ setLoading(true);
270
+ setError(null);
271
+ setConnectionStatus("disconnected");
272
+ hydratedRef.current = false;
273
+ eventQueue.current = [];
274
+ if (refetchTimerRef.current) {
275
+ clearTimeout(refetchTimerRef.current);
276
+ refetchTimerRef.current = null;
277
+ }
278
+ }, [jobId]);
279
+
280
+ // Fetch job detail on mount and when jobId changes
281
+ useEffect(() => {
282
+ if (!jobId || !mountedRef.current) return;
283
+
284
+ const doFetch = async () => {
285
+ logger.group("Initial Data Fetch");
286
+ try {
287
+ setLoading(true);
288
+ setError(null);
289
+ logger.log("Starting initial job data fetch");
290
+
291
+ const jobData = await fetchJobDetail(jobId);
292
+ logger.log("Initial fetch successful", jobData);
293
+
294
+ // Apply any queued events to the fresh data (purely), and detect if a refetch is needed
295
+ let finalData = jobData;
296
+ let queuedNeedsRefetch = false;
297
+ if (eventQueue.current.length > 0) {
298
+ logger.log(`Processing ${eventQueue.current.length} queued events`);
299
+ for (const ev of eventQueue.current) {
300
+ logger.log("Processing queued event:", ev);
301
+ if (ev.type === "state:change") {
302
+ const d = (ev.payload && (ev.payload.data || ev.payload)) || {};
303
+ if (
304
+ typeof d.path === "string" &&
305
+ matchesJobTasksStatusPath(d.path, jobId)
306
+ ) {
307
+ logger.log(
308
+ "Queued state:change matches tasks-status path, scheduling refetch"
309
+ );
310
+ queuedNeedsRefetch = true;
311
+ continue; // don't apply to data
312
+ }
313
+ }
314
+ finalData = applyJobEvent(finalData, ev, jobId);
315
+ logger.log("Applied queued event, result:", finalData);
316
+ }
317
+ eventQueue.current = [];
318
+ }
319
+
320
+ if (mountedRef.current) {
321
+ logger.log("Updating state with final data");
322
+ setData(finalData);
323
+ setError(null);
324
+ hydratedRef.current = true;
325
+ logger.log("Component hydrated");
326
+
327
+ // Now that we're hydrated, if any queued path-only change was seen, schedule a refetch
328
+ if (queuedNeedsRefetch) {
329
+ logger.log("Scheduling refetch for queued path changes");
330
+ scheduleDebouncedRefetch();
331
+ }
332
+ }
333
+ } catch (err) {
334
+ logger.error("Failed to fetch job detail:", err);
335
+ if (mountedRef.current) {
336
+ setError(err.message);
337
+ setData(null);
338
+ }
339
+ } finally {
340
+ if (mountedRef.current) {
341
+ setLoading(false);
342
+ }
343
+ logger.groupEnd();
344
+ }
345
+ };
346
+
347
+ doFetch();
348
+ }, [jobId, scheduleDebouncedRefetch, logger]);
349
+
350
+ // Set up SSE connection
351
+ useEffect(() => {
352
+ if (!jobId) {
353
+ logger.log("SSE setup skipped - no jobId available", {
354
+ hasJobId: !!jobId,
355
+ hasExistingEs: !!esRef.current,
356
+ isMounted: mountedRef.current,
357
+ });
358
+ return undefined;
359
+ }
360
+ if (esRef.current) {
361
+ logger.log("Closing existing EventSource before reinitializing");
362
+ try {
363
+ esRef.current.close();
364
+ } catch (err) {
365
+ logger.warn("Error closing existing EventSource during reinit", err);
366
+ }
367
+ esRef.current = null;
368
+ }
369
+
370
+ logger.group("SSE Connection Setup");
371
+ logger.log("Setting up SSE connection for job:", jobId);
372
+
373
+ // Helper to attach listeners to a given EventSource instance
374
+ const attachListeners = (es) => {
375
+ const onOpen = () => {
376
+ logger.log("SSE connection opened");
377
+ if (mountedRef.current) {
378
+ setConnectionStatus("connected");
379
+ }
380
+ };
381
+
382
+ const onError = () => {
383
+ logger.warn("SSE connection error");
384
+ // Derive state from readyState when possible
385
+ try {
386
+ const rs = esRef.current?.readyState;
387
+ logger.log("SSE readyState:", rs);
388
+ if (rs === 0) {
389
+ if (mountedRef.current) setConnectionStatus("disconnected");
390
+ } else if (rs === 1) {
391
+ if (mountedRef.current) setConnectionStatus("connected");
392
+ } else if (rs === 2) {
393
+ if (mountedRef.current) setConnectionStatus("disconnected");
394
+ } else {
395
+ if (mountedRef.current) setConnectionStatus("disconnected");
396
+ }
397
+ } catch (err) {
398
+ logger.error("Error getting readyState:", err);
399
+ if (mountedRef.current) setConnectionStatus("disconnected");
400
+ }
401
+
402
+ // Attempt reconnect after 2s if closed
403
+ if (
404
+ esRef.current &&
405
+ esRef.current.readyState === 2 &&
406
+ mountedRef.current
407
+ ) {
408
+ logger.log("Scheduling SSE reconnection");
409
+ if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
410
+ reconnectTimer.current = setTimeout(() => {
411
+ if (!mountedRef.current) return;
412
+
413
+ try {
414
+ // Close existing reference if any
415
+ try {
416
+ esRef.current?.close();
417
+ } catch (e) {}
418
+
419
+ // Create a fresh EventSource and attach the same listeners
420
+ const eventsUrl = jobId
421
+ ? `/api/events?jobId=${encodeURIComponent(jobId)}`
422
+ : "/api/events";
423
+ logger.log("Creating new EventSource for reconnection");
424
+ const newEs = new EventSource(eventsUrl);
425
+ newEs.addEventListener("open", onOpen);
426
+ newEs.addEventListener("job:updated", onJobUpdated);
427
+ newEs.addEventListener("job:created", onJobCreated);
428
+ newEs.addEventListener("job:removed", onJobRemoved);
429
+ newEs.addEventListener("status:changed", onStatusChanged);
430
+ newEs.addEventListener("state:change", onStateChange);
431
+ newEs.addEventListener("error", onError);
432
+
433
+ esRef.current = newEs;
434
+ } catch (err) {
435
+ logger.error("Failed to reconnect SSE:", err);
436
+ }
437
+ }, 2000);
438
+ }
439
+ };
440
+
441
+ const handleIncomingEvent = (type, evt) => {
442
+ try {
443
+ const payload = evt && evt.data ? JSON.parse(evt.data) : null;
444
+ const eventObj = { type, payload };
445
+
446
+ logger.sse(type, payload);
447
+
448
+ // Filter events by jobId - only process events for our job when jobId is present
449
+ if (payload && payload.jobId && payload.jobId !== jobId) {
450
+ logger.log(
451
+ `Ignoring event for different job: ${payload.jobId} (current: ${jobId})`
452
+ );
453
+ return; // Ignore events for other jobs
454
+ }
455
+
456
+ if (!hydratedRef.current) {
457
+ logger.log(`Queueing event until hydration: ${type}`);
458
+ // Queue events until hydration completes
459
+ eventQueue.current = (eventQueue.current || []).concat(eventObj);
460
+ return;
461
+ }
462
+
463
+ // Path-matching state:change → schedule debounced refetch
464
+ if (type === "state:change") {
465
+ const d = (payload && (payload.data || payload)) || {};
466
+ logger.log("Processing state:change event:", d);
467
+ if (
468
+ typeof d.path === "string" &&
469
+ matchesJobTasksStatusPath(d.path, jobId)
470
+ ) {
471
+ logger.log(
472
+ `state:change matches tasks-status path: ${d.path}, scheduling refetch`
473
+ );
474
+ scheduleDebouncedRefetch({
475
+ reason: "state:change",
476
+ path: d.path,
477
+ });
478
+ return; // no direct setData
479
+ } else {
480
+ logger.log(
481
+ `state:change does not match tasks-status path: ${d.path}`
482
+ );
483
+ }
484
+ }
485
+
486
+ // Apply event using pure reducer (includes direct state:change with id)
487
+ setData((prev) => {
488
+ logger.group("Applying SSE event to state");
489
+ logger.log("Previous state snapshot:", {
490
+ hasTasks: !!prev?.tasks,
491
+ taskKeys: prev?.tasks ? Object.keys(prev.tasks) : [],
492
+ status: prev?.status,
493
+ });
494
+ logger.log("Incoming event payload:", payload);
495
+ const next = applyJobEvent(prev, eventObj, jobId);
496
+ try {
497
+ if (JSON.stringify(prev) === JSON.stringify(next)) {
498
+ logger.log("Event application resulted in no state change");
499
+ logger.groupEnd();
500
+ return prev;
501
+ }
502
+ } catch (e) {
503
+ logger.error("Error comparing states:", e);
504
+ }
505
+ logger.log("Event applied, state updated", {
506
+ hasTasks: !!next?.tasks,
507
+ taskKeys: next?.tasks ? Object.keys(next.tasks) : [],
508
+ status: next?.status,
509
+ });
510
+ logger.groupEnd();
511
+ return next;
512
+ });
513
+ } catch (err) {
514
+ logger.error("Failed to handle SSE event:", err);
515
+ // Non-fatal: keep queue intact and continue
516
+ // eslint-disable-next-line no-console
517
+ console.error("Failed to handle SSE event:", err);
518
+ }
519
+ };
520
+
521
+ const onJobUpdated = (evt) => handleIncomingEvent("job:updated", evt);
522
+ const onJobCreated = (evt) => handleIncomingEvent("job:created", evt);
523
+ const onJobRemoved = (evt) => handleIncomingEvent("job:removed", evt);
524
+ const onStatusChanged = (evt) =>
525
+ handleIncomingEvent("status:changed", evt);
526
+ const onStateChange = (evt) => handleIncomingEvent("state:change", evt);
527
+
528
+ logger.log("Attaching SSE event listeners");
529
+ es.addEventListener("open", onOpen);
530
+ es.addEventListener("job:updated", onJobUpdated);
531
+ es.addEventListener("job:created", onJobCreated);
532
+ es.addEventListener("job:removed", onJobRemoved);
533
+ es.addEventListener("status:changed", onStatusChanged);
534
+ es.addEventListener("state:change", onStateChange);
535
+ es.addEventListener("error", onError);
536
+
537
+ // Set connection status from readyState when possible
538
+ if (es.readyState === 1 && mountedRef.current) {
539
+ logger.log("SSE already open, setting connected");
540
+ setConnectionStatus("connected");
541
+ } else if (es.readyState === 0 && mountedRef.current) {
542
+ logger.log("SSE connecting, setting disconnected");
543
+ setConnectionStatus("disconnected");
544
+ }
545
+
546
+ return () => {
547
+ logger.log("Cleaning up SSE connection");
548
+ try {
549
+ es.removeEventListener("open", onOpen);
550
+ es.removeEventListener("job:updated", onJobUpdated);
551
+ es.removeEventListener("job:created", onJobCreated);
552
+ es.removeEventListener("job:removed", onJobRemoved);
553
+ es.removeEventListener("status:changed", onStatusChanged);
554
+ es.removeEventListener("state:change", onStateChange);
555
+ es.removeEventListener("error", onError);
556
+ es.close();
557
+ logger.log("SSE connection closed");
558
+ } catch (err) {
559
+ logger.error("Error during SSE cleanup:", err);
560
+ }
561
+ if (reconnectTimer.current) {
562
+ clearTimeout(reconnectTimer.current);
563
+ reconnectTimer.current = null;
564
+ }
565
+ esRef.current = null;
566
+ };
567
+ };
568
+
569
+ // Create EventSource with jobId query parameter for server-side filtering
570
+ try {
571
+ const eventsUrl = jobId
572
+ ? `/api/events?jobId=${encodeURIComponent(jobId)}`
573
+ : "/api/events";
574
+ logger.log(`Creating EventSource with URL: ${eventsUrl}`);
575
+ const es = new EventSource(eventsUrl);
576
+ esRef.current = es;
577
+
578
+ const cleanup = attachListeners(es);
579
+ logger.groupEnd(); // End SSE Connection Setup group
580
+ return cleanup;
581
+ } catch (err) {
582
+ logger.error("Failed to create SSE connection:", err);
583
+ if (mountedRef.current) {
584
+ setConnectionStatus("error");
585
+ }
586
+ logger.groupEnd(); // End SSE Connection Setup group
587
+ return undefined;
588
+ }
589
+ }, [jobId, scheduleDebouncedRefetch, logger]);
590
+
591
+ // Mount/unmount lifecycle: ensure mountedRef is true on mount (StrictMode-safe)
592
+ useEffect(() => {
593
+ mountedRef.current = true;
594
+ return () => {
595
+ mountedRef.current = false;
596
+ if (reconnectTimer.current) {
597
+ clearTimeout(reconnectTimer.current);
598
+ reconnectTimer.current = null;
599
+ }
600
+ if (refetchTimerRef.current) {
601
+ clearTimeout(refetchTimerRef.current);
602
+ refetchTimerRef.current = null;
603
+ }
604
+ if (esRef.current) {
605
+ try {
606
+ esRef.current.close();
607
+ } catch (e) {}
608
+ esRef.current = null;
609
+ }
610
+ };
611
+ }, []);
612
+
613
+ return {
614
+ data,
615
+ loading,
616
+ error,
617
+ connectionStatus,
618
+ };
619
+ }
@@ -0,0 +1,50 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+
3
+ /**
4
+ * Simple fetch hook for /api/jobs
5
+ * Exposes: { loading, data, error, refetch }
6
+ */
7
+ export function useJobList() {
8
+ const [loading, setLoading] = useState(true);
9
+ const [data, setData] = useState(null);
10
+ const [error, setError] = useState(null);
11
+
12
+ const fetchJobs = useCallback(async (signal) => {
13
+ setLoading(true);
14
+ setError(null);
15
+ try {
16
+ const res = await fetch("/api/jobs", { signal });
17
+ if (!res.ok) {
18
+ const payload = await res.json().catch(() => ({}));
19
+ setError(payload);
20
+ setData(null);
21
+ } else {
22
+ const json = await res.json();
23
+ setData(json);
24
+ }
25
+ } catch (err) {
26
+ if (err.name === "AbortError") {
27
+ // ignore
28
+ } else {
29
+ setError({ message: err.message });
30
+ setData(null);
31
+ }
32
+ } finally {
33
+ setLoading(false);
34
+ }
35
+ }, []);
36
+
37
+ const refetch = useCallback(() => {
38
+ const controller = new AbortController();
39
+ void fetchJobs(controller.signal);
40
+ // no persistence of controller here - immediate refetch
41
+ }, [fetchJobs]);
42
+
43
+ useEffect(() => {
44
+ const controller = new AbortController();
45
+ void fetchJobs(controller.signal);
46
+ return () => controller.abort();
47
+ }, [fetchJobs]);
48
+
49
+ return { loading, data, error, refetch };
50
+ }