@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.
- package/README.md +415 -24
- package/package.json +45 -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 +456 -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-BDABnI-4.js +33399 -0
- package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -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,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-
|
|
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:
|
|
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;
|