@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.3.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 +45 -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 +456 -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-BDABnI-4.js +33399 -0
  52. package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -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,223 @@
1
+ // PromptPipelineDashboard.jsx
2
+ import React, { useEffect, useMemo, useState } from "react";
3
+ import { useNavigate } from "react-router-dom";
4
+
5
+ import { Box, Flex, Text, Heading, Tabs, Card } from "@radix-ui/themes";
6
+
7
+ import { Progress } from "../components/ui/progress";
8
+ import { useJobListWithUpdates } from "../ui/client/hooks/useJobListWithUpdates";
9
+ import { adaptJobSummary } from "../ui/client/adapters/job-adapter";
10
+ import { jobCumulativeDurationMs } from "../utils/duration";
11
+ import { useTicker } from "../ui/client/hooks/useTicker";
12
+
13
+ // Referenced components — leave these alone
14
+ import JobTable from "../components/JobTable";
15
+ import UploadSeed from "../components/UploadSeed";
16
+ import Layout from "../components/Layout.jsx";
17
+
18
+ export default function PromptPipelineDashboard({ isConnected }) {
19
+ const navigate = useNavigate();
20
+ const hookResult = useJobListWithUpdates();
21
+
22
+ if (
23
+ process.env.NODE_ENV === "test" &&
24
+ (hookResult === undefined ||
25
+ hookResult === null ||
26
+ typeof hookResult !== "object" ||
27
+ Array.isArray(hookResult))
28
+ ) {
29
+ // eslint-disable-next-line no-console
30
+ console.error(
31
+ "[PromptPipelineDashboard] useJobListWithUpdates returned unexpected value",
32
+ {
33
+ hookResultType: typeof hookResult,
34
+ hookResultKeys:
35
+ hookResult && typeof hookResult === "object"
36
+ ? Object.keys(hookResult)
37
+ : null,
38
+ isMockFunction: Boolean(useJobListWithUpdates?.mock),
39
+ stack: new Error().stack,
40
+ }
41
+ );
42
+ }
43
+
44
+ const { data: apiJobs, loading, error, connectionStatus } = hookResult;
45
+
46
+ const jobs = useMemo(() => {
47
+ const src = Array.isArray(apiJobs) ? apiJobs : [];
48
+
49
+ // Do not fall back to in-memory demo data. On error, return an empty list so
50
+ // UI shows a neutral empty/error state rather than demo jobs.
51
+ if (error) {
52
+ return [];
53
+ }
54
+
55
+ return src.map(adaptJobSummary);
56
+ }, [apiJobs, error]);
57
+ const [activeTab, setActiveTab] = useState("current");
58
+ const [seedUploadSuccess, setSeedUploadSuccess] = useState(null);
59
+ const [seedUploadTimer, setSeedUploadTimer] = useState(null);
60
+
61
+ // Shared ticker for live duration updates
62
+ const now = useTicker(10000);
63
+
64
+ const errorCount = useMemo(
65
+ () => jobs.filter((j) => j.status === "failed").length,
66
+ [jobs]
67
+ );
68
+ const currentCount = useMemo(
69
+ () => jobs.filter((j) => j.status === "running").length,
70
+ [jobs]
71
+ );
72
+ const completedCount = useMemo(
73
+ () => jobs.filter((j) => j.status === "complete").length,
74
+ [jobs]
75
+ );
76
+
77
+ const filteredJobs = useMemo(() => {
78
+ switch (activeTab) {
79
+ case "current":
80
+ return jobs.filter((j) => j.status === "running");
81
+ case "errors":
82
+ return jobs.filter((j) => j.status === "failed");
83
+ case "complete":
84
+ return jobs.filter((j) => j.status === "complete");
85
+ default:
86
+ return [];
87
+ }
88
+ }, [jobs, activeTab]);
89
+
90
+ const overallElapsed = (job) => jobCumulativeDurationMs(job, now);
91
+
92
+ // Aggregate progress for currently running jobs (for a subtle top progress bar)
93
+ const runningJobs = useMemo(
94
+ () => jobs.filter((j) => j.status === "running"),
95
+ [jobs]
96
+ );
97
+ const aggregateProgress = useMemo(() => {
98
+ if (runningJobs.length === 0) return 0;
99
+ const sum = runningJobs.reduce((acc, j) => acc + (j.progress || 0), 0);
100
+ return Math.round(sum / runningJobs.length);
101
+ }, [runningJobs]);
102
+
103
+ const openJob = (job) => {
104
+ // Only navigate if job has a proper ID
105
+ if (job.id) {
106
+ navigate(`/pipeline/${job.id}`);
107
+ } else {
108
+ // Show console warning for jobs without valid ID
109
+ console.warn(`Cannot open job "${job.name}" - no valid job ID available`);
110
+ // TODO: Show user-facing toast or notification for better UX
111
+ }
112
+ };
113
+
114
+ // Handle seed upload success
115
+ const handleSeedUploadSuccess = ({ jobName }) => {
116
+ // Clear any existing timer
117
+ if (seedUploadTimer) {
118
+ clearTimeout(seedUploadTimer);
119
+ }
120
+
121
+ // Set success message
122
+ setSeedUploadSuccess(jobName);
123
+
124
+ // Auto-clear after exactly 5000 ms
125
+ const timer = setTimeout(() => {
126
+ setSeedUploadSuccess(null);
127
+ setSeedUploadTimer(null);
128
+ }, 5000);
129
+
130
+ setSeedUploadTimer(timer);
131
+ };
132
+
133
+ // Cleanup timer on unmount
134
+ useEffect(() => {
135
+ return () => {
136
+ if (seedUploadTimer) {
137
+ clearTimeout(seedUploadTimer);
138
+ }
139
+ };
140
+ }, [seedUploadTimer]);
141
+
142
+ // Header actions for Layout
143
+ const headerActions = runningJobs.length > 0 && (
144
+ <Flex align="center" gap="2" className="text-gray-11">
145
+ <Text size="1" weight="medium">
146
+ Overall Progress
147
+ </Text>
148
+ <Progress value={aggregateProgress} className="w-20" />
149
+ <Text size="1" className="text-gray-9">
150
+ {aggregateProgress}%
151
+ </Text>
152
+ </Flex>
153
+ );
154
+
155
+ return (
156
+ <Layout title="Prompt Pipeline" actions={headerActions}>
157
+ {/* Upload Seed File Section */}
158
+ <Card className="mb-6">
159
+ <Flex direction="column" gap="3">
160
+ <Heading size="4" weight="medium" className="text-gray-12">
161
+ Upload Seed File
162
+ </Heading>
163
+
164
+ {/* Success Message */}
165
+ {seedUploadSuccess && (
166
+ <Box className="rounded-md bg-green-50 p-3 border border-green-200">
167
+ <Text size="2" className="text-green-800">
168
+ Job <strong>{seedUploadSuccess}</strong> created successfully
169
+ </Text>
170
+ </Box>
171
+ )}
172
+
173
+ <UploadSeed onUploadSuccess={handleSeedUploadSuccess} />
174
+ </Flex>
175
+ </Card>
176
+
177
+ {error && (
178
+ <Box className="mb-4 rounded-md bg-yellow-50 p-3 border border-yellow-200">
179
+ <Text size="2" className="text-yellow-800">
180
+ Unable to load jobs from the server
181
+ </Text>
182
+ </Box>
183
+ )}
184
+ <Tabs.Root value={activeTab} onValueChange={setActiveTab}>
185
+ <Tabs.List aria-label="Job filters">
186
+ <Tabs.Trigger value="current">Current ({currentCount})</Tabs.Trigger>
187
+ <Tabs.Trigger value="errors">Errors ({errorCount})</Tabs.Trigger>
188
+ <Tabs.Trigger value="complete">
189
+ Completed ({completedCount})
190
+ </Tabs.Trigger>
191
+ </Tabs.List>
192
+
193
+ <Tabs.Content value="current">
194
+ <JobTable
195
+ jobs={filteredJobs}
196
+ pipeline={null}
197
+ onOpenJob={openJob}
198
+ overallElapsed={overallElapsed}
199
+ now={now}
200
+ />
201
+ </Tabs.Content>
202
+ <Tabs.Content value="errors">
203
+ <JobTable
204
+ jobs={filteredJobs}
205
+ pipeline={null}
206
+ onOpenJob={openJob}
207
+ overallElapsed={overallElapsed}
208
+ now={now}
209
+ />
210
+ </Tabs.Content>
211
+ <Tabs.Content value="complete">
212
+ <JobTable
213
+ jobs={filteredJobs}
214
+ pipeline={null}
215
+ onOpenJob={openJob}
216
+ overallElapsed={overallElapsed}
217
+ now={now}
218
+ />
219
+ </Tabs.Content>
220
+ </Tabs.Root>
221
+ </Layout>
222
+ );
223
+ }
@@ -7,10 +7,10 @@ import {
7
7
 
8
8
  export async function deepseekChat({
9
9
  messages,
10
- model = "deepseek-reasoner",
10
+ model = "deepseek-chat",
11
11
  temperature = 0.7,
12
12
  maxTokens,
13
- responseFormat,
13
+ responseFormat = "json",
14
14
  topP,
15
15
  frequencyPenalty,
16
16
  presencePenalty,
@@ -71,21 +71,9 @@ export async function deepseekChat({
71
71
  const data = await response.json();
72
72
  const content = data.choices[0].message.content;
73
73
 
74
- // Try to parse JSON if expected
75
- let parsed = null;
76
- if (responseFormat?.type === "json_object" || responseFormat === "json") {
77
- parsed = tryParseJSON(content);
78
- if (!parsed && attempt < maxRetries) {
79
- lastError = new Error("Failed to parse JSON response");
80
- continue;
81
- }
82
- }
83
-
84
74
  return {
85
- content: parsed || content,
86
- text: content,
75
+ content: tryParseJSON(content),
87
76
  usage: data.usage,
88
- raw: data,
89
77
  };
90
78
  } catch (error) {
91
79
  lastError = error;
@@ -0,0 +1,258 @@
1
+ import { derivePipelineMetadata } from "../../../utils/pipelines.js";
2
+
3
+ const ALLOWED_STATES = new Set(["pending", "running", "done", "failed"]);
4
+
5
+ /**
6
+ * Normalize a raw task state into canonical enum.
7
+ * Returns { state, warning? } where warning is a string if normalization occurred.
8
+ */
9
+ function normalizeTaskState(raw) {
10
+ if (!raw || typeof raw !== "string")
11
+ return { state: "pending", warning: "missing_state" };
12
+ const s = raw.toLowerCase();
13
+ if (ALLOWED_STATES.has(s)) return { state: s };
14
+ return { state: "pending", warning: `unknown_state:${raw}` };
15
+ }
16
+
17
+ /**
18
+ * Convert tasks input into an object of normalized task objects keyed by task name.
19
+ * Accepts:
20
+ * - object keyed by taskName -> taskObj (preferred canonical shape)
21
+ * - array of task objects (with optional name) - converted to object
22
+ */
23
+ function normalizeTasks(rawTasks) {
24
+ if (!rawTasks) return { tasks: {}, warnings: [] };
25
+ const warnings = [];
26
+
27
+ if (typeof rawTasks === "object" && !Array.isArray(rawTasks)) {
28
+ // Object shape - canonical format
29
+ const tasks = {};
30
+ Object.entries(rawTasks).forEach(([name, t]) => {
31
+ const ns = normalizeTaskState(t && t.state);
32
+ if (ns.warning) warnings.push(`${name}:${ns.warning}`);
33
+ const taskObj = {
34
+ name,
35
+ state: ns.state,
36
+ startedAt: t && t.startedAt ? String(t.startedAt) : null,
37
+ endedAt: t && t.endedAt ? String(t.endedAt) : null,
38
+ attempts:
39
+ typeof (t && t.attempts) === "number" ? t.attempts : undefined,
40
+ executionTimeMs:
41
+ typeof (t && t.executionTimeMs) === "number"
42
+ ? t.executionTimeMs
43
+ : undefined,
44
+ // Preserve stage metadata for DAG visualization
45
+ ...(typeof t?.currentStage === "string" && t.currentStage.length > 0
46
+ ? { currentStage: t.currentStage }
47
+ : {}),
48
+ ...(typeof t?.failedStage === "string" && t.failedStage.length > 0
49
+ ? { failedStage: t.failedStage }
50
+ : {}),
51
+ // Prefer new files.* schema, fallback to legacy artifacts
52
+ files:
53
+ t && t.files
54
+ ? {
55
+ artifacts: Array.isArray(t.files.artifacts)
56
+ ? t.files.artifacts.slice()
57
+ : [],
58
+ logs: Array.isArray(t.files.logs) ? t.files.logs.slice() : [],
59
+ tmp: Array.isArray(t.files.tmp) ? t.files.tmp.slice() : [],
60
+ }
61
+ : {
62
+ artifacts: [],
63
+ logs: [],
64
+ tmp: [],
65
+ },
66
+ artifacts: Array.isArray(t && t.artifacts)
67
+ ? t.artifacts.slice()
68
+ : undefined,
69
+ };
70
+ tasks[name] = taskObj;
71
+ });
72
+ return { tasks, warnings };
73
+ }
74
+
75
+ if (Array.isArray(rawTasks)) {
76
+ // Array shape - convert to object for backward compatibility
77
+ const tasks = {};
78
+ rawTasks.forEach((t, idx) => {
79
+ const name = t && t.name ? String(t.name) : `task-${idx}`;
80
+ const ns = normalizeTaskState(t && t.state);
81
+ if (ns.warning) warnings.push(`${name}:${ns.warning}`);
82
+ tasks[name] = {
83
+ name,
84
+ state: ns.state,
85
+ startedAt: t && t.startedAt ? String(t.startedAt) : null,
86
+ endedAt: t && t.endedAt ? String(t.endedAt) : null,
87
+ attempts:
88
+ typeof (t && t.attempts) === "number" ? t.attempts : undefined,
89
+ executionTimeMs:
90
+ typeof (t && t.executionTimeMs) === "number"
91
+ ? t.executionTimeMs
92
+ : undefined,
93
+ // Preserve stage metadata for DAG visualization
94
+ ...(typeof t?.currentStage === "string" && t.currentStage.length > 0
95
+ ? { currentStage: t.currentStage }
96
+ : {}),
97
+ ...(typeof t?.failedStage === "string" && t.failedStage.length > 0
98
+ ? { failedStage: t.failedStage }
99
+ : {}),
100
+ artifacts: Array.isArray(t && t.artifacts)
101
+ ? t.artifacts.slice()
102
+ : undefined,
103
+ };
104
+ });
105
+ return { tasks, warnings };
106
+ }
107
+
108
+ return { tasks: {}, warnings: ["invalid_tasks_shape"] };
109
+ }
110
+
111
+ /**
112
+ * Derive status from tasks when status is missing/invalid.
113
+ * Rules:
114
+ * - failed if any task state === 'failed'
115
+ * - running if >=1 running and none failed
116
+ * - complete if all done
117
+ * - pending otherwise
118
+ */
119
+ function deriveStatusFromTasks(tasks) {
120
+ const taskList = Object.values(tasks);
121
+ if (!Array.isArray(taskList) || taskList.length === 0) return "pending";
122
+ if (taskList.some((t) => t.state === "failed")) return "failed";
123
+ if (taskList.some((t) => t.state === "running")) return "running";
124
+ if (taskList.every((t) => t.state === "done")) return "complete";
125
+ return "pending";
126
+ }
127
+
128
+ /**
129
+ * Clamp number to 0..100 and ensure integer.
130
+ */
131
+ function clampProgress(n) {
132
+ if (typeof n !== "number" || Number.isNaN(n)) return 0;
133
+ return Math.min(100, Math.max(0, Math.round(n)));
134
+ }
135
+
136
+ /**
137
+ * Compute summary stats from normalized tasks.
138
+ */
139
+ function computeJobSummaryStats(tasks) {
140
+ const taskList = Object.values(tasks);
141
+ const taskCount = taskList.length;
142
+ const doneCount = taskList.reduce(
143
+ (acc, t) => acc + (t.state === "done" ? 1 : 0),
144
+ 0
145
+ );
146
+ const status = deriveStatusFromTasks(tasks);
147
+ const progress =
148
+ taskCount > 0 ? Math.round((doneCount / taskCount) * 100) : 0;
149
+ return { status, progress, doneCount, taskCount };
150
+ }
151
+
152
+ /**
153
+ * adaptJobSummary(apiJob)
154
+ * - apiJob: object roughly matching docs 0.5 /api/jobs entry.
155
+ * Returns normalized summary object for UI consumption.
156
+ */
157
+ export function adaptJobSummary(apiJob) {
158
+ // Demo-only: read canonical fields strictly
159
+ const id = apiJob.jobId;
160
+ const name = apiJob.title || "";
161
+ const rawTasks = apiJob.tasksStatus;
162
+ const location = apiJob.location;
163
+
164
+ // Job-level stage metadata
165
+ const current = apiJob.current;
166
+ const currentStage = apiJob.currentStage;
167
+
168
+ const { tasks, warnings } = normalizeTasks(rawTasks);
169
+
170
+ // Use API status and progress as source of truth, fall back to task-based computation only when missing
171
+ const apiStatus = apiJob.status;
172
+ const apiProgress = apiJob.progress;
173
+ const derivedStats = computeJobSummaryStats(tasks);
174
+
175
+ const job = {
176
+ id,
177
+ jobId: id,
178
+ name,
179
+ status: apiStatus || derivedStats.status,
180
+ progress: apiProgress ?? derivedStats.progress,
181
+ taskCount: derivedStats.taskCount,
182
+ doneCount: derivedStats.doneCount,
183
+ location,
184
+ tasks,
185
+ };
186
+
187
+ // Preserve job-level stage metadata
188
+ if (current != null) job.current = current;
189
+ if (currentStage != null) job.currentStage = currentStage;
190
+
191
+ // Optional/metadata fields (preserve if present)
192
+ if ("createdAt" in apiJob) job.createdAt = apiJob.createdAt;
193
+ if ("updatedAt" in apiJob) job.updatedAt = apiJob.updatedAt;
194
+
195
+ // Pipeline metadata
196
+ const { pipeline, pipelineLabel } = derivePipelineMetadata(apiJob);
197
+ if (pipeline != null) job.pipeline = pipeline;
198
+ if (pipelineLabel != null) job.pipelineLabel = pipelineLabel;
199
+
200
+ // Include warnings for debugging
201
+ if (warnings.length > 0) job.__warnings = warnings;
202
+
203
+ return job;
204
+ }
205
+
206
+ /**
207
+ * adaptJobDetail(apiDetail)
208
+ * - apiDetail: object roughly matching docs 0.5 /api/jobs/:jobId detail schema.
209
+ * Returns a normalized detailed job object for UI consumption.
210
+ */
211
+ export function adaptJobDetail(apiDetail) {
212
+ // Demo-only: read canonical fields strictly
213
+ const id = apiDetail.jobId;
214
+ const name = apiDetail.title || "";
215
+ const rawTasks = apiDetail.tasksStatus;
216
+ const location = apiDetail.location;
217
+
218
+ // Job-level stage metadata
219
+ const current = apiDetail.current;
220
+ const currentStage = apiDetail.currentStage;
221
+
222
+ const { tasks, warnings } = normalizeTasks(rawTasks);
223
+
224
+ // Use API status and progress as source of truth, fall back to task-based computation only when missing
225
+ const apiStatus = apiDetail.status;
226
+ const apiProgress = apiDetail.progress;
227
+ const derivedStats = computeJobSummaryStats(tasks);
228
+
229
+ const detail = {
230
+ id,
231
+ jobId: id,
232
+ name,
233
+ status: apiStatus || derivedStats.status,
234
+ progress: apiProgress ?? derivedStats.progress,
235
+ taskCount: derivedStats.taskCount,
236
+ doneCount: derivedStats.doneCount,
237
+ location,
238
+ tasks,
239
+ };
240
+
241
+ // Preserve job-level stage metadata
242
+ if (current != null) detail.current = current;
243
+ if (currentStage != null) detail.currentStage = currentStage;
244
+
245
+ // Optional/metadata fields (preserve if present)
246
+ if ("createdAt" in apiDetail) detail.createdAt = apiDetail.createdAt;
247
+ if ("updatedAt" in apiDetail) detail.updatedAt = apiDetail.updatedAt;
248
+
249
+ // Pipeline metadata
250
+ const { pipeline, pipelineLabel } = derivePipelineMetadata(apiDetail);
251
+ if (pipeline != null) detail.pipeline = pipeline;
252
+ if (pipelineLabel != null) detail.pipelineLabel = pipelineLabel;
253
+
254
+ // Include warnings for debugging
255
+ if (warnings.length > 0) detail.__warnings = warnings;
256
+
257
+ return detail;
258
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Client bootstrap helper
3
+ *
4
+ * Usage:
5
+ * await bootstrap({
6
+ * stateUrl = '/api/state',
7
+ * sseUrl = '/api/events',
8
+ * applySnapshot: async (snapshot) => { ... },
9
+ * onSseEvent: (type, data) => { ... }
10
+ * })
11
+ *
12
+ * Semantics:
13
+ * - Fetches stateUrl and awaits applySnapshot(snapshot)
14
+ * - Only after applySnapshot resolves, creates EventSource(sseUrl)
15
+ * - Attaches listeners for common event types and forwards them to onSseEvent
16
+ * - Returns the created EventSource instance (or null on failure)
17
+ */
18
+ export async function bootstrap({
19
+ stateUrl = "/api/state",
20
+ sseUrl = "/api/events",
21
+ applySnapshot = async () => {},
22
+ onSseEvent = () => {},
23
+ } = {}) {
24
+ const controller = new AbortController();
25
+ try {
26
+ const res = await fetch(stateUrl, { signal: controller.signal });
27
+ if (res && res.ok) {
28
+ const json = await res.json();
29
+ // Allow applySnapshot to be async and await it
30
+ await applySnapshot(json);
31
+ } else {
32
+ // Try to parse body when available, but still call applySnapshot with whatever we get
33
+ let body = null;
34
+ if (res) {
35
+ try {
36
+ body = await res.json();
37
+ } catch (jsonErr) {
38
+ const contentType = res.headers.get("content-type");
39
+ console.error(
40
+ `[bootstrap] Failed to parse JSON from ${stateUrl}: status=${res.status}, content-type=${contentType}, error=${jsonErr}`
41
+ );
42
+ body = null;
43
+ }
44
+ }
45
+ await applySnapshot(body);
46
+ }
47
+ } catch (err) {
48
+ // Best-effort: still call applySnapshot with null so callers can handle startup failure
49
+ try {
50
+ await applySnapshot(null);
51
+ } catch (e) {
52
+ // ignore
53
+ }
54
+ }
55
+
56
+ // Create EventSource after snapshot applied
57
+ let es = null;
58
+ try {
59
+ es = new EventSource(sseUrl);
60
+
61
+ // Forward 'state' events (server may send full state on connect in current implementation)
62
+ es.addEventListener("state", (evt) => {
63
+ try {
64
+ const data = JSON.parse(evt.data);
65
+ onSseEvent("state", data);
66
+ } catch (err) {
67
+ // ignore parse errors
68
+ }
69
+ });
70
+
71
+ // Forward job-specific events
72
+ es.addEventListener("job:updated", (evt) => {
73
+ try {
74
+ const data = JSON.parse(evt.data);
75
+ onSseEvent("job:updated", data);
76
+ } catch (err) {
77
+ // ignore parse errors
78
+ }
79
+ });
80
+
81
+ es.addEventListener("job:created", (evt) => {
82
+ try {
83
+ const data = JSON.parse(evt.data);
84
+ onSseEvent("job:created", data);
85
+ } catch (err) {}
86
+ });
87
+
88
+ es.addEventListener("job:removed", (evt) => {
89
+ try {
90
+ const data = JSON.parse(evt.data);
91
+ onSseEvent("job:removed", data);
92
+ } catch (err) {}
93
+ });
94
+
95
+ es.addEventListener("heartbeat", (evt) => {
96
+ try {
97
+ const data = JSON.parse(evt.data);
98
+ onSseEvent("heartbeat", data);
99
+ } catch (err) {}
100
+ });
101
+
102
+ // Generic message handler as fallback
103
+ es.addEventListener("message", (evt) => {
104
+ try {
105
+ const data = JSON.parse(evt.data);
106
+ onSseEvent("message", data);
107
+ } catch (err) {}
108
+ });
109
+ } catch (err) {
110
+ // If EventSource creation fails, return null
111
+ try {
112
+ if (es && typeof es.close === "function") es.close();
113
+ } catch {}
114
+ return null;
115
+ }
116
+
117
+ return es;
118
+ }
119
+
120
+ export default bootstrap;