@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,347 @@
1
+ /**
2
+ * List transformer utilities
3
+ *
4
+ * Exports:
5
+ * - aggregateAndSortJobs(currentJobs, completeJobs)
6
+ * - sortJobs(jobs)
7
+ * - getStatusPriority(status)
8
+ * - groupJobsByStatus(jobs)
9
+ * - getJobListStats(jobs)
10
+ * - filterJobs(jobs, searchTerm, options)
11
+ * - transformJobListForAPI(jobs)
12
+ * - getAggregationStats(currentJobs, completeJobs, aggregatedJobs)
13
+ *
14
+ * Behavior guided by tests in tests/list-transformer.test.js and docs/project-data-display.md
15
+ */
16
+
17
+ import { derivePipelineMetadata } from "../../utils/pipelines.js";
18
+
19
+ export function getStatusPriority(status) {
20
+ // Map to numeric priority where higher = higher priority
21
+ switch (status) {
22
+ case "running":
23
+ return 4;
24
+ case "error":
25
+ return 3;
26
+ case "pending":
27
+ return 2;
28
+ case "complete":
29
+ return 1;
30
+ default:
31
+ return 0;
32
+ }
33
+ }
34
+
35
+ function getJobId(job) {
36
+ return job && typeof job === "object" ? job.jobId || job.id || null : null;
37
+ }
38
+
39
+ /**
40
+ * Validate a job object minimally: must have jobId (or id), status, and createdAt
41
+ */
42
+ function isValidJob(job) {
43
+ if (!job || typeof job !== "object") return false;
44
+ if (!getJobId(job)) return false;
45
+ if (!job.status) return false;
46
+ if (!job.createdAt) return false;
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Sort jobs by status priority (descending), then createdAt ascending, then id ascending.
52
+ * Filters out invalid jobs.
53
+ */
54
+ export function sortJobs(jobs) {
55
+ if (!Array.isArray(jobs) || jobs.length === 0) return [];
56
+
57
+ const filtered = jobs.filter(isValidJob).slice();
58
+
59
+ filtered.sort((a, b) => {
60
+ const pa = getStatusPriority(a.status);
61
+ const pb = getStatusPriority(b.status);
62
+
63
+ if (pa !== pb) return pb - pa;
64
+
65
+ const ta = Date.parse(a.createdAt) || 0;
66
+ const tb = Date.parse(b.createdAt) || 0;
67
+
68
+ if (ta !== tb) return ta - tb;
69
+
70
+ const idA = getJobId(a);
71
+ const idB = getJobId(b);
72
+ if (idA == null && idB == null) return 0;
73
+ if (idA == null) return 1;
74
+ if (idB == null) return -1;
75
+ if (idA < idB) return -1;
76
+ if (idA > idB) return 1;
77
+ return 0;
78
+ });
79
+
80
+ return filtered;
81
+ }
82
+
83
+ /**
84
+ * Merge current and complete job lists with precedence: current wins.
85
+ * Returns sorted result using sortJobs.
86
+ */
87
+ export function aggregateAndSortJobs(currentJobs, completeJobs) {
88
+ try {
89
+ const cur = Array.isArray(currentJobs) ? currentJobs : [];
90
+ const comp = Array.isArray(completeJobs) ? completeJobs : [];
91
+
92
+ const map = new Map();
93
+
94
+ for (const j of comp) {
95
+ const jobId = getJobId(j);
96
+ if (!jobId) continue;
97
+ map.set(jobId, j);
98
+ }
99
+
100
+ for (const j of cur) {
101
+ const jobId = getJobId(j);
102
+ if (!jobId) continue;
103
+ map.set(jobId, j);
104
+ }
105
+
106
+ const aggregated = Array.from(map.values());
107
+
108
+ return sortJobs(aggregated);
109
+ } catch (err) {
110
+ console.error("Error aggregating jobs:", err);
111
+ return [];
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Group jobs into buckets by status.
117
+ * Unknown statuses are ignored.
118
+ */
119
+ export function groupJobsByStatus(jobs) {
120
+ const buckets = {
121
+ running: [],
122
+ error: [],
123
+ pending: [],
124
+ complete: [],
125
+ };
126
+
127
+ if (!Array.isArray(jobs)) return buckets;
128
+
129
+ for (const job of jobs) {
130
+ if (!job || typeof job !== "object") continue;
131
+ const status = job.status;
132
+ if (!status || !buckets[status]) continue;
133
+ buckets[status].push(job);
134
+ }
135
+
136
+ return buckets;
137
+ }
138
+
139
+ /**
140
+ * Compute job list statistics:
141
+ * - total
142
+ * - byStatus: counts
143
+ * - byLocation: counts
144
+ * - averageProgress: floor average of available progress values (0 if none)
145
+ */
146
+ export function getJobListStats(jobs = []) {
147
+ if (!Array.isArray(jobs) || jobs.length === 0) {
148
+ return {
149
+ total: 0,
150
+ byStatus: {},
151
+ byLocation: {},
152
+ averageProgress: 0,
153
+ };
154
+ }
155
+
156
+ const byStatus = {};
157
+ const byLocation = {};
158
+ let progressSum = 0;
159
+ let progressCount = 0;
160
+ let total = 0;
161
+
162
+ for (const job of jobs) {
163
+ if (!job || typeof job !== "object") continue;
164
+ total += 1;
165
+
166
+ if (job.status) {
167
+ byStatus[job.status] = (byStatus[job.status] || 0) + 1;
168
+ }
169
+
170
+ if (job.location) {
171
+ byLocation[job.location] = (byLocation[job.location] || 0) + 1;
172
+ }
173
+
174
+ if (typeof job.progress === "number" && !Number.isNaN(job.progress)) {
175
+ progressSum += job.progress;
176
+ progressCount += 1;
177
+ }
178
+ }
179
+
180
+ const averageProgress =
181
+ progressCount === 0 ? 0 : Math.floor(progressSum / progressCount);
182
+
183
+ return {
184
+ total,
185
+ byStatus,
186
+ byLocation,
187
+ averageProgress,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Filter jobs by search term (matches id or name, case-insensitive) and options {status, location}
193
+ * Returns jobs in original order (filtered).
194
+ */
195
+ export function filterJobs(jobs, searchTerm = "", options = {}) {
196
+ if (!Array.isArray(jobs) || jobs.length === 0) return [];
197
+ const term = (searchTerm || "").trim().toLowerCase();
198
+
199
+ return jobs.filter((job) => {
200
+ if (!job || typeof job !== "object") return false;
201
+
202
+ if (options && options.status && job.status !== options.status)
203
+ return false;
204
+ if (options && options.location && job.location !== options.location)
205
+ return false;
206
+
207
+ if (!term) return true;
208
+
209
+ const hay = `${job.title || ""} ${getJobId(job) || ""}`.toLowerCase();
210
+ return hay.includes(term);
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Transform job list for API: pick only allowed fields and drop nulls
216
+ */
217
+ export function transformJobListForAPI(jobs = [], options = {}) {
218
+ if (!Array.isArray(jobs) || jobs.length === 0) return [];
219
+
220
+ const { includePipelineMetadata = true } = options;
221
+
222
+ const out = [];
223
+ for (const job of jobs) {
224
+ if (!job || typeof job !== "object") continue;
225
+
226
+ const base = {
227
+ jobId: job.jobId,
228
+ title: job.title,
229
+ status: job.status,
230
+ progress:
231
+ typeof job.progress === "number" && Number.isFinite(job.progress)
232
+ ? job.progress
233
+ : 0,
234
+ createdAt: job.createdAt,
235
+ updatedAt: job.updatedAt,
236
+ location: job.location,
237
+ };
238
+
239
+ // Only include files if present
240
+ if (job.files) {
241
+ base.files = job.files;
242
+ }
243
+
244
+ // Add required fields for dashboard completeness
245
+ if (job.current != null) {
246
+ base.current = job.current;
247
+ }
248
+
249
+ if (job.currentStage != null) {
250
+ base.currentStage = job.currentStage;
251
+ }
252
+
253
+ if (job.tasksStatus && typeof job.tasksStatus === "object") {
254
+ // Include tasksStatus with all required fields for UI computation
255
+ const tasksStatus = {};
256
+ for (const [taskId, task] of Object.entries(job.tasksStatus)) {
257
+ if (task && typeof task === "object") {
258
+ tasksStatus[taskId] = {
259
+ state: task.state || "pending",
260
+ };
261
+
262
+ // Include optional fields if present
263
+ if (task.startedAt != null)
264
+ tasksStatus[taskId].startedAt = task.startedAt;
265
+ if (task.endedAt != null) tasksStatus[taskId].endedAt = task.endedAt;
266
+ if (task.executionTimeMs != null)
267
+ tasksStatus[taskId].executionTimeMs = task.executionTimeMs;
268
+ if (task.currentStage != null)
269
+ tasksStatus[taskId].currentStage = task.currentStage;
270
+ if (task.failedStage != null)
271
+ tasksStatus[taskId].failedStage = task.failedStage;
272
+ }
273
+ }
274
+ base.tasksStatus = tasksStatus;
275
+ }
276
+
277
+ // Only include pipeline metadata if option is enabled
278
+ if (includePipelineMetadata) {
279
+ const { pipeline, pipelineLabel, pipelineSlug } =
280
+ derivePipelineMetadata(job);
281
+
282
+ if (pipelineSlug != null) {
283
+ base.pipelineSlug = pipelineSlug;
284
+ base.pipeline = pipelineSlug;
285
+ } else if (typeof pipeline === "string") {
286
+ base.pipeline = pipeline;
287
+ }
288
+
289
+ if (pipelineLabel != null) {
290
+ base.pipelineLabel = pipelineLabel;
291
+ }
292
+ }
293
+
294
+ out.push(base);
295
+ }
296
+
297
+ return out;
298
+ }
299
+
300
+ /**
301
+ * Compute aggregation diagnostics
302
+ */
303
+ export function getAggregationStats(
304
+ currentJobs = [],
305
+ completeJobs = [],
306
+ aggregatedJobs = []
307
+ ) {
308
+ const current = Array.isArray(currentJobs) ? currentJobs : [];
309
+ const complete = Array.isArray(completeJobs) ? completeJobs : [];
310
+ const aggregated = Array.isArray(aggregatedJobs) ? aggregatedJobs : [];
311
+
312
+ const totalInput = current.length + complete.length;
313
+
314
+ // duplicates: ids present in both
315
+ const compIds = new Set(complete.map((j) => getJobId(j)).filter(Boolean));
316
+ const curIds = new Set(current.map((j) => getJobId(j)).filter(Boolean));
317
+
318
+ let duplicates = 0;
319
+ for (const id of curIds) {
320
+ if (compIds.has(id)) duplicates += 1;
321
+ }
322
+
323
+ const totalOutput = aggregated.length;
324
+ const efficiency =
325
+ totalInput === 0 ? 0 : Math.round((totalOutput / totalInput) * 100);
326
+
327
+ const statusDistribution = {};
328
+ const locationDistribution = {};
329
+
330
+ for (const j of aggregated) {
331
+ if (!j || typeof j !== "object") continue;
332
+ if (j.status)
333
+ statusDistribution[j.status] = (statusDistribution[j.status] || 0) + 1;
334
+ if (j.location)
335
+ locationDistribution[j.location] =
336
+ (locationDistribution[j.location] || 0) + 1;
337
+ }
338
+
339
+ return {
340
+ totalInput,
341
+ totalOutput,
342
+ duplicates,
343
+ efficiency,
344
+ statusDistribution,
345
+ locationDistribution,
346
+ };
347
+ }
@@ -0,0 +1,307 @@
1
+ import { normalizeTaskFiles } from "../../utils/task-files.js";
2
+ import { derivePipelineMetadata } from "../../utils/pipelines.js";
3
+
4
+ const VALID_TASK_STATES = new Set(["pending", "running", "done", "failed"]);
5
+
6
+ /**
7
+ * Determine job-level status from tasks mapping.
8
+ */
9
+ export function determineJobStatus(tasks = {}) {
10
+ if (!tasks || typeof tasks !== "object") return "pending";
11
+ const names = Object.keys(tasks);
12
+ if (names.length === 0) return "pending";
13
+
14
+ const states = names.map((n) => tasks[n]?.state);
15
+
16
+ if (states.includes("failed")) return "failed";
17
+ if (states.includes("running")) return "running";
18
+ if (states.every((s) => s === "done")) return "complete";
19
+ return "pending";
20
+ }
21
+
22
+ /**
23
+ * Compute job status object { status, progress } and emit warnings for unknown states.
24
+ * Tests expect console.warn to be called for unknown states with substring:
25
+ * Unknown task state "..."
26
+ */
27
+ export function computeJobStatus(tasksInput, existingProgress = null) {
28
+ // Guard invalid input
29
+ if (
30
+ !tasksInput ||
31
+ typeof tasksInput !== "object" ||
32
+ Array.isArray(tasksInput)
33
+ ) {
34
+ return { status: "pending", progress: existingProgress ?? 0 };
35
+ }
36
+
37
+ // Normalize task states, and detect unknown states
38
+ const names = Object.keys(tasksInput);
39
+ if (names.length === 0)
40
+ return { status: "pending", progress: existingProgress ?? 0 };
41
+
42
+ let unknownStatesFound = new Set();
43
+
44
+ const normalized = {};
45
+ for (const name of names) {
46
+ const t = tasksInput[name];
47
+ const state = t && typeof t === "object" ? t.state : undefined;
48
+
49
+ if (state == null || !VALID_TASK_STATES.has(state)) {
50
+ if (state != null && !VALID_TASK_STATES.has(state)) {
51
+ unknownStatesFound.add(state);
52
+ }
53
+ normalized[name] = { state: "pending" };
54
+ } else {
55
+ normalized[name] = { state };
56
+ }
57
+ }
58
+
59
+ // Warn for unknown states
60
+ for (const s of unknownStatesFound) {
61
+ console.warn(`Unknown task state "${s}"`);
62
+ }
63
+
64
+ const status = determineJobStatus(normalized);
65
+ // Use existing progress if provided, otherwise default to 0
66
+ // Progress is pre-calculated in task-statuses.json, not computed from task states
67
+ const progress = existingProgress !== null ? existingProgress : 0;
68
+
69
+ return { status, progress };
70
+ }
71
+
72
+ /**
73
+ * Transform raw task input into a canonical object keyed by task name.
74
+ * - Returns {} for invalid inputs
75
+ * - Missing or invalid state -> "pending" with console.warn for invalid values
76
+ */
77
+ export function transformTasks(rawTasks) {
78
+ if (!rawTasks) return {};
79
+
80
+ let entries = [];
81
+
82
+ if (Array.isArray(rawTasks)) {
83
+ entries = rawTasks.map((raw, index) => {
84
+ const inferredName =
85
+ raw?.name || raw?.id || raw?.taskId || `task-${index + 1}`;
86
+ return [inferredName, raw];
87
+ });
88
+ } else if (typeof rawTasks === "object") {
89
+ entries = Object.entries(rawTasks);
90
+ } else {
91
+ return {};
92
+ }
93
+
94
+ const normalized = {};
95
+
96
+ for (const [name, raw] of entries) {
97
+ if (typeof name !== "string" || name.length === 0) continue;
98
+
99
+ const rawState =
100
+ raw && typeof raw === "object" && "state" in raw ? raw.state : undefined;
101
+
102
+ let finalState = "pending";
103
+ if (rawState != null && VALID_TASK_STATES.has(rawState)) {
104
+ finalState = rawState;
105
+ } else if (rawState != null && !VALID_TASK_STATES.has(rawState)) {
106
+ console.warn(`Invalid task state "${rawState}"`);
107
+ finalState = "pending";
108
+ }
109
+
110
+ const task = {
111
+ state: finalState,
112
+ };
113
+
114
+ if (raw && typeof raw === "object") {
115
+ if ("startedAt" in raw) task.startedAt = raw.startedAt;
116
+ if ("endedAt" in raw) task.endedAt = raw.endedAt;
117
+ if ("attempts" in raw) task.attempts = raw.attempts;
118
+ if ("executionTimeMs" in raw) task.executionTimeMs = raw.executionTimeMs;
119
+ if ("refinementAttempts" in raw)
120
+ task.refinementAttempts = raw.refinementAttempts;
121
+ if ("stageLogPath" in raw) task.stageLogPath = raw.stageLogPath;
122
+ if ("errorContext" in raw) task.errorContext = raw.errorContext;
123
+
124
+ if (typeof raw.currentStage === "string" && raw.currentStage.length > 0) {
125
+ task.currentStage = raw.currentStage;
126
+ }
127
+ if (typeof raw.failedStage === "string" && raw.failedStage.length > 0) {
128
+ task.failedStage = raw.failedStage;
129
+ }
130
+
131
+ task.files = normalizeTaskFiles(raw?.files);
132
+ if ("artifacts" in raw) task.artifacts = raw.artifacts;
133
+
134
+ if ("error" in raw) {
135
+ if (
136
+ raw.error &&
137
+ typeof raw.error === "object" &&
138
+ !Array.isArray(raw.error)
139
+ ) {
140
+ task.error = { ...raw.error };
141
+ } else if (raw.error != null) {
142
+ task.error = { message: String(raw.error) };
143
+ } else {
144
+ task.error = null;
145
+ }
146
+ }
147
+ } else {
148
+ task.files = normalizeTaskFiles();
149
+ }
150
+
151
+ task.name =
152
+ raw && typeof raw === "object" && "name" in raw ? raw.name : name;
153
+
154
+ normalized[name] = task;
155
+ }
156
+
157
+ return normalized;
158
+ }
159
+
160
+ /**
161
+ * Transform a single raw job payload into canonical job object expected by UI/tests.
162
+ *
163
+ * Output schema:
164
+ * - jobId: string
165
+ * - title: string
166
+ * - status: canonical job status
167
+ * - progress: number 0-100
168
+ * - createdAt / updatedAt: ISO strings | null
169
+ * - location: lifecycle bucket
170
+ * - current / currentStage: stage metadata (optional)
171
+ * - tasksStatus: object keyed by task name
172
+ * - files: normalized job-level files
173
+ */
174
+ export function transformJobStatus(raw, jobId, location) {
175
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
176
+
177
+ const warnings = [];
178
+
179
+ // Check for job ID mismatch (supports both legacy and canonical)
180
+ const rawJobId = raw.jobId || raw.id;
181
+ if (rawJobId && String(rawJobId) !== String(jobId)) {
182
+ const msg = `Job ID mismatch: JSON has "${rawJobId}", using directory name "${jobId}"`;
183
+ warnings.push(msg);
184
+ console.warn(msg);
185
+ }
186
+
187
+ const title = raw.title || raw.name || "Unnamed Job";
188
+ const createdAt = raw.createdAt || null;
189
+ const updatedAt = raw.updatedAt || raw.lastUpdated || createdAt || null;
190
+ const resolvedLocation = location || raw.location || null;
191
+
192
+ // Support both canonical (tasksStatus) and legacy (tasks) schema
193
+ const tasksStatus = transformTasks(raw.tasksStatus || raw.tasks);
194
+ const jobStatusObj = computeJobStatus(tasksStatus, raw.progress);
195
+
196
+ const jobFiles = normalizeTaskFiles(raw.files);
197
+
198
+ // Convert tasksStatus object to tasks array for API compatibility
199
+ const tasks = Object.entries(tasksStatus).map(([name, task]) => ({
200
+ name,
201
+ ...task,
202
+ }));
203
+
204
+ const job = {
205
+ id: jobId, // API expects 'id' not 'jobId'
206
+ name: title, // API expects 'name' not 'title'
207
+ jobId, // Keep jobId for backward compatibility
208
+ title, // Keep title for backward compatibility
209
+ status: jobStatusObj.status,
210
+ progress: jobStatusObj.progress,
211
+ createdAt,
212
+ updatedAt,
213
+ location: resolvedLocation,
214
+ tasksStatus, // Keep tasksStatus for backward compatibility
215
+ tasks, // API expects 'tasks' array
216
+ files: jobFiles,
217
+ };
218
+
219
+ if (raw.current != null) job.current = raw.current;
220
+ if (raw.currentStage != null) job.currentStage = raw.currentStage;
221
+ if (raw.lastUpdated && !job.updatedAt) job.updatedAt = raw.lastUpdated;
222
+
223
+ const { pipeline, pipelineLabel } = derivePipelineMetadata(raw);
224
+
225
+ if (pipeline != null) {
226
+ job.pipeline = pipeline;
227
+ }
228
+ if (pipelineLabel != null) {
229
+ job.pipelineLabel = pipelineLabel;
230
+ }
231
+
232
+ if (raw.pipelineConfig) {
233
+ job.pipelineConfig = raw.pipelineConfig;
234
+ }
235
+
236
+ if (warnings.length > 0) {
237
+ job.warnings = warnings;
238
+ }
239
+
240
+ return job;
241
+ }
242
+
243
+ /**
244
+ * Transform multiple job read results (as returned by readJob and job scanner logic)
245
+ * - Logs "Transforming N jobs" (tests assert this substring)
246
+ * - Filters out failed reads (ok !== true)
247
+ * - Uses transformJobStatus for each successful read
248
+ * - Preserves order of reads as provided
249
+ */
250
+ export function transformMultipleJobs(jobReadResults = []) {
251
+ const total = Array.isArray(jobReadResults) ? jobReadResults.length : 0;
252
+ console.log(`Transforming ${total} jobs`);
253
+
254
+ if (!Array.isArray(jobReadResults) || jobReadResults.length === 0) return [];
255
+
256
+ const out = [];
257
+ for (const r of jobReadResults) {
258
+ if (!r || r.ok !== true) continue;
259
+ // r.data is expected to be raw job JSON
260
+ const raw = r.data || {};
261
+ // jobId and location metadata may be present on read result (tests attach them)
262
+ const jobId = r.jobId || (raw && (raw.jobId || raw.id)) || undefined;
263
+ const location = r.location || raw.location || undefined;
264
+ // If jobId is missing, skip (defensive)
265
+ if (!jobId) continue;
266
+ const transformed = transformJobStatus(raw, jobId, location);
267
+ if (transformed) out.push(transformed);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ /**
273
+ * Compute transformation statistics used by tests:
274
+ * - totalRead: total read attempts
275
+ * - successfulReads: count of readResults with ok === true
276
+ * - successfulTransforms: transformedJobs.length
277
+ * - failedTransforms: successfulReads - successfulTransforms
278
+ * - transformationRate: Math.round(successfulTransforms / totalRead * 100) or 0
279
+ * - statusDistribution: counts of statuses in transformedJobs
280
+ */
281
+ export function getTransformationStats(readResults = [], transformedJobs = []) {
282
+ const totalRead = Array.isArray(readResults) ? readResults.length : 0;
283
+ const successfulReads = Array.isArray(readResults)
284
+ ? readResults.filter((r) => r && r.ok === true).length
285
+ : 0;
286
+ const successfulTransforms = Array.isArray(transformedJobs)
287
+ ? transformedJobs.length
288
+ : 0;
289
+ const failedTransforms = Math.max(0, successfulReads - successfulTransforms);
290
+ const transformationRate =
291
+ totalRead === 0 ? 0 : Math.round((successfulTransforms / totalRead) * 100);
292
+
293
+ const statusDistribution = {};
294
+ for (const j of transformedJobs || []) {
295
+ if (!j || !j.status) continue;
296
+ statusDistribution[j.status] = (statusDistribution[j.status] || 0) + 1;
297
+ }
298
+
299
+ return {
300
+ totalRead,
301
+ successfulReads,
302
+ successfulTransforms,
303
+ failedTransforms,
304
+ transformationRate,
305
+ statusDistribution,
306
+ };
307
+ }