@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.
- package/package.json +3 -1
- package/src/api/index.js +38 -1
- package/src/components/DAGGrid.jsx +180 -53
- package/src/components/JobDetail.jsx +11 -0
- package/src/components/TaskDetailSidebar.jsx +27 -3
- package/src/components/UploadSeed.jsx +2 -2
- 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/orchestrator.js +32 -0
- package/src/core/pipeline-runner.js +312 -217
- package/src/core/status-initializer.js +155 -0
- package/src/core/status-writer.js +235 -13
- 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 +81 -2
- package/src/ui/client/api.js +233 -8
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +92 -0
- package/src/ui/client/hooks/useJobList.js +14 -1
- package/src/ui/dist/app.js +262 -0
- package/src/ui/dist/assets/{index-DqkbzXZ1.js → index-B320avRx.js} +5051 -2186
- 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 -1788
- 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/ui/zip-utils.js +103 -0
- package/src/utils/jobs.js +39 -0
- package/src/ui/dist/assets/style-DBF9NQGk.css +0 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/ui/server.js",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"chokidar": "^3.5.3",
|
|
44
44
|
"commander": "^14.0.2",
|
|
45
45
|
"dotenv": "^17.2.3",
|
|
46
|
+
"express": "^4.19.2",
|
|
47
|
+
"fflate": "^0.8.2",
|
|
46
48
|
"lucide-react": "^0.544.0",
|
|
47
49
|
"openai": "^5.23.1",
|
|
48
50
|
"react": "^19.2.0",
|
package/src/api/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getJobPipelinePath,
|
|
13
13
|
} from "../config/paths.js";
|
|
14
14
|
import { generateJobId } from "../utils/id-generator.js";
|
|
15
|
+
import { initializeJobArtifacts } from "../core/status-writer.js";
|
|
15
16
|
|
|
16
17
|
// Pure functional utilities
|
|
17
18
|
const createPaths = (config) => {
|
|
@@ -100,17 +101,28 @@ export const submitJob = async (state, seed) => {
|
|
|
100
101
|
* @param {Object} options - Options object
|
|
101
102
|
* @param {string} options.dataDir - Base data directory
|
|
102
103
|
* @param {Object} options.seedObject - Seed object to submit
|
|
104
|
+
* @param {Array} [options.uploadArtifacts] - Array of {filename, content} objects
|
|
103
105
|
* @returns {Promise<Object>} Result object with success status
|
|
104
106
|
*/
|
|
105
|
-
export const submitJobWithValidation = async ({
|
|
107
|
+
export const submitJobWithValidation = async ({
|
|
108
|
+
dataDir,
|
|
109
|
+
seedObject,
|
|
110
|
+
uploadArtifacts = [],
|
|
111
|
+
}) => {
|
|
106
112
|
let partialFiles = [];
|
|
107
113
|
|
|
108
114
|
try {
|
|
109
115
|
// Validate the seed object
|
|
116
|
+
console.log("[DEBUG] submitJobWithValidation: validating seed", {
|
|
117
|
+
seedName: seedObject.name,
|
|
118
|
+
seedPipeline: seedObject.pipeline,
|
|
119
|
+
hasData: !!seedObject.data,
|
|
120
|
+
});
|
|
110
121
|
const validatedSeed = await validateSeed(
|
|
111
122
|
JSON.stringify(seedObject),
|
|
112
123
|
dataDir
|
|
113
124
|
);
|
|
125
|
+
console.log("[DEBUG] submitJobWithValidation: seed validation passed");
|
|
114
126
|
|
|
115
127
|
// Generate a random job ID
|
|
116
128
|
const jobId = generateJobId();
|
|
@@ -175,6 +187,24 @@ export const submitJobWithValidation = async ({ dataDir, seedObject }) => {
|
|
|
175
187
|
JSON.stringify(pipelineSnapshot, null, 2)
|
|
176
188
|
);
|
|
177
189
|
|
|
190
|
+
// Initialize job artifacts if any provided
|
|
191
|
+
if (uploadArtifacts.length > 0) {
|
|
192
|
+
console.log("[DEBUG] submitJobWithValidation: initializing artifacts", {
|
|
193
|
+
artifactCount: uploadArtifacts.length,
|
|
194
|
+
artifactNames: uploadArtifacts.map((a) => a.filename),
|
|
195
|
+
currentJobDir,
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
await initializeJobArtifacts(currentJobDir, uploadArtifacts);
|
|
199
|
+
console.log(
|
|
200
|
+
"[DEBUG] submitJobWithValidation: artifacts initialized successfully"
|
|
201
|
+
);
|
|
202
|
+
} catch (artifactError) {
|
|
203
|
+
// Don't fail the upload if artifact initialization fails, just log the error
|
|
204
|
+
console.error("Failed to initialize job artifacts:", artifactError);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
178
208
|
return {
|
|
179
209
|
success: true,
|
|
180
210
|
jobId,
|
|
@@ -199,6 +229,13 @@ export const submitJobWithValidation = async ({ dataDir, seedObject }) => {
|
|
|
199
229
|
errorMessage = "Required fields missing";
|
|
200
230
|
}
|
|
201
231
|
|
|
232
|
+
console.error("[DEBUG] submitJobWithValidation: validation failed", {
|
|
233
|
+
errorMessage,
|
|
234
|
+
originalError: error.message,
|
|
235
|
+
seedName: seedObject.name,
|
|
236
|
+
seedPipeline: seedObject.pipeline,
|
|
237
|
+
});
|
|
238
|
+
|
|
202
239
|
return {
|
|
203
240
|
success: false,
|
|
204
241
|
message: errorMessage,
|
|
@@ -10,9 +10,10 @@ import { areGeometriesEqual } from "../utils/geometry-equality.js";
|
|
|
10
10
|
import { TaskDetailSidebar } from "./TaskDetailSidebar.jsx";
|
|
11
11
|
import { RestartJobModal } from "./ui/RestartJobModal.jsx";
|
|
12
12
|
import { Button } from "./ui/button.jsx";
|
|
13
|
-
import { restartJob } from "../ui/client/api.js";
|
|
13
|
+
import { restartJob, startTask } from "../ui/client/api.js";
|
|
14
14
|
import { createEmptyTaskFiles } from "../utils/task-files.js";
|
|
15
15
|
import { TaskState } from "../config/statuses.js";
|
|
16
|
+
import { deriveAllowedActions } from "../ui/client/adapters/job-adapter.js";
|
|
16
17
|
import TimerText from "./TimerText.jsx";
|
|
17
18
|
import { taskToTimerProps } from "../utils/time-utils.js";
|
|
18
19
|
|
|
@@ -87,6 +88,30 @@ const canShowRestart = (status) => {
|
|
|
87
88
|
return status === TaskState.FAILED || status === TaskState.DONE;
|
|
88
89
|
};
|
|
89
90
|
|
|
91
|
+
// Check if Start button should be shown for a given status
|
|
92
|
+
const canShowStart = (status) => {
|
|
93
|
+
return status === TaskState.PENDING;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Custom comparison function for TaskCard memoization
|
|
97
|
+
const areEqualTaskCardProps = (prevProps, nextProps) => {
|
|
98
|
+
return (
|
|
99
|
+
prevProps.item === nextProps.item &&
|
|
100
|
+
prevProps.idx === nextProps.idx &&
|
|
101
|
+
prevProps.status === nextProps.status &&
|
|
102
|
+
prevProps.isActive === nextProps.isActive &&
|
|
103
|
+
prevProps.canRestart === nextProps.canRestart &&
|
|
104
|
+
prevProps.canStart === nextProps.canStart &&
|
|
105
|
+
prevProps.isSubmitting === nextProps.isSubmitting &&
|
|
106
|
+
prevProps.disabledReason === nextProps.disabledReason &&
|
|
107
|
+
prevProps.startDisabledReason === nextProps.startDisabledReason &&
|
|
108
|
+
prevProps.onClick === nextProps.onClick &&
|
|
109
|
+
prevProps.onKeyDown === nextProps.onKeyDown &&
|
|
110
|
+
prevProps.handleRestartClick === nextProps.handleRestartClick &&
|
|
111
|
+
prevProps.handleStartClick === nextProps.handleStartClick
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
90
115
|
// Memoized card component to prevent unnecessary re-renders
|
|
91
116
|
const TaskCard = memo(function TaskCard({
|
|
92
117
|
item,
|
|
@@ -95,11 +120,14 @@ const TaskCard = memo(function TaskCard({
|
|
|
95
120
|
status,
|
|
96
121
|
isActive,
|
|
97
122
|
canRestart,
|
|
123
|
+
canStart,
|
|
98
124
|
isSubmitting,
|
|
99
|
-
|
|
125
|
+
disabledReason,
|
|
126
|
+
startDisabledReason,
|
|
100
127
|
onClick,
|
|
101
128
|
onKeyDown,
|
|
102
129
|
handleRestartClick,
|
|
130
|
+
handleStartClick,
|
|
103
131
|
}) {
|
|
104
132
|
const { startMs, endMs } = taskToTimerProps(item);
|
|
105
133
|
const reducedMotion = prefersReducedMotion();
|
|
@@ -180,9 +208,24 @@ const TaskCard = memo(function TaskCard({
|
|
|
180
208
|
<div className="mt-2 text-sm text-gray-700">{item.body}</div>
|
|
181
209
|
)}
|
|
182
210
|
|
|
183
|
-
{/*
|
|
184
|
-
|
|
185
|
-
|
|
211
|
+
{/* Action buttons */}
|
|
212
|
+
<div className="mt-3 pt-3 border-t border-gray-100 flex gap-2">
|
|
213
|
+
{/* Start button */}
|
|
214
|
+
{canShowStart(status) && (
|
|
215
|
+
<Button
|
|
216
|
+
variant="outline"
|
|
217
|
+
size="sm"
|
|
218
|
+
onClick={(e) => handleStartClick(e, item.id)}
|
|
219
|
+
disabled={!canStart || isSubmitting}
|
|
220
|
+
className="text-xs cursor-pointer disabled:cursor-not-allowed"
|
|
221
|
+
title={!canStart ? startDisabledReason : `Start task ${item.id}`}
|
|
222
|
+
>
|
|
223
|
+
Start
|
|
224
|
+
</Button>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{/* Restart button */}
|
|
228
|
+
{canShowRestart(status) && (
|
|
186
229
|
<Button
|
|
187
230
|
variant="outline"
|
|
188
231
|
size="sm"
|
|
@@ -190,19 +233,17 @@ const TaskCard = memo(function TaskCard({
|
|
|
190
233
|
disabled={!canRestart || isSubmitting}
|
|
191
234
|
className="text-xs cursor-pointer disabled:cursor-not-allowed"
|
|
192
235
|
title={
|
|
193
|
-
!canRestart
|
|
194
|
-
? getRestartDisabledReason()
|
|
195
|
-
: `Restart job from ${item.id}`
|
|
236
|
+
!canRestart ? disabledReason : `Restart job from ${item.id}`
|
|
196
237
|
}
|
|
197
238
|
>
|
|
198
239
|
Restart
|
|
199
240
|
</Button>
|
|
200
|
-
|
|
201
|
-
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
202
243
|
</div>
|
|
203
244
|
</div>
|
|
204
245
|
);
|
|
205
|
-
});
|
|
246
|
+
}, areEqualTaskCardProps);
|
|
206
247
|
|
|
207
248
|
/**
|
|
208
249
|
* DAGGrid component for visualizing pipeline tasks with connectors and slide-over details
|
|
@@ -222,6 +263,8 @@ function DAGGrid({
|
|
|
222
263
|
activeIndex = 0,
|
|
223
264
|
jobId,
|
|
224
265
|
filesByTypeForItem = () => createEmptyTaskFiles(),
|
|
266
|
+
taskById = {},
|
|
267
|
+
pipelineTasks = [],
|
|
225
268
|
}) {
|
|
226
269
|
const overlayRef = useRef(null);
|
|
227
270
|
const gridRef = useRef(null);
|
|
@@ -428,20 +471,16 @@ function DAGGrid({
|
|
|
428
471
|
}
|
|
429
472
|
|
|
430
473
|
const handleResize = () => compute();
|
|
431
|
-
const handleScroll = () => compute();
|
|
432
|
-
|
|
433
474
|
window.addEventListener("resize", handleResize);
|
|
434
|
-
window.addEventListener("scroll", handleScroll, true);
|
|
435
475
|
|
|
436
476
|
return () => {
|
|
437
477
|
if (ro) ro.disconnect();
|
|
438
478
|
window.removeEventListener("resize", handleResize);
|
|
439
|
-
window.removeEventListener("scroll", handleScroll, true);
|
|
440
479
|
if (rafRef.current) {
|
|
441
480
|
cancelAnimationFrame(rafRef.current);
|
|
442
481
|
}
|
|
443
482
|
};
|
|
444
|
-
}, [items, effectiveCols, visualOrder]);
|
|
483
|
+
}, [items.length, effectiveCols, visualOrder]);
|
|
445
484
|
|
|
446
485
|
// Get status for a given item index with fallback to activeIndex
|
|
447
486
|
const getStatus = (index) => {
|
|
@@ -472,6 +511,62 @@ function DAGGrid({
|
|
|
472
511
|
}
|
|
473
512
|
}, [openIdx]);
|
|
474
513
|
|
|
514
|
+
// Start functionality
|
|
515
|
+
const handleStartClick = async (e, taskId) => {
|
|
516
|
+
e.stopPropagation(); // Prevent card click
|
|
517
|
+
|
|
518
|
+
if (!jobId || isSubmitting) return;
|
|
519
|
+
|
|
520
|
+
setIsSubmitting(true);
|
|
521
|
+
setAlertMessage(null);
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
await startTask(jobId, taskId);
|
|
525
|
+
|
|
526
|
+
const successMessage = `Task ${taskId} started successfully.`;
|
|
527
|
+
setAlertMessage(successMessage);
|
|
528
|
+
setAlertType("success");
|
|
529
|
+
} catch (error) {
|
|
530
|
+
let message;
|
|
531
|
+
let type;
|
|
532
|
+
|
|
533
|
+
switch (error.code) {
|
|
534
|
+
case "job_running":
|
|
535
|
+
message = "Job is currently running; start is unavailable.";
|
|
536
|
+
type = "warning";
|
|
537
|
+
break;
|
|
538
|
+
case "job_not_found":
|
|
539
|
+
message = "Job not found.";
|
|
540
|
+
type = "error";
|
|
541
|
+
break;
|
|
542
|
+
case "task_not_found":
|
|
543
|
+
message = "Task not found.";
|
|
544
|
+
type = "error";
|
|
545
|
+
break;
|
|
546
|
+
case "task_not_pending":
|
|
547
|
+
message = "Task is not in pending state.";
|
|
548
|
+
type = "warning";
|
|
549
|
+
break;
|
|
550
|
+
case "dependencies_not_satisfied":
|
|
551
|
+
message = "Dependencies not satisfied for task.";
|
|
552
|
+
type = "warning";
|
|
553
|
+
break;
|
|
554
|
+
case "unsupported_lifecycle":
|
|
555
|
+
message = "Job must be in current to start a task.";
|
|
556
|
+
type = "warning";
|
|
557
|
+
break;
|
|
558
|
+
default:
|
|
559
|
+
message = error.message || "An unexpected error occurred.";
|
|
560
|
+
type = "error";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
setAlertMessage(message);
|
|
564
|
+
setAlertType(type);
|
|
565
|
+
} finally {
|
|
566
|
+
setIsSubmitting(false);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
475
570
|
// Restart functionality
|
|
476
571
|
const handleRestartClick = (e, taskId) => {
|
|
477
572
|
e.stopPropagation(); // Prevent card click
|
|
@@ -479,7 +574,7 @@ function DAGGrid({
|
|
|
479
574
|
setRestartModalOpen(true);
|
|
480
575
|
};
|
|
481
576
|
|
|
482
|
-
const handleRestartConfirm = async () => {
|
|
577
|
+
const handleRestartConfirm = async (options) => {
|
|
483
578
|
if (!jobId || isSubmitting) return;
|
|
484
579
|
|
|
485
580
|
setIsSubmitting(true);
|
|
@@ -490,12 +585,17 @@ function DAGGrid({
|
|
|
490
585
|
if (restartTaskId) {
|
|
491
586
|
restartOptions.fromTask = restartTaskId;
|
|
492
587
|
}
|
|
588
|
+
if (options?.singleTask) {
|
|
589
|
+
restartOptions.singleTask = options.singleTask;
|
|
590
|
+
}
|
|
493
591
|
|
|
494
592
|
await restartJob(jobId, restartOptions);
|
|
495
593
|
|
|
496
|
-
const successMessage =
|
|
497
|
-
? `
|
|
498
|
-
:
|
|
594
|
+
const successMessage = options?.singleTask
|
|
595
|
+
? `Re-running task ${restartTaskId} in isolation. The job will remain in current after completion.`
|
|
596
|
+
: restartTaskId
|
|
597
|
+
? `Restart requested from ${restartTaskId}. The job will start from that task in the background.`
|
|
598
|
+
: "Restart requested. The job will reset to pending and start in the background.";
|
|
499
599
|
setAlertMessage(successMessage);
|
|
500
600
|
setAlertType("success");
|
|
501
601
|
setRestartModalOpen(false);
|
|
@@ -509,10 +609,6 @@ function DAGGrid({
|
|
|
509
609
|
message = "Job is currently running; restart is unavailable.";
|
|
510
610
|
type = "warning";
|
|
511
611
|
break;
|
|
512
|
-
case "unsupported_lifecycle":
|
|
513
|
-
message = "Job must be in current lifecycle to restart.";
|
|
514
|
-
type = "warning";
|
|
515
|
-
break;
|
|
516
612
|
case "job_not_found":
|
|
517
613
|
message = "Job not found.";
|
|
518
614
|
type = "error";
|
|
@@ -548,41 +644,65 @@ function DAGGrid({
|
|
|
548
644
|
}
|
|
549
645
|
}, [alertMessage]);
|
|
550
646
|
|
|
551
|
-
//
|
|
552
|
-
const
|
|
553
|
-
//
|
|
554
|
-
const
|
|
555
|
-
(item) => item?.state === TaskState.RUNNING
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
647
|
+
// Use adapter to derive allowed actions based on job and task states
|
|
648
|
+
const allowedActions = React.useMemo(() => {
|
|
649
|
+
// Create a normalized job object for the adapter
|
|
650
|
+
const adaptedJob = {
|
|
651
|
+
status: items.some((item) => item?.state === TaskState.RUNNING)
|
|
652
|
+
? "running"
|
|
653
|
+
: "pending",
|
|
654
|
+
tasks: items.reduce((acc, item) => {
|
|
655
|
+
if (item?.id) {
|
|
656
|
+
acc[item.id] = {
|
|
657
|
+
state: item?.status || TaskState.PENDING,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
return acc;
|
|
661
|
+
}, {}),
|
|
662
|
+
};
|
|
562
663
|
|
|
563
|
-
|
|
664
|
+
return deriveAllowedActions(adaptedJob, pipelineTasks);
|
|
665
|
+
}, [items, pipelineTasks]);
|
|
564
666
|
|
|
565
|
-
|
|
566
|
-
|
|
667
|
+
// Check if restart should be enabled using adapter logic
|
|
668
|
+
const isRestartEnabled = React.useCallback(() => {
|
|
669
|
+
return allowedActions.restart;
|
|
670
|
+
}, [allowedActions]);
|
|
671
|
+
|
|
672
|
+
// Check if start should be enabled for a specific task using adapter logic
|
|
673
|
+
const canStartTask = React.useCallback(
|
|
674
|
+
(task) => {
|
|
675
|
+
if (!task) return false;
|
|
676
|
+
|
|
677
|
+
// Use adapter logic - start is enabled globally, so check if this specific task can start
|
|
678
|
+
return allowedActions.start && task.status === TaskState.PENDING;
|
|
679
|
+
},
|
|
680
|
+
[allowedActions]
|
|
681
|
+
);
|
|
567
682
|
|
|
568
|
-
// Get disabled reason for tooltip
|
|
683
|
+
// Get disabled reason for tooltip using adapter logic
|
|
569
684
|
const getRestartDisabledReason = React.useCallback(() => {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
);
|
|
685
|
+
if (allowedActions.restart) return "";
|
|
686
|
+
return "Job is currently running";
|
|
687
|
+
}, [allowedActions]);
|
|
574
688
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
689
|
+
// Get disabled reason for start tooltip using adapter logic
|
|
690
|
+
const getStartDisabledReason = React.useCallback(
|
|
691
|
+
(task) => {
|
|
692
|
+
if (!task) return "Task not found";
|
|
579
693
|
|
|
580
|
-
|
|
694
|
+
if (task.status !== TaskState.PENDING) {
|
|
695
|
+
return "Task is not in pending state";
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (!allowedActions.start) {
|
|
699
|
+
return "Job lifecycle policy does not allow starting";
|
|
700
|
+
}
|
|
581
701
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
702
|
+
return "";
|
|
703
|
+
},
|
|
704
|
+
[allowedActions]
|
|
705
|
+
);
|
|
586
706
|
|
|
587
707
|
return (
|
|
588
708
|
<div className="relative w-full" role="list">
|
|
@@ -679,6 +799,9 @@ function DAGGrid({
|
|
|
679
799
|
const status = getStatus(idx);
|
|
680
800
|
const isActive = idx === activeIndex;
|
|
681
801
|
const canRestart = isRestartEnabled();
|
|
802
|
+
const canStart = canStartTask(item);
|
|
803
|
+
const restartDisabledReason = getRestartDisabledReason();
|
|
804
|
+
const startDisabledReason = getStartDisabledReason(item);
|
|
682
805
|
|
|
683
806
|
return (
|
|
684
807
|
<TaskCard
|
|
@@ -688,8 +811,10 @@ function DAGGrid({
|
|
|
688
811
|
status={status}
|
|
689
812
|
isActive={isActive}
|
|
690
813
|
canRestart={canRestart}
|
|
814
|
+
canStart={canStart}
|
|
691
815
|
isSubmitting={isSubmitting}
|
|
692
|
-
|
|
816
|
+
disabledReason={restartDisabledReason}
|
|
817
|
+
startDisabledReason={startDisabledReason}
|
|
693
818
|
onClick={() => {
|
|
694
819
|
setOpenIdx(idx);
|
|
695
820
|
}}
|
|
@@ -700,6 +825,7 @@ function DAGGrid({
|
|
|
700
825
|
}
|
|
701
826
|
}}
|
|
702
827
|
handleRestartClick={handleRestartClick}
|
|
828
|
+
handleStartClick={handleStartClick}
|
|
703
829
|
item={item}
|
|
704
830
|
/>
|
|
705
831
|
);
|
|
@@ -715,6 +841,7 @@ function DAGGrid({
|
|
|
715
841
|
jobId={jobId}
|
|
716
842
|
taskId={items[openIdx]?.id || `task-${openIdx}`}
|
|
717
843
|
taskBody={items[openIdx]?.body || null}
|
|
844
|
+
taskError={taskById[items[openIdx]?.id]?.error || null}
|
|
718
845
|
filesByTypeForItem={filesByTypeForItem}
|
|
719
846
|
task={items[openIdx]}
|
|
720
847
|
taskIndex={openIdx}
|
|
@@ -129,6 +129,16 @@ export default function JobDetail({ job, pipeline }) {
|
|
|
129
129
|
return item;
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// Check if all entries were reused and lengths match
|
|
133
|
+
const allReused = newItems.every(
|
|
134
|
+
(item, index) => item === prevItems[index]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (allReused && prevItems.length === newItems.length) {
|
|
138
|
+
// All items reused, preserve array reference
|
|
139
|
+
return prevItems;
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
prevDagItemsRef.current = newItems;
|
|
133
143
|
return newItems;
|
|
134
144
|
}, [stableDagItems]);
|
|
@@ -156,6 +166,7 @@ export default function JobDetail({ job, pipeline }) {
|
|
|
156
166
|
activeIndex={activeIndex}
|
|
157
167
|
jobId={job.id}
|
|
158
168
|
filesByTypeForItem={filesByTypeForItem}
|
|
169
|
+
taskById={taskById}
|
|
159
170
|
/>
|
|
160
171
|
</div>
|
|
161
172
|
);
|
|
@@ -23,6 +23,7 @@ export function TaskDetailSidebar({
|
|
|
23
23
|
jobId,
|
|
24
24
|
taskId,
|
|
25
25
|
taskBody,
|
|
26
|
+
taskError,
|
|
26
27
|
filesByTypeForItem = () => ({ artifacts: [], logs: [], tmp: [] }),
|
|
27
28
|
task,
|
|
28
29
|
onClose,
|
|
@@ -32,6 +33,7 @@ export function TaskDetailSidebar({
|
|
|
32
33
|
const [filePaneType, setFilePaneType] = useState("artifacts");
|
|
33
34
|
const [filePaneOpen, setFilePaneOpen] = useState(false);
|
|
34
35
|
const [filePaneFilename, setFilePaneFilename] = useState(null);
|
|
36
|
+
const [showStack, setShowStack] = useState(false);
|
|
35
37
|
const closeButtonRef = useRef(null);
|
|
36
38
|
|
|
37
39
|
// Get CSS classes for card header based on status (mirrored from DAGGrid)
|
|
@@ -120,14 +122,36 @@ export function TaskDetailSidebar({
|
|
|
120
122
|
</div>
|
|
121
123
|
|
|
122
124
|
<div className="p-6 space-y-8 overflow-y-auto h-full">
|
|
123
|
-
{/* Error Callout - shown when task has error status
|
|
124
|
-
{status === TaskState.FAILED && taskBody && (
|
|
125
|
+
{/* Error Callout - shown when task has error status */}
|
|
126
|
+
{status === TaskState.FAILED && (taskError?.message || taskBody) && (
|
|
125
127
|
<section aria-label="Error">
|
|
126
128
|
<Callout.Root role="alert" aria-live="assertive">
|
|
127
129
|
<Callout.Text className="whitespace-pre-wrap break-words">
|
|
128
|
-
{taskBody}
|
|
130
|
+
{taskError?.message || taskBody}
|
|
129
131
|
</Callout.Text>
|
|
130
132
|
</Callout.Root>
|
|
133
|
+
|
|
134
|
+
{/* Stack trace toggle */}
|
|
135
|
+
{taskError?.stack && (
|
|
136
|
+
<div className="mt-3">
|
|
137
|
+
<button
|
|
138
|
+
onClick={() => setShowStack(!showStack)}
|
|
139
|
+
className="text-sm text-blue-600 hover:text-blue-800 underline"
|
|
140
|
+
aria-expanded={showStack}
|
|
141
|
+
aria-controls="error-stack"
|
|
142
|
+
>
|
|
143
|
+
{showStack ? "Hide stack" : "Show stack"}
|
|
144
|
+
</button>
|
|
145
|
+
{showStack && (
|
|
146
|
+
<pre
|
|
147
|
+
id="error-stack"
|
|
148
|
+
className="mt-2 p-2 bg-gray-50 border rounded text-xs font-mono max-h-64 overflow-auto whitespace-pre-wrap"
|
|
149
|
+
>
|
|
150
|
+
{taskError.stack}
|
|
151
|
+
</pre>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
131
155
|
</section>
|
|
132
156
|
)}
|
|
133
157
|
|
|
@@ -152,14 +152,14 @@ export default function UploadSeed({ onUploadSuccess }) {
|
|
|
152
152
|
<span className="font-medium text-gray-900">Click to upload</span>{" "}
|
|
153
153
|
or drag and drop
|
|
154
154
|
</div>
|
|
155
|
-
<p className="text-xs text-gray-500">JSON files only</p>
|
|
155
|
+
<p className="text-xs text-gray-500">JSON or zip files only</p>
|
|
156
156
|
</div>
|
|
157
157
|
</div>
|
|
158
158
|
|
|
159
159
|
<input
|
|
160
160
|
ref={fileInputRef}
|
|
161
161
|
type="file"
|
|
162
|
-
accept=".json"
|
|
162
|
+
accept=".json,.zip"
|
|
163
163
|
className="hidden"
|
|
164
164
|
onChange={handleFileChange}
|
|
165
165
|
data-testid="file-input"
|
|
@@ -7,7 +7,7 @@ import { Button } from "./button.jsx";
|
|
|
7
7
|
* @param {Object} props
|
|
8
8
|
* @param {boolean} props.open - Whether the modal is open
|
|
9
9
|
* @param {Function} props.onClose - Function to call when modal is closed
|
|
10
|
-
* @param {Function} props.onConfirm - Function to call when restart is confirmed
|
|
10
|
+
* @param {Function} props.onConfirm - Function to call when restart is confirmed (receives options object)
|
|
11
11
|
* @param {string} props.jobId - The ID of the job to restart
|
|
12
12
|
* @param {string} props.taskId - The ID of the task that triggered the restart (optional)
|
|
13
13
|
* @param {boolean} props.isSubmitting - Whether the restart action is in progress
|
|
@@ -47,7 +47,10 @@ export function RestartJobModal({
|
|
|
47
47
|
const handleKeyDown = (e) => {
|
|
48
48
|
if (e.key === "Enter" && !isSubmitting && open) {
|
|
49
49
|
e.preventDefault();
|
|
50
|
-
|
|
50
|
+
// Do not confirm via Enter when a task is set; let the user click explicitly
|
|
51
|
+
if (!taskId) {
|
|
52
|
+
onConfirm({ singleTask: false });
|
|
53
|
+
}
|
|
51
54
|
}
|
|
52
55
|
};
|
|
53
56
|
|
|
@@ -100,9 +103,15 @@ export function RestartJobModal({
|
|
|
100
103
|
</Text>
|
|
101
104
|
|
|
102
105
|
{taskId && (
|
|
103
|
-
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
+
<>
|
|
107
|
+
<Text as="p" className="text-sm text-gray-600 mb-3">
|
|
108
|
+
<strong>Triggered from task:</strong> {taskId}
|
|
109
|
+
</Text>
|
|
110
|
+
<Text as="p" className="text-sm text-blue-600 mb-3">
|
|
111
|
+
<strong>Just this task:</strong> Only the selected task will
|
|
112
|
+
be reset and re-run. Other tasks remain unchanged.
|
|
113
|
+
</Text>
|
|
114
|
+
</>
|
|
106
115
|
)}
|
|
107
116
|
|
|
108
117
|
<Text as="p" className="text-sm text-gray-500 italic">
|
|
@@ -121,9 +130,20 @@ export function RestartJobModal({
|
|
|
121
130
|
Cancel
|
|
122
131
|
</Button>
|
|
123
132
|
|
|
133
|
+
{taskId && (
|
|
134
|
+
<Button
|
|
135
|
+
variant="outline"
|
|
136
|
+
onClick={() => onConfirm({ singleTask: true })}
|
|
137
|
+
disabled={isSubmitting}
|
|
138
|
+
className="min-w-[120px]"
|
|
139
|
+
>
|
|
140
|
+
{isSubmitting ? "Running..." : "Just this task"}
|
|
141
|
+
</Button>
|
|
142
|
+
)}
|
|
143
|
+
|
|
124
144
|
<Button
|
|
125
145
|
variant="destructive"
|
|
126
|
-
onClick={onConfirm}
|
|
146
|
+
onClick={() => onConfirm({ singleTask: false })}
|
|
127
147
|
disabled={isSubmitting}
|
|
128
148
|
className="min-w-[80px]"
|
|
129
149
|
>
|