@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "0.11.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
- {/* Restart button */}
200
- {canShowRestart(status) && (
201
- <div className="mt-3 pt-3 border-t border-gray-100">
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
- </div>
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 = restartTaskId
512
- ? `Restart requested from ${restartTaskId}. The job will start from that task in the background.`
513
- : "Restart requested. The job will reset to pending and start in the background.";
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
- // Check if restart should be enabled (job lifecycle = current and not running)
567
- const isRestartEnabled = React.useCallback(() => {
568
- // Check if any item indicates that job is running (job-level state)
569
- const isJobRunning = items.some(
570
- (item) => item?.state === TaskState.RUNNING
571
- );
572
-
573
- // Check if any task has explicit running status (not derived from activeIndex)
574
- const hasRunningTask = items.some(
575
- (item) => item?.status === TaskState.RUNNING
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
- const jobLifecycle = items[0]?.lifecycle || "current";
664
+ return deriveAllowedActions(adaptedJob, pipelineTasks);
665
+ }, [items, pipelineTasks]);
579
666
 
580
- return jobLifecycle === "current" && !isJobRunning && !hasRunningTask;
581
- }, [items]);
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
- // Check if any item indicates that job is running (job-level state)
586
- const isJobRunning = items.some(
587
- (item) => item?.state === TaskState.RUNNING
588
- );
685
+ if (allowedActions.restart) return "";
686
+ return "Job is currently running";
687
+ }, [allowedActions]);
589
688
 
590
- // Check if any task has explicit running status (not derived from activeIndex)
591
- const hasRunningTask = items.some(
592
- (item) => item?.status === TaskState.RUNNING
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
- const jobLifecycle = items[0]?.lifecycle || "current";
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
- if (isJobRunning || hasRunningTask) return "Job is currently running";
598
- if (jobLifecycle !== "current") return "Job must be in current lifecycle";
599
- return "";
600
- }, [items]);
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
- onConfirm();
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
- <Text as="p" className="text-sm text-gray-600 mb-3">
104
- <strong>Triggered from task:</strong> {taskId}
105
- </Text>
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;
@@ -515,9 +515,13 @@ export function getConfig() {
515
515
  Object.keys(currentConfig.pipelines).length === 0
516
516
  ) {
517
517
  const repoRoot = resolveRepoRoot(currentConfig);
518
- throw new Error(
519
- `No pipelines are registered. Create pipeline-config/registry.json in ${repoRoot} to register pipelines.`
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
+ }