@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,335 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { useJobList } from "./useJobList.js";
3
+ import { sortJobs } from "../../transformers/list-transformer.js";
4
+
5
+ /**
6
+ * useJobListWithUpdates
7
+ *
8
+ * - Uses useJobList to fetch initial data (or a snapshot)
9
+ * - Hydrates local state from base data, then listens for SSE incremental events
10
+ * - Applies SSE events idempotently via pure reducer functions
11
+ * - Maintains connectionStatus derived from EventSource.readyState
12
+ * - Queues events received before hydration completes and applies them after hydrate
13
+ *
14
+ * Note: SSE payloads (job:created, job:updated) match the canonical list item shape
15
+ * from /api/jobs to ensure consistent updates without page refresh.
16
+ */
17
+ function applyJobEvent(prev = [], event) {
18
+ // prev: Array of jobs (treated immutably)
19
+ // event: { type: string, payload: object }
20
+ const list = Array.isArray(prev) ? prev.slice() : [];
21
+
22
+ if (!event || !event.type) return list;
23
+
24
+ const p = event.payload || {};
25
+ switch (event.type) {
26
+ case "job:created": {
27
+ if (!p.jobId) return list;
28
+ const idx = list.findIndex((j) => j.jobId === p.jobId);
29
+ if (idx === -1) {
30
+ // New job: add and sort
31
+ list.push(p);
32
+ return sortJobs(list);
33
+ } else {
34
+ // Merge with existing; if no effective change, return prev to avoid unnecessary updates
35
+ const merged = { ...list[idx], ...p };
36
+ try {
37
+ if (JSON.stringify(merged) === JSON.stringify(list[idx])) return prev;
38
+ } catch (e) {
39
+ // Fall back to returning merged result if JSON stringify fails
40
+ }
41
+ list[idx] = merged;
42
+ return sortJobs(list);
43
+ }
44
+ }
45
+
46
+ case "job:updated": {
47
+ if (!p.jobId) return list;
48
+ const idx = list.findIndex((j) => j.jobId === p.jobId);
49
+ if (idx === -1) {
50
+ // If we don't have it yet, add it
51
+ list.push(p);
52
+ return sortJobs(list);
53
+ } else {
54
+ const merged = { ...list[idx], ...p };
55
+ try {
56
+ if (JSON.stringify(merged) === JSON.stringify(list[idx])) return prev;
57
+ } catch (e) {
58
+ // ignore stringify errors
59
+ }
60
+ list[idx] = merged;
61
+ return sortJobs(list);
62
+ }
63
+ }
64
+
65
+ case "job:removed": {
66
+ if (!p.jobId) return list;
67
+ const filtered = list.filter((j) => j.jobId !== p.jobId);
68
+ // If nothing removed, return prev
69
+ try {
70
+ if (JSON.stringify(filtered) === JSON.stringify(prev)) return prev;
71
+ } catch (e) {}
72
+ return filtered;
73
+ }
74
+
75
+ case "status:changed": {
76
+ if (!p.jobId) return list;
77
+ const mapped = list.map((j) =>
78
+ j.jobId === p.jobId ? { ...j, status: p.status } : j
79
+ );
80
+ try {
81
+ if (JSON.stringify(mapped) === JSON.stringify(prev)) return prev;
82
+ } catch (e) {}
83
+ return mapped;
84
+ }
85
+
86
+ default:
87
+ return list;
88
+ }
89
+ }
90
+
91
+ export function useJobListWithUpdates() {
92
+ const base = useJobList();
93
+ const { loading, data, error, refetch } = base;
94
+
95
+ const [localData, setLocalData] = useState(data || null);
96
+ const [connectionStatus, setConnectionStatus] = useState("disconnected");
97
+ const esRef = useRef(null);
98
+ const reconnectTimer = useRef(null);
99
+ // Debounce timer for refetch triggered by seed uploads or state-level events
100
+ const refetchDebounceRef = useRef(null);
101
+ const REFETCH_DEBOUNCE_MS = 300;
102
+
103
+ // Hydration guard and event queue for events that arrive before hydration completes.
104
+ const hydratedRef = useRef(false);
105
+ const eventQueue = useRef([]);
106
+
107
+ // Keep localData in sync when base data changes (this is the hydration point).
108
+ useEffect(() => {
109
+ setLocalData(data || null);
110
+
111
+ if (data && Array.isArray(data)) {
112
+ // Compute hydrated base from incoming data, then apply any queued events deterministically
113
+ const base = Array.isArray(data) ? data.slice() : [];
114
+ if (eventQueue.current.length > 0) {
115
+ try {
116
+ const applied = eventQueue.current.reduce(
117
+ (acc, ev) => applyJobEvent(acc, ev),
118
+ base
119
+ );
120
+ // Debug: show applied array content for test troubleshooting
121
+ setLocalData(applied);
122
+ } catch (e) {
123
+ // eslint-disable-next-line no-console
124
+ console.error(
125
+ "[useJobListWithUpdates] failed applying queued events",
126
+ e
127
+ );
128
+ // Fallback: set base as localData
129
+ setLocalData(base);
130
+ }
131
+ eventQueue.current = [];
132
+ hydratedRef.current = true;
133
+ } else {
134
+ // No queued events: just hydrate to base
135
+ setLocalData(base);
136
+ hydratedRef.current = true;
137
+ }
138
+ } else {
139
+ hydratedRef.current = false;
140
+ }
141
+ }, [data]);
142
+
143
+ useEffect(() => {
144
+ // Only create one EventSource per mounted hook instance
145
+ if (esRef.current) {
146
+ return undefined;
147
+ }
148
+
149
+ // Helper to attach listeners to a given EventSource instance
150
+ const attachListeners = (es) => {
151
+ const onOpen = () => {
152
+ setConnectionStatus("connected");
153
+ };
154
+
155
+ const onError = () => {
156
+ // Derive state from readyState when possible
157
+ try {
158
+ const rs = esRef.current?.readyState;
159
+ if (rs === 0) {
160
+ // connecting
161
+ setConnectionStatus("disconnected");
162
+ } else if (rs === 1) {
163
+ setConnectionStatus("connected");
164
+ } else if (rs === 2) {
165
+ setConnectionStatus("disconnected");
166
+ } else {
167
+ setConnectionStatus("disconnected");
168
+ }
169
+ } catch (err) {
170
+ setConnectionStatus("disconnected");
171
+ }
172
+
173
+ // Attempt reconnect after 2s if closed
174
+ if (esRef.current && esRef.current.readyState === 2) {
175
+ if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
176
+ reconnectTimer.current = setTimeout(() => {
177
+ try {
178
+ // Close existing reference if any
179
+ try {
180
+ esRef.current?.close();
181
+ } catch (e) {
182
+ // ignore
183
+ }
184
+
185
+ // Create a fresh EventSource and attach the same listeners so reconnect works
186
+ const newEs = new EventSource("/api/events");
187
+ newEs.addEventListener("open", onOpen);
188
+ newEs.addEventListener("job:updated", onJobUpdated);
189
+ newEs.addEventListener("job:created", onJobCreated);
190
+ newEs.addEventListener("job:removed", onJobRemoved);
191
+ newEs.addEventListener("status:changed", onStatusChanged);
192
+ newEs.addEventListener("error", onError);
193
+
194
+ esRef.current = newEs;
195
+ } catch (err) {
196
+ // ignore
197
+ }
198
+ }, 2000);
199
+ }
200
+ };
201
+
202
+ const handleIncomingEvent = (type, evt) => {
203
+ try {
204
+ const payload = evt && evt.data ? JSON.parse(evt.data) : null;
205
+ const eventObj = { type, payload };
206
+
207
+ if (!hydratedRef.current) {
208
+ // Queue events functionally (avoid mutating existing array in place)
209
+ eventQueue.current = (eventQueue.current || []).concat(eventObj);
210
+ return;
211
+ }
212
+
213
+ // Apply event using pure reducer. If reducer returns an unchanged value,
214
+ // return prev to avoid unnecessary re-renders.
215
+ setLocalData((prev) => {
216
+ const next = applyJobEvent(prev, eventObj);
217
+ try {
218
+ if (JSON.stringify(prev) === JSON.stringify(next)) {
219
+ return prev;
220
+ }
221
+ } catch (e) {
222
+ // If stringify fails, fall back to returning next
223
+ }
224
+ return next;
225
+ });
226
+ } catch (err) {
227
+ // Non-fatal: keep queue intact and continue
228
+ // Logging for visibility in dev; tests should mock console if asserting logs
229
+ // eslint-disable-next-line no-console
230
+ if (type === "job:updated") {
231
+ console.error("Failed to parse job update event:", err);
232
+ } else {
233
+ console.error("Failed to handle SSE event:", err);
234
+ }
235
+ }
236
+ };
237
+
238
+ const onJobUpdated = (evt) => handleIncomingEvent("job:updated", evt);
239
+ const onJobCreated = (evt) => handleIncomingEvent("job:created", evt);
240
+ const onJobRemoved = (evt) => handleIncomingEvent("job:removed", evt);
241
+ const onStatusChanged = (evt) =>
242
+ handleIncomingEvent("status:changed", evt);
243
+
244
+ const scheduleRefetch = () => {
245
+ if (refetchDebounceRef.current)
246
+ clearTimeout(refetchDebounceRef.current);
247
+ refetchDebounceRef.current = setTimeout(() => {
248
+ try {
249
+ if (typeof refetch === "function") {
250
+ refetch();
251
+ }
252
+ } catch (e) {
253
+ // ignore refetch failures
254
+ } finally {
255
+ refetchDebounceRef.current = null;
256
+ }
257
+ }, REFETCH_DEBOUNCE_MS);
258
+ };
259
+
260
+ const onSeedUploaded = (evt) => {
261
+ // Minimal parsing - we don't need payload for refetching
262
+ try {
263
+ // eslint-disable-next-line no-unused-vars
264
+ const payload = evt && evt.data ? JSON.parse(evt.data) : null;
265
+ } catch (e) {
266
+ // ignore parse errors
267
+ }
268
+ scheduleRefetch();
269
+ };
270
+
271
+ const onStateChange = (evt) => {
272
+ // Treat state-level changes as a hint to refetch the jobs list (debounced)
273
+ scheduleRefetch();
274
+ };
275
+
276
+ es.addEventListener("open", onOpen);
277
+ es.addEventListener("job:updated", onJobUpdated);
278
+ es.addEventListener("job:created", onJobCreated);
279
+ es.addEventListener("job:removed", onJobRemoved);
280
+ es.addEventListener("status:changed", onStatusChanged);
281
+ es.addEventListener("seed:uploaded", onSeedUploaded);
282
+ es.addEventListener("state:change", onStateChange);
283
+ es.addEventListener("state:summary", onStateChange);
284
+ es.addEventListener("error", onError);
285
+
286
+ // Set connection status from readyState when possible
287
+ if (es.readyState === 1) setConnectionStatus("connected");
288
+ else if (es.readyState === 0) setConnectionStatus("disconnected");
289
+
290
+ return () => {
291
+ try {
292
+ es.removeEventListener("open", onOpen);
293
+ es.removeEventListener("job:updated", onJobUpdated);
294
+ es.removeEventListener("job:created", onJobCreated);
295
+ es.removeEventListener("job:removed", onJobRemoved);
296
+ es.removeEventListener("status:changed", onStatusChanged);
297
+ es.removeEventListener("seed:uploaded", onSeedUploaded);
298
+ es.removeEventListener("state:change", onStateChange);
299
+ es.removeEventListener("state:summary", onStateChange);
300
+ es.removeEventListener("error", onError);
301
+ es.close();
302
+ } catch (err) {
303
+ // ignore
304
+ }
305
+ if (reconnectTimer.current) {
306
+ clearTimeout(reconnectTimer.current);
307
+ reconnectTimer.current = null;
308
+ }
309
+ esRef.current = null;
310
+ };
311
+ };
312
+
313
+ // Create EventSource on mount regardless of base snapshot presence
314
+ try {
315
+ const es = new EventSource("/api/events");
316
+ esRef.current = es;
317
+
318
+ // attach listeners and return the cleanup function
319
+ return attachListeners(es);
320
+ } catch (err) {
321
+ // eslint-disable-next-line no-console
322
+ console.error("Failed to create SSE connection:", err);
323
+ setConnectionStatus("error");
324
+ return undefined;
325
+ }
326
+ }, []);
327
+
328
+ return {
329
+ loading,
330
+ data: localData,
331
+ error,
332
+ refetch,
333
+ connectionStatus,
334
+ };
335
+ }
@@ -0,0 +1,26 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * Reactive ticker hook that provides updating timestamp
5
+ * @param {number} intervalMs - Update interval in milliseconds (default: 1000)
6
+ * @returns {number} Current timestamp that updates on interval
7
+ */
8
+ export function useTicker(intervalMs = 1000) {
9
+ const [now, setNow] = useState(() => Date.now());
10
+
11
+ useEffect(() => {
12
+ // Set up interval to update timestamp
13
+ const intervalId = setInterval(() => {
14
+ setNow(Date.now());
15
+ }, intervalMs);
16
+
17
+ // Cleanup interval on unmount
18
+ return () => {
19
+ clearInterval(intervalId);
20
+ };
21
+ }, [intervalMs]);
22
+
23
+ return now;
24
+ }
25
+
26
+ export default useTicker;
@@ -0,0 +1,31 @@
1
+ /* src/ui/client/index.css */
2
+ @import "@radix-ui/themes/styles.css" layer(radix);
3
+ @import "tailwindcss";
4
+
5
+ /* Reset and base styles */
6
+ @layer base {
7
+ * {
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ html {
12
+ font-family: system-ui, sans-serif;
13
+ }
14
+
15
+ body {
16
+ margin: 0;
17
+ line-height: inherit;
18
+ }
19
+ }
20
+
21
+ body {
22
+ font-family: "Inter", sans-serif;
23
+ line-height: 1.6;
24
+ min-height: 100vh;
25
+ }
26
+
27
+ .container {
28
+ max-width: 1200px;
29
+ margin: 0 auto;
30
+ padding: 2rem;
31
+ }
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
7
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
10
+ rel="stylesheet"
11
+ />
12
+ <title>Prompt Pipeline Dashboard</title>
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="./main.jsx"></script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,38 @@
1
+ // import React from "react";
2
+ // import ReactDOM from "react-dom/client";
3
+ // import { Layout } from "../components/Layout.jsx";
4
+ // import "../styles/index.css";
5
+
6
+ // ReactDOM.createRoot(document.getElementById("root")).render(
7
+ // <React.StrictMode>
8
+ // <Layout />
9
+ // </React.StrictMode>
10
+ // );
11
+
12
+ import "./index.css"; // Tailwind + tokens + base
13
+
14
+ import React from "react";
15
+ import ReactDOM from "react-dom/client";
16
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
17
+ import PromptPipelineDashboard from "@/pages/PromptPipelineDashboard.jsx";
18
+ import PipelineDetail from "@/pages/PipelineDetail.jsx";
19
+ import { Theme } from "@radix-ui/themes";
20
+
21
+ ReactDOM.createRoot(document.getElementById("root")).render(
22
+ <React.StrictMode>
23
+ <Theme
24
+ accentColor="iris"
25
+ grayColor="gray"
26
+ panelBackground="solid"
27
+ scaling="100%"
28
+ radius="full"
29
+ >
30
+ <BrowserRouter>
31
+ <Routes>
32
+ <Route path="/" element={<PromptPipelineDashboard />} />
33
+ <Route path="/pipeline/:jobId" element={<PipelineDetail />} />
34
+ </Routes>
35
+ </BrowserRouter>
36
+ </Theme>
37
+ </React.StrictMode>
38
+ );
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Browser-specific configuration bridge for UI helpers.
3
+ * Contains only browser-safe utilities (no Node APIs).
4
+ */
5
+
6
+ /**
7
+ * Global constants and contracts for project data display system
8
+ * @namespace Constants
9
+ */
10
+ export const Constants = {
11
+ /**
12
+ * Job ID validation regex
13
+ * @type {RegExp}
14
+ */
15
+ JOB_ID_REGEX: /^[A-Za-z0-9-_]+$/,
16
+
17
+ /**
18
+ * Valid task states
19
+ * @type {string[]}
20
+ */
21
+ TASK_STATES: ["pending", "running", "done", "failed"],
22
+
23
+ /**
24
+ * Valid job locations
25
+ * @type {string[]}
26
+ */
27
+ JOB_LOCATIONS: ["current", "complete"],
28
+
29
+ /**
30
+ * Status sort order (descending priority)
31
+ * @type {string[]}
32
+ */
33
+ STATUS_ORDER: ["running", "failed", "pending", "complete"],
34
+
35
+ /**
36
+ * File size limits for reading
37
+ * @type {Object}
38
+ */
39
+ FILE_LIMITS: {
40
+ MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
41
+ },
42
+
43
+ /**
44
+ * Retry configuration for atomic reads
45
+ * @type {Object}
46
+ */
47
+ RETRY_CONFIG: {
48
+ MAX_ATTEMPTS: 3,
49
+ DELAY_MS: 1000,
50
+ },
51
+
52
+ /**
53
+ * SSE debounce configuration
54
+ * @type {Object}
55
+ */
56
+ SSE_CONFIG: {
57
+ DEBOUNCE_MS: 200,
58
+ },
59
+
60
+ /**
61
+ * Error codes for structured error responses
62
+ * @type {Object}
63
+ */
64
+ ERROR_CODES: {
65
+ NOT_FOUND: "not_found",
66
+ INVALID_JSON: "invalid_json",
67
+ FS_ERROR: "fs_error",
68
+ JOB_NOT_FOUND: "job_not_found",
69
+ BAD_REQUEST: "bad_request",
70
+ },
71
+ };
72
+
73
+ /**
74
+ * Validates a job ID against the global contract
75
+ * @param {string} jobId - Job ID to validate
76
+ * @returns {boolean} True if valid
77
+ */
78
+ export function validateJobId(jobId) {
79
+ return Constants.JOB_ID_REGEX.test(jobId);
80
+ }
81
+
82
+ /**
83
+ * Validates a task state against the global contract
84
+ * @param {string} state - Task state to validate
85
+ * @returns {boolean} True if valid
86
+ */
87
+ export function validateTaskState(state) {
88
+ return Constants.TASK_STATES.includes(state);
89
+ }
90
+
91
+ /**
92
+ * Gets the status sort priority for a job status
93
+ * @param {string} status - Job status
94
+ * @returns {number} Sort priority (lower number = higher priority)
95
+ */
96
+ export function getStatusPriority(status) {
97
+ const index = Constants.STATUS_ORDER.indexOf(status);
98
+ return index === -1 ? Constants.STATUS_ORDER.length : index;
99
+ }
100
+
101
+ /**
102
+ * Determines job status based on task states
103
+ * @param {Object} tasks - Tasks object from tasks-status.json
104
+ * @returns {string} Job status
105
+ */
106
+ export function determineJobStatus(tasks = {}) {
107
+ const taskEntries = Object.entries(tasks);
108
+
109
+ if (taskEntries.length === 0) {
110
+ return "pending";
111
+ }
112
+
113
+ const taskStates = taskEntries.map(([_, task]) => task.state);
114
+
115
+ if (taskStates.includes("failed")) {
116
+ return "failed";
117
+ }
118
+
119
+ if (taskStates.includes("running")) {
120
+ return "running";
121
+ }
122
+
123
+ if (taskStates.every((state) => state === "done")) {
124
+ return "complete";
125
+ }
126
+
127
+ return "pending";
128
+ }
129
+
130
+ /**
131
+ * Creates a structured error response
132
+ * @param {string} code - Error code
133
+ * @param {string} message - Error message
134
+ * @param {string} [path] - Optional file path
135
+ * @returns {Object} Structured error object
136
+ */
137
+ export function createErrorResponse(code, message, path = null) {
138
+ const error = {
139
+ ok: false,
140
+ code,
141
+ message,
142
+ };
143
+
144
+ if (path) {
145
+ error.path = path;
146
+ }
147
+
148
+ return error;
149
+ }