@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.
- package/package.json +2 -1
- package/src/components/DAGGrid.jsx +157 -47
- package/src/components/ui/RestartJobModal.jsx +26 -6
- package/src/components/ui/StopJobModal.jsx +183 -0
- package/src/core/config.js +7 -3
- package/src/core/lifecycle-policy.js +62 -0
- package/src/core/pipeline-runner.js +312 -217
- package/src/core/status-writer.js +84 -0
- package/src/pages/Code.jsx +8 -1
- package/src/pages/PipelineDetail.jsx +85 -3
- package/src/pages/PromptPipelineDashboard.jsx +10 -11
- package/src/ui/client/adapters/job-adapter.js +60 -0
- package/src/ui/client/api.js +233 -8
- package/src/ui/client/hooks/useJobList.js +14 -1
- package/src/ui/dist/app.js +262 -0
- package/src/ui/dist/assets/{index-DeDzq-Kk.js → index-B320avRx.js} +4854 -2104
- package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
- package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
- package/src/ui/dist/favicon.svg +12 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/endpoints/file-endpoints.js +330 -0
- package/src/ui/endpoints/job-control-endpoints.js +1001 -0
- package/src/ui/endpoints/job-endpoints.js +62 -0
- package/src/ui/endpoints/sse-endpoints.js +223 -0
- package/src/ui/endpoints/state-endpoint.js +85 -0
- package/src/ui/endpoints/upload-endpoints.js +406 -0
- package/src/ui/express-app.js +182 -0
- package/src/ui/server.js +38 -1880
- package/src/ui/sse-broadcast.js +93 -0
- package/src/ui/utils/http-utils.js +139 -0
- package/src/ui/utils/mime-types.js +196 -0
- package/src/ui/vite.config.js +22 -0
- package/src/utils/jobs.js +39 -0
- 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
|
package/src/pages/Code.jsx
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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.
|
|
59
|
+
() => jobs.filter((j) => j.displayCategory === "errors").length,
|
|
61
60
|
[jobs]
|
|
62
61
|
);
|
|
63
62
|
const currentCount = useMemo(
|
|
64
|
-
() => jobs.filter((j) => j.
|
|
63
|
+
() => jobs.filter((j) => j.displayCategory === "current").length,
|
|
65
64
|
[jobs]
|
|
66
65
|
);
|
|
67
66
|
const completedCount = useMemo(
|
|
68
|
-
() => jobs.filter((j) => j.
|
|
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.
|
|
74
|
+
return jobs.filter((j) => j.displayCategory === "current");
|
|
76
75
|
case "errors":
|
|
77
|
-
return jobs.filter((j) => j.
|
|
76
|
+
return jobs.filter((j) => j.displayCategory === "errors");
|
|
78
77
|
case "complete":
|
|
79
|
-
return jobs.filter((j) => j.
|
|
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.
|
|
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
|
+
}
|
package/src/ui/client/api.js
CHANGED
|
@@ -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 {
|
|
11
|
-
* @param {boolean} opts.
|
|
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
|
-
? {
|
|
23
|
-
|
|
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
|
-
|
|
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") {
|