@ryanfw/prompt-orchestration-pipeline 0.11.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 (34) hide show
  1. package/package.json +2 -1
  2. package/src/components/DAGGrid.jsx +157 -47
  3. package/src/components/ui/RestartJobModal.jsx +26 -6
  4. package/src/components/ui/StopJobModal.jsx +183 -0
  5. package/src/core/config.js +7 -3
  6. package/src/core/lifecycle-policy.js +62 -0
  7. package/src/core/pipeline-runner.js +312 -217
  8. package/src/core/status-writer.js +84 -0
  9. package/src/pages/Code.jsx +8 -1
  10. package/src/pages/PipelineDetail.jsx +85 -3
  11. package/src/pages/PromptPipelineDashboard.jsx +10 -11
  12. package/src/ui/client/adapters/job-adapter.js +60 -0
  13. package/src/ui/client/api.js +233 -8
  14. package/src/ui/client/hooks/useJobList.js +14 -1
  15. package/src/ui/dist/app.js +262 -0
  16. package/src/ui/dist/assets/{index-DeDzq-Kk.js → index-B320avRx.js} +4854 -2104
  17. package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
  18. package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
  19. package/src/ui/dist/favicon.svg +12 -0
  20. package/src/ui/dist/index.html +2 -2
  21. package/src/ui/endpoints/file-endpoints.js +330 -0
  22. package/src/ui/endpoints/job-control-endpoints.js +1001 -0
  23. package/src/ui/endpoints/job-endpoints.js +62 -0
  24. package/src/ui/endpoints/sse-endpoints.js +223 -0
  25. package/src/ui/endpoints/state-endpoint.js +85 -0
  26. package/src/ui/endpoints/upload-endpoints.js +406 -0
  27. package/src/ui/express-app.js +182 -0
  28. package/src/ui/server.js +38 -1880
  29. package/src/ui/sse-broadcast.js +93 -0
  30. package/src/ui/utils/http-utils.js +139 -0
  31. package/src/ui/utils/mime-types.js +196 -0
  32. package/src/ui/vite.config.js +22 -0
  33. package/src/utils/jobs.js +39 -0
  34. package/src/ui/dist/assets/style-aBtD_Yrs.css +0 -62
@@ -208,6 +208,26 @@ export async function writeJobStatus(jobDir, updateFn) {
208
208
  logger.error("Failed to emit SSE event:", error);
209
209
  }
210
210
 
211
+ // Emit lifecycle_block event if update contains lifecycle block reason
212
+ if (snapshot.lifecycleBlockReason) {
213
+ try {
214
+ const lifecycleEventData = {
215
+ jobId,
216
+ taskId: snapshot.lifecycleBlockTaskId,
217
+ op: snapshot.lifecycleBlockOp,
218
+ reason: snapshot.lifecycleBlockReason,
219
+ };
220
+ await logger.sse("lifecycle_block", lifecycleEventData);
221
+ logger.log(
222
+ "lifecycle_block SSE event broadcasted successfully",
223
+ lifecycleEventData
224
+ );
225
+ } catch (error) {
226
+ // Don't fail the write if SSE emission fails
227
+ logger.error("Failed to emit lifecycle_block SSE event:", error);
228
+ }
229
+ }
230
+
211
231
  logger.groupEnd();
212
232
  resultSnapshot = snapshot;
213
233
  })
@@ -485,6 +505,70 @@ export async function resetJobToCleanSlate(
485
505
  });
486
506
  }
487
507
 
508
+ /**
509
+ * Reset a single task to pending state without affecting other tasks
510
+ *
511
+ * @param {string} jobDir - Job directory path containing tasks-status.json
512
+ * @param {string} taskId - Task identifier to reset
513
+ * @param {Object} options - Reset options
514
+ * @param {boolean} [options.clearTokenUsage=true] - Whether to clear token usage arrays
515
+ * @returns {Promise<Object>} The updated status snapshot
516
+ */
517
+ export async function resetSingleTask(
518
+ jobDir,
519
+ taskId,
520
+ { clearTokenUsage = true } = {}
521
+ ) {
522
+ if (!jobDir || typeof jobDir !== "string") {
523
+ throw new Error("jobDir must be a non-empty string");
524
+ }
525
+
526
+ if (!taskId || typeof taskId !== "string") {
527
+ throw new Error("taskId must be a non-empty string");
528
+ }
529
+
530
+ return writeJobStatus(jobDir, (snapshot) => {
531
+ // Ensure tasks object exists
532
+ if (!snapshot.tasks || typeof snapshot.tasks !== "object") {
533
+ snapshot.tasks = {};
534
+ }
535
+
536
+ // Ensure the target task exists
537
+ if (!snapshot.tasks[taskId]) {
538
+ snapshot.tasks[taskId] = {};
539
+ }
540
+
541
+ const task = snapshot.tasks[taskId];
542
+
543
+ // Reset only the target task state and metadata
544
+ task.state = TaskState.PENDING;
545
+ task.currentStage = null;
546
+
547
+ // Remove error-related fields
548
+ delete task.failedStage;
549
+ delete task.error;
550
+
551
+ // Reset counters
552
+ task.attempts = 0;
553
+ task.refinementAttempts = 0;
554
+
555
+ // Clear token usage if requested
556
+ if (clearTokenUsage) {
557
+ task.tokenUsage = [];
558
+ }
559
+
560
+ // Update lastUpdated timestamp
561
+ snapshot.lastUpdated = new Date().toISOString();
562
+
563
+ // Do not modify:
564
+ // - Any other tasks within snapshot.tasks
565
+ // - snapshot.files.artifacts|logs|tmp
566
+ // - Root-level fields other than lastUpdated
567
+
568
+ return snapshot;
569
+ });
570
+ }
571
+
488
572
  /**
489
573
  * Consolidated path jail security validation with generic error messages
490
574
  * @param {string} filename - Filename to validate
@@ -86,7 +86,14 @@ export default function CodePage() {
86
86
  useEffect(() => {
87
87
  fetch("/api/llm/functions")
88
88
  .then((res) => res.json())
89
- .then(setLlmFunctions)
89
+ .then(({ ok, data }) => {
90
+ if (!ok || typeof data !== "object" || data === null) {
91
+ throw new Error(
92
+ "Invalid /api/llm/functions response: expected { ok:true, data:Object }"
93
+ );
94
+ }
95
+ setLlmFunctions(data);
96
+ })
90
97
  .catch(console.error);
91
98
  }, []);
92
99
 
@@ -1,6 +1,6 @@
1
- import React from "react";
1
+ import React, { useState } from "react";
2
2
  import { data, useParams } from "react-router-dom";
3
- import { Box, Flex, Text } from "@radix-ui/themes";
3
+ import { Box, Flex, Text, Button } from "@radix-ui/themes";
4
4
  import * as Tooltip from "@radix-ui/react-tooltip";
5
5
  import JobDetail from "../components/JobDetail.jsx";
6
6
  import { useJobDetailWithUpdates } from "../ui/client/hooks/useJobDetailWithUpdates.js";
@@ -8,9 +8,15 @@ import Layout from "../components/Layout.jsx";
8
8
  import PageSubheader from "../components/PageSubheader.jsx";
9
9
  import { statusBadge } from "../utils/ui.jsx";
10
10
  import { formatCurrency4, formatTokensCompact } from "../utils/formatters.js";
11
+ import { rescanJob } from "../ui/client/api.js";
12
+ import StopJobModal from "../components/ui/StopJobModal.jsx";
13
+ import { stopJob } from "../ui/client/api.js";
11
14
 
12
15
  export default function PipelineDetail() {
13
16
  const { jobId } = useParams();
17
+ const [isRescanning, setIsRescanning] = useState(false);
18
+ const [isStopModalOpen, setIsStopModalOpen] = useState(false);
19
+ const [isStopping, setIsStopping] = useState(false);
14
20
 
15
21
  // Handle missing job ID (undefined/null)
16
22
  if (jobId === undefined || jobId === null) {
@@ -143,6 +149,12 @@ export default function PipelineDetail() {
143
149
  ...(job.name ? [{ label: job.name }] : []),
144
150
  ];
145
151
 
152
+ // Determine if job is currently running
153
+ const isRunning =
154
+ job?.status === "running" ||
155
+ (job?.tasks &&
156
+ Object.values(job.tasks).some((task) => task?.state === "running"));
157
+
146
158
  // Derive cost data from job object with safe fallbacks
147
159
  const totalCost = job?.totalCost || job?.costs?.summary?.totalCost || 0;
148
160
  const totalTokens = job?.totalTokens || job?.costs?.summary?.totalTokens || 0;
@@ -193,13 +205,75 @@ export default function PipelineDetail() {
193
205
  costIndicator
194
206
  );
195
207
 
196
- // Right side content for PageSubheader: job ID, cost indicator, and status badge
208
+ const handleRescan = async () => {
209
+ setIsRescanning(true);
210
+ try {
211
+ const result = await rescanJob(jobId);
212
+ if (result.ok) {
213
+ const addedCount = result.added ? result.added.length : 0;
214
+ const removedCount = result.removed ? result.removed.length : 0;
215
+ let message = "Rescan complete.";
216
+ if (addedCount > 0 && removedCount > 0) {
217
+ message += ` Added ${addedCount} task${addedCount > 1 ? "s" : ""}: ${JSON.stringify(result.added)}. Removed ${removedCount} task${removedCount > 1 ? "s" : ""}: ${JSON.stringify(result.removed)}.`;
218
+ } else if (addedCount > 0) {
219
+ message += ` Added ${addedCount} task${addedCount > 1 ? "s" : ""}: ${JSON.stringify(result.added)}.`;
220
+ } else if (removedCount > 0) {
221
+ message += ` Removed ${removedCount} task${removedCount > 1 ? "s" : ""}: ${JSON.stringify(result.removed)}.`;
222
+ } else {
223
+ message += " No changes found.";
224
+ }
225
+ console.log(message);
226
+ }
227
+ } catch (err) {
228
+ console.error("Rescan failed:", err);
229
+ alert("Rescan failed: " + err.message);
230
+ } finally {
231
+ setIsRescanning(false);
232
+ }
233
+ };
234
+
235
+ const openStopModal = () => setIsStopModalOpen(true);
236
+ const closeStopModal = () => setIsStopModalOpen(false);
237
+
238
+ const handleStopConfirm = async () => {
239
+ setIsStopping(true);
240
+ try {
241
+ await stopJob(jobId);
242
+ closeStopModal();
243
+ } catch (error) {
244
+ console.warn("Failed to stop job:", error);
245
+ closeStopModal();
246
+ } finally {
247
+ setIsStopping(false);
248
+ }
249
+ };
250
+
251
+ // Right side content for PageSubheader: job ID, cost indicator, status badge, and Stop control
197
252
  const subheaderRightContent = (
198
253
  <Flex align="center" gap="3" className="shrink-0 flex-wrap">
199
254
  <Text size="2" color="gray">
200
255
  ID: {job.id || jobId}
201
256
  </Text>
202
257
  {costIndicatorWithTooltip}
258
+ {isRunning && (
259
+ <Button
260
+ size="1"
261
+ variant="solid"
262
+ color="red"
263
+ disabled={isStopping}
264
+ onClick={openStopModal}
265
+ >
266
+ {isStopping ? "Stopping..." : "Stop"}
267
+ </Button>
268
+ )}
269
+ <Button
270
+ size="1"
271
+ variant="soft"
272
+ disabled={isRescanning}
273
+ onClick={handleRescan}
274
+ >
275
+ {isRescanning ? "Rescanning..." : "Rescan"}
276
+ </Button>
203
277
  {statusBadge(job.status)}
204
278
  </Flex>
205
279
  );
@@ -215,6 +289,14 @@ export default function PipelineDetail() {
215
289
  )}
216
290
  </PageSubheader>
217
291
  <JobDetail job={job} pipeline={pipeline} />
292
+ <StopJobModal
293
+ isOpen={isStopModalOpen}
294
+ onClose={closeStopModal}
295
+ onConfirm={handleStopConfirm}
296
+ runningJobs={[{ id: job.id, name: job.name, progress: job.progress }]}
297
+ defaultJobId={job.id}
298
+ isSubmitting={isStopping}
299
+ />
218
300
  </Layout>
219
301
  );
220
302
  }
@@ -7,24 +7,23 @@ import { Box, Flex, Text, Tabs } from "@radix-ui/themes";
7
7
  import { Progress } from "../components/ui/progress";
8
8
  import { useJobListWithUpdates } from "../ui/client/hooks/useJobListWithUpdates";
9
9
  import { adaptJobSummary } from "../ui/client/adapters/job-adapter";
10
- import { TaskState, JobStatus } from "../config/statuses.js";
11
10
 
12
11
  // Referenced components — leave these alone
13
12
  import JobTable from "../components/JobTable";
14
13
  import Layout from "../components/Layout.jsx";
15
14
 
16
- export default function PromptPipelineDashboard({ isConnected }) {
15
+ export default function PromptPipelineDashboard() {
17
16
  const navigate = useNavigate();
18
17
  const hookResult = useJobListWithUpdates();
19
18
 
20
19
  if (
20
+ /* eslint-disable-next-line no-undef */
21
21
  process.env.NODE_ENV === "test" &&
22
22
  (hookResult === undefined ||
23
23
  hookResult === null ||
24
24
  typeof hookResult !== "object" ||
25
25
  Array.isArray(hookResult))
26
26
  ) {
27
- // eslint-disable-next-line no-console
28
27
  console.error(
29
28
  "[PromptPipelineDashboard] useJobListWithUpdates returned unexpected value",
30
29
  {
@@ -39,7 +38,7 @@ export default function PromptPipelineDashboard({ isConnected }) {
39
38
  );
40
39
  }
41
40
 
42
- const { data: apiJobs, loading, error, connectionStatus } = hookResult;
41
+ const { data: apiJobs, error } = hookResult;
43
42
 
44
43
  const jobs = useMemo(() => {
45
44
  const src = Array.isArray(apiJobs) ? apiJobs : [];
@@ -57,26 +56,26 @@ export default function PromptPipelineDashboard({ isConnected }) {
57
56
  // Shared ticker for live duration updates - removed useTicker
58
57
 
59
58
  const errorCount = useMemo(
60
- () => jobs.filter((j) => j.status === TaskState.FAILED).length,
59
+ () => jobs.filter((j) => j.displayCategory === "errors").length,
61
60
  [jobs]
62
61
  );
63
62
  const currentCount = useMemo(
64
- () => jobs.filter((j) => j.status === TaskState.RUNNING).length,
63
+ () => jobs.filter((j) => j.displayCategory === "current").length,
65
64
  [jobs]
66
65
  );
67
66
  const completedCount = useMemo(
68
- () => jobs.filter((j) => j.status === JobStatus.COMPLETE).length,
67
+ () => jobs.filter((j) => j.displayCategory === "complete").length,
69
68
  [jobs]
70
69
  );
71
70
 
72
71
  const filteredJobs = useMemo(() => {
73
72
  switch (activeTab) {
74
73
  case "current":
75
- return jobs.filter((j) => j.status === TaskState.RUNNING);
74
+ return jobs.filter((j) => j.displayCategory === "current");
76
75
  case "errors":
77
- return jobs.filter((j) => j.status === TaskState.FAILED);
76
+ return jobs.filter((j) => j.displayCategory === "errors");
78
77
  case "complete":
79
- return jobs.filter((j) => j.status === JobStatus.COMPLETE);
78
+ return jobs.filter((j) => j.displayCategory === "complete");
80
79
  default:
81
80
  return [];
82
81
  }
@@ -86,7 +85,7 @@ export default function PromptPipelineDashboard({ isConnected }) {
86
85
 
87
86
  // Aggregate progress for currently running jobs (for a subtle top progress bar)
88
87
  const runningJobs = useMemo(
89
- () => jobs.filter((j) => j.status === TaskState.RUNNING),
88
+ () => jobs.filter((j) => j.displayCategory === "current"),
90
89
  [jobs]
91
90
  );
92
91
  const aggregateProgress = useMemo(() => {
@@ -3,6 +3,8 @@ import {
3
3
  normalizeTaskState,
4
4
  deriveJobStatusFromTasks,
5
5
  } from "../../../config/statuses.js";
6
+ import { classifyJobForDisplay } from "../../../utils/jobs.js";
7
+ import { decideTransition } from "../../../core/lifecycle-policy.js";
6
8
 
7
9
  /**
8
10
  * Normalize a raw task state into canonical enum.
@@ -225,6 +227,9 @@ export function adaptJobSummary(apiJob) {
225
227
  job.totalTokens = job.costsSummary.totalTokens;
226
228
  }
227
229
 
230
+ // Compute and attach display category for UI bucketing
231
+ job.displayCategory = classifyJobForDisplay(job);
232
+
228
233
  // Include warnings for debugging
229
234
  if (warnings.length > 0) job.__warnings = warnings;
230
235
 
@@ -284,8 +289,63 @@ export function adaptJobDetail(apiDetail) {
284
289
  detail.costs = apiDetail.costs;
285
290
  }
286
291
 
292
+ // Compute and attach display category for UI bucketing
293
+ detail.displayCategory = classifyJobForDisplay(detail);
294
+
287
295
  // Include warnings for debugging
288
296
  if (warnings.length > 0) detail.__warnings = warnings;
289
297
 
290
298
  return detail;
291
299
  }
300
+
301
+ /**
302
+ * deriveAllowedActions(adaptedJob, pipelineTasks)
303
+ * - adaptedJob: normalized job object from adaptJobSummary/adaptJobDetail
304
+ * - pipelineTasks: array of task names in execution order from pipeline.json
305
+ * Returns { start, restart } boolean flags for UI controls.
306
+ */
307
+ export function deriveAllowedActions(adaptedJob, pipelineTasks) {
308
+ // Check if any task is running
309
+ const hasRunningTask = Object.values(adaptedJob.tasks || {}).some(
310
+ (task) => task.state === "running"
311
+ );
312
+
313
+ // Default to disabled if job state is running or any task is running
314
+ if (adaptedJob.status === "running" || hasRunningTask) {
315
+ return { start: false, restart: false };
316
+ }
317
+
318
+ // Default to enabled for restart if not running
319
+ const restart = true;
320
+
321
+ // Start requires checking if ANY task can be started (not ALL tasks)
322
+ // Edge case: if no pipeline tasks, default to enabled
323
+ if (pipelineTasks.length === 0) {
324
+ return { start: true, restart };
325
+ }
326
+
327
+ const start = pipelineTasks.some((taskName) => {
328
+ const task = adaptedJob.tasks[taskName];
329
+ if (!task) return false; // Task not found, skip
330
+
331
+ const taskState = task.state || "pending";
332
+
333
+ // Check if all upstream tasks are done for dependency evaluation
334
+ const taskIndex = pipelineTasks.indexOf(taskName);
335
+ const upstreamTasks = pipelineTasks.slice(0, taskIndex);
336
+ const dependenciesReady = upstreamTasks.every((upstreamTaskName) => {
337
+ const upstreamTask = adaptedJob.tasks[upstreamTaskName];
338
+ return upstreamTask && upstreamTask.state === "done";
339
+ });
340
+
341
+ const startDecision = decideTransition({
342
+ op: "start",
343
+ taskState,
344
+ dependenciesReady,
345
+ });
346
+
347
+ return startDecision.ok;
348
+ });
349
+
350
+ return { start, restart };
351
+ }
@@ -3,12 +3,14 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Restart a job with clean-slate mode
6
+ * Restart a job with clean-slate mode or from a specific task
7
7
  *
8
8
  * @param {string} jobId - The ID of the job to restart
9
9
  * @param {Object} opts - Options object
10
- * @param {Object} opts.options - Additional options for the restart
11
- * @param {boolean} opts.options.clearTokenUsage - Whether to clear token usage (default: true)
10
+ * @param {string} [opts.fromTask] - Task ID to restart from (inclusive)
11
+ * @param {boolean} [opts.singleTask] - Whether to run only the target task and then stop
12
+ * @param {Object} [opts.options] - Additional options for the restart
13
+ * @param {boolean} [opts.options.clearTokenUsage=true] - Whether to clear token usage
12
14
  * @returns {Promise<Object>} Parsed JSON response from the server
13
15
  * @throws {Object} Structured error object with { code, message } for non-2xx responses
14
16
  */
@@ -19,8 +21,16 @@ export async function restartJob(jobId, opts = {}) {
19
21
  };
20
22
 
21
23
  const requestBody = opts.fromTask
22
- ? { fromTask: opts.fromTask, options }
23
- : { mode: "clean-slate", options };
24
+ ? {
25
+ fromTask: opts.fromTask,
26
+ options,
27
+ ...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
28
+ }
29
+ : {
30
+ mode: "clean-slate",
31
+ options,
32
+ ...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
33
+ };
24
34
 
25
35
  try {
26
36
  const response = await fetch(
@@ -67,6 +77,164 @@ export async function restartJob(jobId, opts = {}) {
67
77
  }
68
78
  }
69
79
 
80
+ /**
81
+ * Rescan a job to synchronize tasks with the pipeline definition, detecting both added and removed tasks.
82
+ *
83
+ * @param {string} jobId - The ID of the job to rescan
84
+ * @returns {Promise<Object>} Parsed JSON response from the server
85
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
86
+ */
87
+ export async function rescanJob(jobId) {
88
+ try {
89
+ const response = await fetch(
90
+ `/api/jobs/${encodeURIComponent(jobId)}/rescan`,
91
+ {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ },
96
+ }
97
+ );
98
+
99
+ if (!response.ok) {
100
+ // Try to parse error response, fall back to status text if parsing fails
101
+ let errorData;
102
+ try {
103
+ errorData = await response.json();
104
+ } catch {
105
+ errorData = { message: response.statusText };
106
+ }
107
+
108
+ // Throw structured error with code and message
109
+ throw {
110
+ code: errorData.code || getErrorCodeFromStatus(response.status),
111
+ message:
112
+ errorData.message || getErrorMessageFromStatus(response.status),
113
+ status: response.status,
114
+ };
115
+ }
116
+
117
+ // Return parsed JSON for successful responses
118
+ return await response.json();
119
+ } catch (error) {
120
+ // Re-throw structured errors as-is
121
+ if (error.code && error.message) {
122
+ throw error;
123
+ }
124
+
125
+ // Handle network errors or other unexpected errors
126
+ throw {
127
+ code: "network_error",
128
+ message: error.message || "Failed to connect to server",
129
+ };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Start a specific pending task for a job that is not actively running
135
+ *
136
+ * @param {string} jobId - The ID of the job
137
+ * @param {string} taskId - The ID of the task to start
138
+ * @returns {Promise<Object>} Parsed JSON response from the server
139
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
140
+ */
141
+ export async function startTask(jobId, taskId) {
142
+ try {
143
+ const response = await fetch(
144
+ `/api/jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(taskId)}/start`,
145
+ {
146
+ method: "POST",
147
+ headers: {
148
+ "Content-Type": "application/json",
149
+ },
150
+ }
151
+ );
152
+
153
+ if (!response.ok) {
154
+ // Try to parse error response, fall back to status text if parsing fails
155
+ let errorData;
156
+ try {
157
+ errorData = await response.json();
158
+ } catch {
159
+ errorData = { message: response.statusText };
160
+ }
161
+
162
+ // Throw structured error with code and message
163
+ throw {
164
+ code: errorData.code || getErrorCodeFromStatus(response.status),
165
+ message: getStartTaskErrorMessage(errorData, response.status),
166
+ status: response.status,
167
+ };
168
+ }
169
+
170
+ // Return parsed JSON for successful responses
171
+ return await response.json();
172
+ } catch (error) {
173
+ // Re-throw structured errors as-is
174
+ if (error.code && error.message) {
175
+ throw error;
176
+ }
177
+
178
+ // Handle network errors or other unexpected errors
179
+ throw {
180
+ code: "network_error",
181
+ message: error.message || "Failed to connect to server",
182
+ };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Stop a running job's pipeline
188
+ *
189
+ * @param {string} jobId - The ID of the job to stop
190
+ * @returns {Promise<Object>} Parsed JSON response from the server
191
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
192
+ */
193
+ export async function stopJob(jobId) {
194
+ try {
195
+ const response = await fetch(
196
+ `/api/jobs/${encodeURIComponent(jobId)}/stop`,
197
+ {
198
+ method: "POST",
199
+ headers: {
200
+ "Content-Type": "application/json",
201
+ },
202
+ }
203
+ );
204
+
205
+ if (!response.ok) {
206
+ // Try to parse error response, fall back to status text if parsing fails
207
+ let errorData;
208
+ try {
209
+ errorData = await response.json();
210
+ } catch {
211
+ errorData = { message: response.statusText };
212
+ }
213
+
214
+ // Throw structured error with code and message
215
+ throw {
216
+ code: errorData.code || getErrorCodeFromStatus(response.status),
217
+ message: getStopErrorMessage(errorData, response.status),
218
+ status: response.status,
219
+ };
220
+ }
221
+
222
+ // Return parsed JSON for successful responses
223
+ return await response.json();
224
+ } catch (error) {
225
+ // Re-throw structured errors as-is
226
+ if (error.code && error.message) {
227
+ throw error;
228
+ }
229
+
230
+ // Handle network errors or other unexpected errors
231
+ throw {
232
+ code: "network_error",
233
+ message: error.message || "Failed to connect to server",
234
+ };
235
+ }
236
+ }
237
+
70
238
  /**
71
239
  * Map HTTP status codes to error codes for structured error handling
72
240
  */
@@ -108,9 +276,6 @@ function getRestartErrorMessage(errorData, status) {
108
276
  if (errorData.code === "job_running") {
109
277
  return "Job is currently running; restart is unavailable.";
110
278
  }
111
- if (errorData.code === "unsupported_lifecycle") {
112
- return "Job must be in current to restart.";
113
- }
114
279
  if (errorData.message?.includes("job_running")) {
115
280
  return "Job is currently running; restart is unavailable.";
116
281
  }
@@ -132,3 +297,63 @@ function getRestartErrorMessage(errorData, status) {
132
297
  // Fall back to provided message or default
133
298
  return errorData.message || "Failed to restart job.";
134
299
  }
300
+
301
+ /**
302
+ * Get specific error message from error response for start task functionality
303
+ */
304
+ function getStartTaskErrorMessage(errorData, status) {
305
+ // Handle specific 409 conflict errors
306
+ if (status === 409) {
307
+ if (errorData.code === "job_running") {
308
+ return "Job is currently running; start is unavailable.";
309
+ }
310
+ if (errorData.code === "dependencies_not_satisfied") {
311
+ return "Dependencies not satisfied for task.";
312
+ }
313
+ if (errorData.code === "unsupported_lifecycle") {
314
+ return "Job must be in current to start a task.";
315
+ }
316
+ return "Request conflict.";
317
+ }
318
+
319
+ // Handle 404 errors
320
+ if (status === 404) {
321
+ return "Job not found.";
322
+ }
323
+
324
+ // Handle 400 errors
325
+ if (status === 400) {
326
+ return errorData.message || "Bad request";
327
+ }
328
+
329
+ // Handle 500 errors
330
+ if (status === 500) {
331
+ return "Internal server error";
332
+ }
333
+
334
+ // Fall back to provided message or default
335
+ return errorData.message || "Failed to start task.";
336
+ }
337
+
338
+ /**
339
+ * Get specific error message from error response for stop functionality
340
+ */
341
+ function getStopErrorMessage(errorData, status) {
342
+ // Handle 404 errors
343
+ if (status === 404) {
344
+ return "Job not found.";
345
+ }
346
+
347
+ // Handle 409 errors
348
+ if (status === 409) {
349
+ return errorData.message || "Job stop is already in progress.";
350
+ }
351
+
352
+ // Handle 500 errors
353
+ if (status === 500) {
354
+ return "Internal server error";
355
+ }
356
+
357
+ // Fall back to provided message or default
358
+ return errorData.message || `Request failed with status ${status}`;
359
+ }
@@ -20,7 +20,20 @@ export function useJobList() {
20
20
  setData(null);
21
21
  } else {
22
22
  const json = await res.json();
23
- setData(json);
23
+ if (json && typeof json === "object" && "ok" in json) {
24
+ if (json.ok && Array.isArray(json.data)) {
25
+ setData(json.data);
26
+ } else if (!json.ok) {
27
+ setError({ code: json.code, message: json.message });
28
+ setData(null);
29
+ } else {
30
+ // Fallback: data is not an array; treat as empty to avoid crashes
31
+ setData([]);
32
+ }
33
+ } else {
34
+ // Legacy path: response is already an array
35
+ setData(Array.isArray(json) ? json : []);
36
+ }
24
37
  }
25
38
  } catch (err) {
26
39
  if (err.name === "AbortError") {