@ryanfw/prompt-orchestration-pipeline 0.10.0 → 0.12.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 (42) hide show
  1. package/package.json +3 -1
  2. package/src/api/index.js +38 -1
  3. package/src/components/DAGGrid.jsx +180 -53
  4. package/src/components/JobDetail.jsx +11 -0
  5. package/src/components/TaskDetailSidebar.jsx +27 -3
  6. package/src/components/UploadSeed.jsx +2 -2
  7. package/src/components/ui/RestartJobModal.jsx +26 -6
  8. package/src/components/ui/StopJobModal.jsx +183 -0
  9. package/src/core/config.js +7 -3
  10. package/src/core/lifecycle-policy.js +62 -0
  11. package/src/core/orchestrator.js +32 -0
  12. package/src/core/pipeline-runner.js +312 -217
  13. package/src/core/status-initializer.js +155 -0
  14. package/src/core/status-writer.js +235 -13
  15. package/src/pages/Code.jsx +8 -1
  16. package/src/pages/PipelineDetail.jsx +85 -3
  17. package/src/pages/PromptPipelineDashboard.jsx +10 -11
  18. package/src/ui/client/adapters/job-adapter.js +81 -2
  19. package/src/ui/client/api.js +233 -8
  20. package/src/ui/client/hooks/useJobDetailWithUpdates.js +92 -0
  21. package/src/ui/client/hooks/useJobList.js +14 -1
  22. package/src/ui/dist/app.js +262 -0
  23. package/src/ui/dist/assets/{index-DqkbzXZ1.js → index-B320avRx.js} +5051 -2186
  24. package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
  25. package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
  26. package/src/ui/dist/favicon.svg +12 -0
  27. package/src/ui/dist/index.html +2 -2
  28. package/src/ui/endpoints/file-endpoints.js +330 -0
  29. package/src/ui/endpoints/job-control-endpoints.js +1001 -0
  30. package/src/ui/endpoints/job-endpoints.js +62 -0
  31. package/src/ui/endpoints/sse-endpoints.js +223 -0
  32. package/src/ui/endpoints/state-endpoint.js +85 -0
  33. package/src/ui/endpoints/upload-endpoints.js +406 -0
  34. package/src/ui/express-app.js +182 -0
  35. package/src/ui/server.js +38 -1788
  36. package/src/ui/sse-broadcast.js +93 -0
  37. package/src/ui/utils/http-utils.js +139 -0
  38. package/src/ui/utils/mime-types.js +196 -0
  39. package/src/ui/vite.config.js +22 -0
  40. package/src/ui/zip-utils.js +103 -0
  41. package/src/utils/jobs.js +39 -0
  42. package/src/ui/dist/assets/style-DBF9NQGk.css +0 -62
@@ -0,0 +1,93 @@
1
+ /**
2
+ * SSE broadcast module for state updates
3
+ * Extracted from router.js to support Express migration
4
+ */
5
+
6
+ import { sseRegistry } from "./sse.js";
7
+
8
+ /**
9
+ * Decorate change with job ID extracted from file path
10
+ */
11
+ function decorateChangeWithJobId(change) {
12
+ if (!change || typeof change !== "object") return change;
13
+ const normalizedPath = String(change.path || "").replace(/\\/g, "/");
14
+ const match = normalizedPath.match(
15
+ /pipeline-data\/(current|complete|pending|rejected)\/([^/]+)/
16
+ );
17
+ if (!match) {
18
+ return change;
19
+ }
20
+ return {
21
+ ...change,
22
+ lifecycle: match[1],
23
+ jobId: match[2],
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Prioritize job status changes over other changes
29
+ */
30
+ function prioritizeJobStatusChange(changes = []) {
31
+ const normalized = changes.map((change) => decorateChangeWithJobId(change));
32
+ const statusChange = normalized.find(
33
+ (change) =>
34
+ typeof change?.path === "string" &&
35
+ /tasks-status\.json$/.test(change.path)
36
+ );
37
+ return statusChange || normalized[0] || null;
38
+ }
39
+
40
+ /**
41
+ * Broadcast state update to all SSE clients
42
+ *
43
+ * NOTE: Per plan, SSE should emit compact, incremental events rather than
44
+ * streaming full application state. Use /api/state for full snapshot
45
+ * retrieval on client bootstrap. This function will emit only the most
46
+ * recent change when available (type: "state:change") and fall back to a
47
+ * lightweight summary event if no recent change is present.
48
+ */
49
+ function broadcastStateUpdate(currentState) {
50
+ try {
51
+ const recentChanges = (currentState && currentState.recentChanges) || [];
52
+ const latest = prioritizeJobStatusChange(recentChanges);
53
+ if (latest) {
54
+ // Emit only the most recent change as a compact, typed event
55
+ const eventData = { type: "state:change", data: latest };
56
+ sseRegistry.broadcast(eventData);
57
+ } else {
58
+ // Fallback: emit a minimal summary so clients can observe a state "tick"
59
+ const eventData = {
60
+ type: "state:summary",
61
+ data: {
62
+ changeCount:
63
+ currentState && currentState.changeCount
64
+ ? currentState.changeCount
65
+ : 0,
66
+ },
67
+ };
68
+ sseRegistry.broadcast(eventData);
69
+ }
70
+ } catch (err) {
71
+ // Defensive: if something unexpected happens, fall back to a lightweight notification
72
+ try {
73
+ console.error("[Router] Error in broadcastStateUpdate:", err);
74
+ sseRegistry.broadcast({
75
+ type: "state:summary",
76
+ data: {
77
+ changeCount:
78
+ currentState && currentState.changeCount
79
+ ? currentState.changeCount
80
+ : 0,
81
+ },
82
+ });
83
+ } catch (fallbackErr) {
84
+ // Log error to aid debugging; this should never happen unless sseRegistry.broadcast is broken
85
+ console.error(
86
+ "Failed to broadcast fallback state summary in broadcastStateUpdate:",
87
+ fallbackErr
88
+ );
89
+ }
90
+ }
91
+ }
92
+
93
+ export { broadcastStateUpdate };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * HTTP utility functions for request/response handling
3
+ */
4
+
5
+ /**
6
+ * Send JSON response with proper headers
7
+ * @param {http.ServerResponse} res - HTTP response object
8
+ * @param {number} code - HTTP status code
9
+ * @param {Object} obj - Response body object
10
+ */
11
+ export const sendJson = (res, code, obj) => {
12
+ res.writeHead(code, {
13
+ "content-type": "application/json",
14
+ connection: "close",
15
+ });
16
+ res.end(JSON.stringify(obj));
17
+ };
18
+
19
+ /**
20
+ * Read raw request body with size limit
21
+ * @param {http.IncomingMessage} req - HTTP request object
22
+ * @param {number} maxBytes - Maximum bytes to read (default: 2MB)
23
+ * @returns {Promise<Buffer>} Raw request body as Buffer
24
+ */
25
+ export async function readRawBody(req, maxBytes = 2 * 1024 * 1024) {
26
+ // 2MB guard
27
+ const chunks = [];
28
+ let total = 0;
29
+ for await (const chunk of req) {
30
+ total += chunk.length;
31
+ if (total > maxBytes) throw new Error("Payload too large");
32
+ chunks.push(chunk);
33
+ }
34
+ return Buffer.concat(chunks);
35
+ }
36
+
37
+ /**
38
+ * Parse multipart form data from request
39
+ * @param {http.IncomingMessage} req - HTTP request object
40
+ * @returns {Promise<Object>} Parsed form data with file content as Buffer
41
+ */
42
+ export function parseMultipartFormData(req) {
43
+ return new Promise((resolve, reject) => {
44
+ const chunks = [];
45
+ let boundary = null;
46
+
47
+ // Extract boundary from content-type header
48
+ const contentType = req.headers["content-type"];
49
+ if (!contentType || !contentType.includes("multipart/form-data")) {
50
+ reject(new Error("Invalid content-type: expected multipart/form-data"));
51
+ return;
52
+ }
53
+
54
+ const boundaryMatch = contentType.match(/boundary=([^;]+)/);
55
+ if (!boundaryMatch) {
56
+ reject(new Error("Missing boundary in content-type"));
57
+ return;
58
+ }
59
+
60
+ boundary = `--${boundaryMatch[1].trim()}`;
61
+
62
+ req.on("data", (chunk) => {
63
+ chunks.push(chunk);
64
+ });
65
+
66
+ req.on("end", () => {
67
+ try {
68
+ const buffer = Buffer.concat(chunks);
69
+
70
+ // Find file part in the buffer using string operations for headers
71
+ const data = buffer.toString(
72
+ "utf8",
73
+ 0,
74
+ Math.min(buffer.length, 1024 * 1024)
75
+ ); // First MB for header search
76
+ const parts = data.split(boundary);
77
+
78
+ for (let i = 0; i < parts.length; i++) {
79
+ const part = parts[i];
80
+
81
+ if (part.includes('name="file"') && part.includes("filename")) {
82
+ // Extract filename
83
+ const filenameMatch = part.match(/filename="([^"]+)"/);
84
+ if (!filenameMatch) continue;
85
+
86
+ // Extract content type
87
+ const contentTypeMatch = part.match(/Content-Type:\s*([^\r\n]+)/);
88
+
89
+ // Find this specific part's start in the data string
90
+ const partIndexInData = data.indexOf(part);
91
+ const headerEndInPart = part.indexOf("\r\n\r\n");
92
+ if (headerEndInPart === -1) {
93
+ reject(
94
+ new Error("Could not find end of headers in multipart part")
95
+ );
96
+ return;
97
+ }
98
+
99
+ // Calculate the actual byte positions in the buffer for this part
100
+ const headerEndInData = partIndexInData + headerEndInPart + 4;
101
+
102
+ // Use binary buffer to find the next boundary
103
+ const boundaryBuf = Buffer.from(boundary, "ascii");
104
+ const nextBoundaryIndex = buffer.indexOf(
105
+ boundaryBuf,
106
+ headerEndInData
107
+ );
108
+ const contentEndInData =
109
+ nextBoundaryIndex !== -1
110
+ ? nextBoundaryIndex - 2 // Subtract 2 for \r\n before boundary
111
+ : buffer.length;
112
+
113
+ // Extract the file content as Buffer
114
+ const contentBuffer = buffer.slice(
115
+ headerEndInData,
116
+ contentEndInData
117
+ );
118
+
119
+ resolve({
120
+ filename: filenameMatch[1],
121
+ contentType: contentTypeMatch
122
+ ? contentTypeMatch[1]
123
+ : "application/octet-stream",
124
+ contentBuffer: contentBuffer,
125
+ });
126
+ return;
127
+ }
128
+ }
129
+
130
+ reject(new Error("No file field found in form data"));
131
+ } catch (error) {
132
+ console.error("Error parsing multipart:", error);
133
+ reject(error);
134
+ }
135
+ });
136
+
137
+ req.on("error", reject);
138
+ });
139
+ }
@@ -0,0 +1,196 @@
1
+ import path from "path";
2
+
3
+ // MIME type detection map
4
+ const MIME_MAP = {
5
+ // Text types
6
+ ".txt": "text/plain",
7
+ ".log": "text/plain",
8
+ ".md": "text/markdown",
9
+ ".csv": "text/csv",
10
+ ".json": "application/json",
11
+ ".xml": "application/xml",
12
+ ".yaml": "application/x-yaml",
13
+ ".yml": "application/x-yaml",
14
+ ".toml": "application/toml",
15
+ ".ini": "text/plain",
16
+ ".conf": "text/plain",
17
+ ".config": "text/plain",
18
+ ".env": "text/plain",
19
+ ".gitignore": "text/plain",
20
+ ".dockerfile": "text/plain",
21
+ ".sh": "application/x-sh",
22
+ ".bash": "application/x-sh",
23
+ ".zsh": "application/x-sh",
24
+ ".fish": "application/x-fish",
25
+ ".ps1": "application/x-powershell",
26
+ ".bat": "application/x-bat",
27
+ ".cmd": "application/x-cmd",
28
+
29
+ // Code types
30
+ ".js": "application/javascript",
31
+ ".mjs": "application/javascript",
32
+ ".cjs": "application/javascript",
33
+ ".ts": "application/typescript",
34
+ ".mts": "application/typescript",
35
+ ".cts": "application/typescript",
36
+ ".jsx": "application/javascript",
37
+ ".tsx": "application/typescript",
38
+ ".py": "text/x-python",
39
+ ".rb": "text/x-ruby",
40
+ ".php": "application/x-php",
41
+ ".java": "text/x-java-source",
42
+ ".c": "text/x-c",
43
+ ".cpp": "text/x-c++",
44
+ ".cc": "text/x-c++",
45
+ ".cxx": "text/x-c++",
46
+ ".h": "text/x-c",
47
+ ".hpp": "text/x-c++",
48
+ ".cs": "text/x-csharp",
49
+ ".go": "text/x-go",
50
+ ".rs": "text/x-rust",
51
+ ".swift": "text/x-swift",
52
+ ".kt": "text/x-kotlin",
53
+ ".scala": "text/x-scala",
54
+ ".r": "text/x-r",
55
+ ".sql": "application/sql",
56
+ ".pl": "text/x-perl",
57
+ ".lua": "text/x-lua",
58
+ ".vim": "text/x-vim",
59
+ ".el": "text/x-elisp",
60
+ ".lisp": "text/x-lisp",
61
+ ".hs": "text/x-haskell",
62
+ ".ml": "text/x-ocaml",
63
+ ".ex": "text/x-elixir",
64
+ ".exs": "text/x-elixir",
65
+ ".erl": "text/x-erlang",
66
+ ".beam": "application/x-erlang-beam",
67
+
68
+ // Web types
69
+ ".html": "text/html",
70
+ ".htm": "text/html",
71
+ ".xhtml": "application/xhtml+xml",
72
+ ".css": "text/css",
73
+ ".scss": "text/x-scss",
74
+ ".sass": "text/x-sass",
75
+ ".less": "text/x-less",
76
+ ".styl": "text/x-stylus",
77
+ ".vue": "text/x-vue",
78
+ ".svelte": "text/x-svelte",
79
+
80
+ // Data formats
81
+ ".pdf": "application/pdf",
82
+ ".doc": "application/msword",
83
+ ".docx":
84
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
85
+ ".xls": "application/vnd.ms-excel",
86
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
87
+ ".ppt": "application/vnd.ms-powerpoint",
88
+ ".pptx":
89
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
90
+ ".odt": "application/vnd.oasis.opendocument.text",
91
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
92
+ ".odp": "application/vnd.oasis.opendocument.presentation",
93
+
94
+ // Images
95
+ ".png": "image/png",
96
+ ".jpg": "image/jpeg",
97
+ ".jpeg": "image/jpeg",
98
+ ".gif": "image/gif",
99
+ ".bmp": "image/bmp",
100
+ ".webp": "image/webp",
101
+ ".svg": "image/svg+xml",
102
+ ".ico": "image/x-icon",
103
+ ".tiff": "image/tiff",
104
+ ".tif": "image/tiff",
105
+ ".psd": "image/vnd.adobe.photoshop",
106
+ ".ai": "application/pdf", // Illustrator files often saved as PDF
107
+ ".eps": "application/postscript",
108
+
109
+ // Audio
110
+ ".mp3": "audio/mpeg",
111
+ ".wav": "audio/wav",
112
+ ".ogg": "audio/ogg",
113
+ ".flac": "audio/flac",
114
+ ".aac": "audio/aac",
115
+ ".m4a": "audio/mp4",
116
+ ".wma": "audio/x-ms-wma",
117
+
118
+ // Video
119
+ ".mp4": "video/mp4",
120
+ ".avi": "video/x-msvideo",
121
+ ".mov": "video/quicktime",
122
+ ".wmv": "video/x-ms-wmv",
123
+ ".flv": "video/x-flv",
124
+ ".webm": "video/webm",
125
+ ".mkv": "video/x-matroska",
126
+ ".m4v": "video/mp4",
127
+
128
+ // Archives
129
+ ".zip": "application/zip",
130
+ ".rar": "application/x-rar-compressed",
131
+ ".tar": "application/x-tar",
132
+ ".gz": "application/gzip",
133
+ ".tgz": "application/gzip",
134
+ ".bz2": "application/x-bzip2",
135
+ ".xz": "application/x-xz",
136
+ ".7z": "application/x-7z-compressed",
137
+ ".deb": "application/x-debian-package",
138
+ ".rpm": "application/x-rpm",
139
+ ".dmg": "application/x-apple-diskimage",
140
+ ".iso": "application/x-iso9660-image",
141
+
142
+ // Fonts
143
+ ".ttf": "font/ttf",
144
+ ".otf": "font/otf",
145
+ ".woff": "font/woff",
146
+ ".woff2": "font/woff2",
147
+ ".eot": "application/vnd.ms-fontobject",
148
+
149
+ // Misc
150
+ ".bin": "application/octet-stream",
151
+ ".exe": "application/x-msdownload",
152
+ ".dll": "application/x-msdownload",
153
+ ".so": "application/x-sharedlib",
154
+ ".dylib": "application/x-mach-binary",
155
+ ".class": "application/java-vm",
156
+ ".jar": "application/java-archive",
157
+ ".war": "application/java-archive",
158
+ ".ear": "application/java-archive",
159
+ ".apk": "application/vnd.android.package-archive",
160
+ ".ipa": "application/x-itunes-ipa",
161
+ };
162
+
163
+ /**
164
+ * Determine MIME type from file extension
165
+ * @param {string} filename - File name
166
+ * @returns {string} MIME type
167
+ */
168
+ function getMimeType(filename) {
169
+ const ext = path.extname(filename).toLowerCase();
170
+ return MIME_MAP[ext] || "application/octet-stream";
171
+ }
172
+
173
+ /**
174
+ * Check if MIME type should be treated as text
175
+ * @param {string} mime - MIME type
176
+ * @returns {boolean} True if text-like
177
+ */
178
+ function isTextMime(mime) {
179
+ return (
180
+ mime.startsWith("text/") ||
181
+ mime === "application/json" ||
182
+ mime === "application/javascript" ||
183
+ mime === "application/xml" ||
184
+ mime === "application/x-yaml" ||
185
+ mime === "application/x-sh" ||
186
+ mime === "application/x-bat" ||
187
+ mime === "application/x-cmd" ||
188
+ mime === "application/x-powershell" ||
189
+ mime === "image/svg+xml" ||
190
+ mime === "application/x-ndjson" ||
191
+ mime === "text/csv" ||
192
+ mime === "text/markdown"
193
+ );
194
+ }
195
+
196
+ export { MIME_MAP, getMimeType, isTextMime };
@@ -0,0 +1,22 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ // Fix the publicDir path to resolve the static assets issue.
5
+ // Vite was looking in src/ui/client/public (relative to root),
6
+ // but public assets are in src/ui/public.
7
+ // By setting publicDir to an absolute path, Vite will copy
8
+ // contents of src/ui/public to dist during build.
9
+ const publicDir = new URL("./public", import.meta.url).pathname;
10
+
11
+ export default defineConfig({
12
+ plugins: [react()],
13
+ root: "src/ui/client",
14
+ build: {
15
+ outDir: "../../dist", // Output to src/ui/dist
16
+ sourcemap: true,
17
+ },
18
+ publicDir,
19
+ server: {
20
+ cors: true,
21
+ },
22
+ });
@@ -0,0 +1,103 @@
1
+ import { unzipSync } from "fflate";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Extract seed JSON and artifacts from a zip buffer using fflate
6
+ * @param {Buffer|Uint8Array} zipBuffer - Buffer containing zip data
7
+ * @returns {Promise<{seedObject: Object, artifacts: Array<{filename: string, content: Buffer}>}>}
8
+ */
9
+ export async function extractSeedZip(zipBuffer) {
10
+ // Normalize to Uint8Array for fflate
11
+ const zipData = Buffer.isBuffer(zipBuffer)
12
+ ? new Uint8Array(zipBuffer)
13
+ : zipBuffer;
14
+
15
+ console.log("[ZIP] Starting real zip parsing", {
16
+ bufferSize: zipData.length,
17
+ });
18
+
19
+ try {
20
+ // Check if this looks like a valid zip by looking for PK signature
21
+ if (zipData.length < 4 || zipData[0] !== 0x50 || zipData[1] !== 0x4b) {
22
+ throw new Error("Invalid ZIP file signature");
23
+ }
24
+
25
+ // Use fflate to extract all entries
26
+ const entries = unzipSync(zipData);
27
+ const artifacts = [];
28
+ let seedObject = null;
29
+ let seedJsonCount = 0;
30
+
31
+ console.log("[ZIP] Extracted entries from zip", {
32
+ entryCount: Object.keys(entries).length,
33
+ entryNames: Object.keys(entries),
34
+ });
35
+
36
+ // Process each entry
37
+ for (const [entryName, rawContent] of Object.entries(entries)) {
38
+ // Skip directory entries (names ending with /)
39
+ if (entryName.endsWith("/")) {
40
+ console.log("[ZIP] Skipping directory entry", { entryName });
41
+ continue;
42
+ }
43
+
44
+ // Derive filename using basename (flatten directory structure)
45
+ const filename = path.basename(entryName);
46
+ console.log("[ZIP] Processing entry", { entryName, filename });
47
+
48
+ // Convert Uint8Array to Buffer
49
+ const content = Buffer.from(rawContent);
50
+
51
+ // Add to artifacts
52
+ artifacts.push({ filename, content });
53
+
54
+ // Check if this is seed.json
55
+ if (filename === "seed.json") {
56
+ seedJsonCount++;
57
+ try {
58
+ const jsonContent = content.toString("utf8");
59
+ seedObject = JSON.parse(jsonContent);
60
+ console.log("[ZIP] Successfully parsed seed.json", {
61
+ seedName: seedObject.name,
62
+ seedPipeline: seedObject.pipeline,
63
+ });
64
+ } catch (parseError) {
65
+ console.error("[ZIP] Failed to parse seed.json", {
66
+ error: parseError.message,
67
+ filename,
68
+ });
69
+ throw new Error("Invalid JSON");
70
+ }
71
+ }
72
+ }
73
+
74
+ // Validate that we found at least one seed.json
75
+ if (seedJsonCount === 0) {
76
+ throw new Error("seed.json not found in zip");
77
+ }
78
+
79
+ if (seedJsonCount > 1) {
80
+ console.log(
81
+ "[ZIP] Warning: multiple seed.json files found, using last one",
82
+ {
83
+ count: seedJsonCount,
84
+ }
85
+ );
86
+ }
87
+
88
+ console.log("[ZIP] Zip extraction completed", {
89
+ artifactCount: artifacts.length,
90
+ artifactNames: artifacts.map((a) => a.filename),
91
+ seedKeys: seedObject ? Object.keys(seedObject) : [],
92
+ seedJsonCount,
93
+ });
94
+
95
+ return { seedObject, artifacts };
96
+ } catch (error) {
97
+ console.error("[ZIP] Zip extraction failed", {
98
+ error: error.message,
99
+ bufferSize: zipData.length,
100
+ });
101
+ throw error;
102
+ }
103
+ }
package/src/utils/jobs.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { normalizeTaskState } from "../config/statuses.js";
2
+
1
3
  export const countCompleted = (job) => {
2
4
  const list = Array.isArray(job?.tasks)
3
5
  ? job.tasks
@@ -5,3 +7,40 @@ export const countCompleted = (job) => {
5
7
  return list.filter((t) => t?.state === "done" || t?.state === "completed")
6
8
  .length;
7
9
  };
10
+
11
+ export const DisplayCategory = Object.freeze({
12
+ ERRORS: "errors",
13
+ CURRENT: "current",
14
+ COMPLETE: "complete",
15
+ });
16
+
17
+ export function classifyJobForDisplay(job) {
18
+ if (!job) return DisplayCategory.CURRENT;
19
+
20
+ const tasks = Array.isArray(job?.tasks)
21
+ ? job.tasks
22
+ : Object.values(job?.tasks || {});
23
+
24
+ const normalizedStates = tasks.map((task) => normalizeTaskState(task?.state));
25
+
26
+ // Precedence: errors > current > complete > fallback to current
27
+ if (
28
+ job.status === "failed" ||
29
+ normalizedStates.some((state) => state === "failed")
30
+ ) {
31
+ return DisplayCategory.ERRORS;
32
+ }
33
+
34
+ if (
35
+ job.status === "running" ||
36
+ normalizedStates.some((state) => state === "running")
37
+ ) {
38
+ return DisplayCategory.CURRENT;
39
+ }
40
+
41
+ if (tasks.length > 0 && normalizedStates.every((state) => state === "done")) {
42
+ return DisplayCategory.COMPLETE;
43
+ }
44
+
45
+ return DisplayCategory.CURRENT;
46
+ }