@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
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,7 @@
|
|
|
43
43
|
"chokidar": "^3.5.3",
|
|
44
44
|
"commander": "^14.0.2",
|
|
45
45
|
"dotenv": "^17.2.3",
|
|
46
|
+
"express": "^4.19.2",
|
|
46
47
|
"fflate": "^0.8.2",
|
|
47
48
|
"lucide-react": "^0.544.0",
|
|
48
49
|
"openai": "^5.23.1",
|
|
@@ -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,11 @@ 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
|
+
|
|
90
96
|
// Custom comparison function for TaskCard memoization
|
|
91
97
|
const areEqualTaskCardProps = (prevProps, nextProps) => {
|
|
92
98
|
return (
|
|
@@ -95,11 +101,14 @@ const areEqualTaskCardProps = (prevProps, nextProps) => {
|
|
|
95
101
|
prevProps.status === nextProps.status &&
|
|
96
102
|
prevProps.isActive === nextProps.isActive &&
|
|
97
103
|
prevProps.canRestart === nextProps.canRestart &&
|
|
104
|
+
prevProps.canStart === nextProps.canStart &&
|
|
98
105
|
prevProps.isSubmitting === nextProps.isSubmitting &&
|
|
99
106
|
prevProps.disabledReason === nextProps.disabledReason &&
|
|
107
|
+
prevProps.startDisabledReason === nextProps.startDisabledReason &&
|
|
100
108
|
prevProps.onClick === nextProps.onClick &&
|
|
101
109
|
prevProps.onKeyDown === nextProps.onKeyDown &&
|
|
102
|
-
prevProps.handleRestartClick === nextProps.handleRestartClick
|
|
110
|
+
prevProps.handleRestartClick === nextProps.handleRestartClick &&
|
|
111
|
+
prevProps.handleStartClick === nextProps.handleStartClick
|
|
103
112
|
);
|
|
104
113
|
};
|
|
105
114
|
|
|
@@ -111,11 +120,14 @@ const TaskCard = memo(function TaskCard({
|
|
|
111
120
|
status,
|
|
112
121
|
isActive,
|
|
113
122
|
canRestart,
|
|
123
|
+
canStart,
|
|
114
124
|
isSubmitting,
|
|
115
125
|
disabledReason,
|
|
126
|
+
startDisabledReason,
|
|
116
127
|
onClick,
|
|
117
128
|
onKeyDown,
|
|
118
129
|
handleRestartClick,
|
|
130
|
+
handleStartClick,
|
|
119
131
|
}) {
|
|
120
132
|
const { startMs, endMs } = taskToTimerProps(item);
|
|
121
133
|
const reducedMotion = prefersReducedMotion();
|
|
@@ -196,9 +208,24 @@ const TaskCard = memo(function TaskCard({
|
|
|
196
208
|
<div className="mt-2 text-sm text-gray-700">{item.body}</div>
|
|
197
209
|
)}
|
|
198
210
|
|
|
199
|
-
{/*
|
|
200
|
-
|
|
201
|
-
|
|
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) && (
|
|
202
229
|
<Button
|
|
203
230
|
variant="outline"
|
|
204
231
|
size="sm"
|
|
@@ -211,8 +238,8 @@ const TaskCard = memo(function TaskCard({
|
|
|
211
238
|
>
|
|
212
239
|
Restart
|
|
213
240
|
</Button>
|
|
214
|
-
|
|
215
|
-
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
216
243
|
</div>
|
|
217
244
|
</div>
|
|
218
245
|
);
|
|
@@ -237,6 +264,7 @@ function DAGGrid({
|
|
|
237
264
|
jobId,
|
|
238
265
|
filesByTypeForItem = () => createEmptyTaskFiles(),
|
|
239
266
|
taskById = {},
|
|
267
|
+
pipelineTasks = [],
|
|
240
268
|
}) {
|
|
241
269
|
const overlayRef = useRef(null);
|
|
242
270
|
const gridRef = useRef(null);
|
|
@@ -443,15 +471,11 @@ function DAGGrid({
|
|
|
443
471
|
}
|
|
444
472
|
|
|
445
473
|
const handleResize = () => compute();
|
|
446
|
-
const handleScroll = () => compute();
|
|
447
|
-
|
|
448
474
|
window.addEventListener("resize", handleResize);
|
|
449
|
-
window.addEventListener("scroll", handleScroll, true);
|
|
450
475
|
|
|
451
476
|
return () => {
|
|
452
477
|
if (ro) ro.disconnect();
|
|
453
478
|
window.removeEventListener("resize", handleResize);
|
|
454
|
-
window.removeEventListener("scroll", handleScroll, true);
|
|
455
479
|
if (rafRef.current) {
|
|
456
480
|
cancelAnimationFrame(rafRef.current);
|
|
457
481
|
}
|
|
@@ -487,6 +511,62 @@ function DAGGrid({
|
|
|
487
511
|
}
|
|
488
512
|
}, [openIdx]);
|
|
489
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
|
+
|
|
490
570
|
// Restart functionality
|
|
491
571
|
const handleRestartClick = (e, taskId) => {
|
|
492
572
|
e.stopPropagation(); // Prevent card click
|
|
@@ -494,7 +574,7 @@ function DAGGrid({
|
|
|
494
574
|
setRestartModalOpen(true);
|
|
495
575
|
};
|
|
496
576
|
|
|
497
|
-
const handleRestartConfirm = async () => {
|
|
577
|
+
const handleRestartConfirm = async (options) => {
|
|
498
578
|
if (!jobId || isSubmitting) return;
|
|
499
579
|
|
|
500
580
|
setIsSubmitting(true);
|
|
@@ -505,12 +585,17 @@ function DAGGrid({
|
|
|
505
585
|
if (restartTaskId) {
|
|
506
586
|
restartOptions.fromTask = restartTaskId;
|
|
507
587
|
}
|
|
588
|
+
if (options?.singleTask) {
|
|
589
|
+
restartOptions.singleTask = options.singleTask;
|
|
590
|
+
}
|
|
508
591
|
|
|
509
592
|
await restartJob(jobId, restartOptions);
|
|
510
593
|
|
|
511
|
-
const successMessage =
|
|
512
|
-
? `
|
|
513
|
-
:
|
|
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.";
|
|
514
599
|
setAlertMessage(successMessage);
|
|
515
600
|
setAlertType("success");
|
|
516
601
|
setRestartModalOpen(false);
|
|
@@ -524,10 +609,6 @@ function DAGGrid({
|
|
|
524
609
|
message = "Job is currently running; restart is unavailable.";
|
|
525
610
|
type = "warning";
|
|
526
611
|
break;
|
|
527
|
-
case "unsupported_lifecycle":
|
|
528
|
-
message = "Job must be in current lifecycle to restart.";
|
|
529
|
-
type = "warning";
|
|
530
|
-
break;
|
|
531
612
|
case "job_not_found":
|
|
532
613
|
message = "Job not found.";
|
|
533
614
|
type = "error";
|
|
@@ -563,41 +644,65 @@ function DAGGrid({
|
|
|
563
644
|
}
|
|
564
645
|
}, [alertMessage]);
|
|
565
646
|
|
|
566
|
-
//
|
|
567
|
-
const
|
|
568
|
-
//
|
|
569
|
-
const
|
|
570
|
-
(item) => item?.state === TaskState.RUNNING
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
+
};
|
|
577
663
|
|
|
578
|
-
|
|
664
|
+
return deriveAllowedActions(adaptedJob, pipelineTasks);
|
|
665
|
+
}, [items, pipelineTasks]);
|
|
579
666
|
|
|
580
|
-
|
|
581
|
-
|
|
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
|
+
);
|
|
582
682
|
|
|
583
|
-
// Get disabled reason for tooltip
|
|
683
|
+
// Get disabled reason for tooltip using adapter logic
|
|
584
684
|
const getRestartDisabledReason = React.useCallback(() => {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
);
|
|
685
|
+
if (allowedActions.restart) return "";
|
|
686
|
+
return "Job is currently running";
|
|
687
|
+
}, [allowedActions]);
|
|
589
688
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
689
|
+
// Get disabled reason for start tooltip using adapter logic
|
|
690
|
+
const getStartDisabledReason = React.useCallback(
|
|
691
|
+
(task) => {
|
|
692
|
+
if (!task) return "Task not found";
|
|
594
693
|
|
|
595
|
-
|
|
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
|
+
}
|
|
596
701
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
702
|
+
return "";
|
|
703
|
+
},
|
|
704
|
+
[allowedActions]
|
|
705
|
+
);
|
|
601
706
|
|
|
602
707
|
return (
|
|
603
708
|
<div className="relative w-full" role="list">
|
|
@@ -694,7 +799,9 @@ function DAGGrid({
|
|
|
694
799
|
const status = getStatus(idx);
|
|
695
800
|
const isActive = idx === activeIndex;
|
|
696
801
|
const canRestart = isRestartEnabled();
|
|
802
|
+
const canStart = canStartTask(item);
|
|
697
803
|
const restartDisabledReason = getRestartDisabledReason();
|
|
804
|
+
const startDisabledReason = getStartDisabledReason(item);
|
|
698
805
|
|
|
699
806
|
return (
|
|
700
807
|
<TaskCard
|
|
@@ -704,8 +811,10 @@ function DAGGrid({
|
|
|
704
811
|
status={status}
|
|
705
812
|
isActive={isActive}
|
|
706
813
|
canRestart={canRestart}
|
|
814
|
+
canStart={canStart}
|
|
707
815
|
isSubmitting={isSubmitting}
|
|
708
816
|
disabledReason={restartDisabledReason}
|
|
817
|
+
startDisabledReason={startDisabledReason}
|
|
709
818
|
onClick={() => {
|
|
710
819
|
setOpenIdx(idx);
|
|
711
820
|
}}
|
|
@@ -716,6 +825,7 @@ function DAGGrid({
|
|
|
716
825
|
}
|
|
717
826
|
}}
|
|
718
827
|
handleRestartClick={handleRestartClick}
|
|
828
|
+
handleStartClick={handleStartClick}
|
|
719
829
|
item={item}
|
|
720
830
|
/>
|
|
721
831
|
);
|
|
@@ -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
|
>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Flex, Text, Heading, Select } from "@radix-ui/themes";
|
|
3
|
+
import { Button } from "./button.jsx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* StopJobModal component for confirming job stop
|
|
7
|
+
* @param {Object} props
|
|
8
|
+
* @param {boolean} props.isOpen - Whether the modal is open
|
|
9
|
+
* @param {Function} props.onClose - Function to call when modal is closed
|
|
10
|
+
* @param {Function} props.onConfirm - Function to call when stop is confirmed (receives jobId)
|
|
11
|
+
* @param {Array} props.runningJobs - Array of running jobs with {id, name, progress?}
|
|
12
|
+
* @param {string} [props.defaultJobId] - Default job ID to pre-select
|
|
13
|
+
* @param {boolean} props.isSubmitting - Whether the stop action is in progress
|
|
14
|
+
*/
|
|
15
|
+
export function StopJobModal({
|
|
16
|
+
isOpen,
|
|
17
|
+
onClose,
|
|
18
|
+
onConfirm,
|
|
19
|
+
runningJobs,
|
|
20
|
+
defaultJobId,
|
|
21
|
+
isSubmitting = false,
|
|
22
|
+
}) {
|
|
23
|
+
const modalRef = useRef(null);
|
|
24
|
+
const [selectedJobId, setSelectedJobId] = useState(defaultJobId || "");
|
|
25
|
+
|
|
26
|
+
// Reset selected job when modal opens/closes
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (isOpen) {
|
|
29
|
+
setSelectedJobId(
|
|
30
|
+
defaultJobId || (runningJobs.length === 1 ? runningJobs[0].id : "")
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}, [isOpen, defaultJobId]);
|
|
34
|
+
|
|
35
|
+
// Handle Escape key to close modal
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleKeyDown = (e) => {
|
|
38
|
+
if (e.key === "Escape" && isOpen) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
onClose();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (isOpen) {
|
|
45
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
46
|
+
// Focus the modal for accessibility
|
|
47
|
+
if (modalRef.current) {
|
|
48
|
+
modalRef.current.focus();
|
|
49
|
+
}
|
|
50
|
+
return () => {
|
|
51
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}, [isOpen, onClose]);
|
|
55
|
+
|
|
56
|
+
// Handle Enter key to confirm when modal is focused
|
|
57
|
+
const handleKeyDown = (e) => {
|
|
58
|
+
if (e.key === "Enter" && !isSubmitting && isOpen && selectedJobId) {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
onConfirm(selectedJobId);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!isOpen) return null;
|
|
65
|
+
|
|
66
|
+
const handleConfirm = () => {
|
|
67
|
+
if (selectedJobId) {
|
|
68
|
+
onConfirm(selectedJobId);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const selectedJob = runningJobs.find((job) => job.id === selectedJobId);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<div
|
|
77
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
78
|
+
aria-hidden={!isOpen}
|
|
79
|
+
>
|
|
80
|
+
{/* Backdrop */}
|
|
81
|
+
<div
|
|
82
|
+
className="absolute inset-0 bg-black/50"
|
|
83
|
+
onClick={onClose}
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
/>
|
|
86
|
+
|
|
87
|
+
{/* Modal */}
|
|
88
|
+
<div
|
|
89
|
+
ref={modalRef}
|
|
90
|
+
role="dialog"
|
|
91
|
+
aria-modal="true"
|
|
92
|
+
aria-labelledby="stop-modal-title"
|
|
93
|
+
aria-describedby="stop-modal-description"
|
|
94
|
+
className="relative bg-white rounded-lg shadow-2xl border border-gray-200 max-w-lg w-full mx-4 outline-none"
|
|
95
|
+
style={{ minWidth: "320px", maxWidth: "560px" }}
|
|
96
|
+
tabIndex={-1}
|
|
97
|
+
onKeyDown={handleKeyDown}
|
|
98
|
+
>
|
|
99
|
+
<div className="p-6">
|
|
100
|
+
{/* Header */}
|
|
101
|
+
<Heading
|
|
102
|
+
id="stop-modal-title"
|
|
103
|
+
as="h2"
|
|
104
|
+
size="5"
|
|
105
|
+
className="mb-4 text-gray-900"
|
|
106
|
+
>
|
|
107
|
+
Stop pipeline?
|
|
108
|
+
</Heading>
|
|
109
|
+
|
|
110
|
+
{/* Body */}
|
|
111
|
+
<Box id="stop-modal-description" className="mb-6">
|
|
112
|
+
<Text as="p" className="text-gray-700 mb-4">
|
|
113
|
+
This will stop the running pipeline and reset the current task
|
|
114
|
+
to pending. The pipeline will remain stopped until explicitly
|
|
115
|
+
started or restarted. Files and artifacts are preserved. This
|
|
116
|
+
cannot be undone.
|
|
117
|
+
</Text>
|
|
118
|
+
|
|
119
|
+
{runningJobs.length > 1 && !defaultJobId && (
|
|
120
|
+
<Box className="mb-4">
|
|
121
|
+
<Text as="p" className="text-sm text-gray-600 mb-2">
|
|
122
|
+
Select which job to stop:
|
|
123
|
+
</Text>
|
|
124
|
+
<Select.Root
|
|
125
|
+
value={selectedJobId}
|
|
126
|
+
onValueChange={setSelectedJobId}
|
|
127
|
+
disabled={isSubmitting}
|
|
128
|
+
>
|
|
129
|
+
<Select.Trigger className="w-full" />
|
|
130
|
+
<Select.Content>
|
|
131
|
+
{runningJobs.map((job) => (
|
|
132
|
+
<Select.Item key={job.id} value={job.id}>
|
|
133
|
+
{job.name}{" "}
|
|
134
|
+
{job.progress !== undefined &&
|
|
135
|
+
`(${Math.round(job.progress)}%)`}
|
|
136
|
+
</Select.Item>
|
|
137
|
+
))}
|
|
138
|
+
</Select.Content>
|
|
139
|
+
</Select.Root>
|
|
140
|
+
</Box>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{selectedJob && (
|
|
144
|
+
<Text as="p" className="text-sm text-blue-600 mb-3">
|
|
145
|
+
<strong>Job to stop:</strong> {selectedJob.name}
|
|
146
|
+
{selectedJob.progress !== undefined &&
|
|
147
|
+
` (${Math.round(selectedJob.progress)}%)`}
|
|
148
|
+
</Text>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
<Text as="p" className="text-sm text-gray-500 italic">
|
|
152
|
+
Note: The job must be currently running to be stopped.
|
|
153
|
+
</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
|
|
156
|
+
{/* Actions */}
|
|
157
|
+
<Flex gap="3" justify="end">
|
|
158
|
+
<Button
|
|
159
|
+
variant="outline"
|
|
160
|
+
onClick={onClose}
|
|
161
|
+
disabled={isSubmitting}
|
|
162
|
+
className="min-w-[80px]"
|
|
163
|
+
>
|
|
164
|
+
Cancel
|
|
165
|
+
</Button>
|
|
166
|
+
|
|
167
|
+
<Button
|
|
168
|
+
variant="destructive"
|
|
169
|
+
onClick={handleConfirm}
|
|
170
|
+
disabled={!selectedJobId || isSubmitting}
|
|
171
|
+
className="min-w-[80px]"
|
|
172
|
+
>
|
|
173
|
+
{isSubmitting ? "Stopping..." : "Stop"}
|
|
174
|
+
</Button>
|
|
175
|
+
</Flex>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default StopJobModal;
|
package/src/core/config.js
CHANGED
|
@@ -515,9 +515,13 @@ export function getConfig() {
|
|
|
515
515
|
Object.keys(currentConfig.pipelines).length === 0
|
|
516
516
|
) {
|
|
517
517
|
const repoRoot = resolveRepoRoot(currentConfig);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
518
|
+
// In test environment, we might start without pipelines and add them later
|
|
519
|
+
// so we just warn instead of throwing, or handle it gracefully
|
|
520
|
+
if (process.env.NODE_ENV !== "test") {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`No pipelines are registered. Create pipeline-config/registry.json in ${repoRoot} to register pipelines.`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
521
525
|
}
|
|
522
526
|
}
|
|
523
527
|
return currentConfig;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static Lifecycle Policy - Pure decision engine for task transitions
|
|
3
|
+
*
|
|
4
|
+
* This module implements centralized lifecycle rules without configuration
|
|
5
|
+
* or runtime toggles, following the principle of explicit failure.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decide if a task transition is allowed based on static lifecycle rules
|
|
10
|
+
* @param {Object} params - Decision parameters
|
|
11
|
+
* @param {string} params.op - Operation: "start" | "restart"
|
|
12
|
+
* @param {string} params.taskState - Current task state
|
|
13
|
+
* @param {boolean} params.dependenciesReady - Whether all upstream dependencies are satisfied
|
|
14
|
+
* @returns {Object} Decision result - { ok: true } | { ok: false, code: "unsupported_lifecycle", reason: "dependencies"|"policy" }
|
|
15
|
+
*/
|
|
16
|
+
export function decideTransition({ op, taskState, dependenciesReady }) {
|
|
17
|
+
// Validate inputs early - let it crash on invalid data
|
|
18
|
+
if (typeof op !== "string" || !["start", "restart"].includes(op)) {
|
|
19
|
+
throw new Error(`Invalid operation: ${op}. Must be "start" or "restart"`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof taskState !== "string") {
|
|
23
|
+
throw new Error(`Invalid taskState: ${taskState}. Must be a string`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof dependenciesReady !== "boolean") {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Invalid dependenciesReady: ${dependenciesReady}. Must be boolean`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Handle start operation
|
|
33
|
+
if (op === "start") {
|
|
34
|
+
if (!dependenciesReady) {
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
ok: false,
|
|
37
|
+
code: "unsupported_lifecycle",
|
|
38
|
+
reason: "dependencies",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({ ok: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle restart operation
|
|
45
|
+
if (op === "restart") {
|
|
46
|
+
if (taskState === "completed") {
|
|
47
|
+
return Object.freeze({ ok: true });
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
ok: false,
|
|
51
|
+
code: "unsupported_lifecycle",
|
|
52
|
+
reason: "policy",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// This should never be reached due to input validation
|
|
57
|
+
return Object.freeze({
|
|
58
|
+
ok: false,
|
|
59
|
+
code: "unsupported_lifecycle",
|
|
60
|
+
reason: "policy",
|
|
61
|
+
});
|
|
62
|
+
}
|