@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.
Files changed (42) hide show
  1. package/package.json +3 -1
  2. package/src/api/index.js +38 -1
  3. package/src/components/DAGGrid.jsx +180 -53
  4. package/src/components/JobDetail.jsx +11 -0
  5. package/src/components/TaskDetailSidebar.jsx +27 -3
  6. package/src/components/UploadSeed.jsx +2 -2
  7. package/src/components/ui/RestartJobModal.jsx +26 -6
  8. package/src/components/ui/StopJobModal.jsx +183 -0
  9. package/src/core/config.js +7 -3
  10. package/src/core/lifecycle-policy.js +62 -0
  11. package/src/core/orchestrator.js +32 -0
  12. package/src/core/pipeline-runner.js +312 -217
  13. package/src/core/status-initializer.js +155 -0
  14. package/src/core/status-writer.js +235 -13
  15. package/src/pages/Code.jsx +8 -1
  16. package/src/pages/PipelineDetail.jsx +85 -3
  17. package/src/pages/PromptPipelineDashboard.jsx +10 -11
  18. package/src/ui/client/adapters/job-adapter.js +81 -2
  19. package/src/ui/client/api.js +233 -8
  20. package/src/ui/client/hooks/useJobDetailWithUpdates.js +92 -0
  21. package/src/ui/client/hooks/useJobList.js +14 -1
  22. package/src/ui/dist/app.js +262 -0
  23. package/src/ui/dist/assets/{index-DqkbzXZ1.js → index-B320avRx.js} +5051 -2186
  24. package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
  25. package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
  26. package/src/ui/dist/favicon.svg +12 -0
  27. package/src/ui/dist/index.html +2 -2
  28. package/src/ui/endpoints/file-endpoints.js +330 -0
  29. package/src/ui/endpoints/job-control-endpoints.js +1001 -0
  30. package/src/ui/endpoints/job-endpoints.js +62 -0
  31. package/src/ui/endpoints/sse-endpoints.js +223 -0
  32. package/src/ui/endpoints/state-endpoint.js +85 -0
  33. package/src/ui/endpoints/upload-endpoints.js +406 -0
  34. package/src/ui/express-app.js +182 -0
  35. package/src/ui/server.js +38 -1788
  36. package/src/ui/sse-broadcast.js +93 -0
  37. package/src/ui/utils/http-utils.js +139 -0
  38. package/src/ui/utils/mime-types.js +196 -0
  39. package/src/ui/vite.config.js +22 -0
  40. package/src/ui/zip-utils.js +103 -0
  41. package/src/utils/jobs.js +39 -0
  42. 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.10.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 ({ dataDir, seedObject }) => {
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
- getRestartDisabledReason,
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
- {/* Restart button */}
184
- {canShowRestart(status) && (
185
- <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) && (
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
- </div>
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 = restartTaskId
497
- ? `Restart requested from ${restartTaskId}. The job will start from that task in the background.`
498
- : "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.";
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
- // Check if restart should be enabled (job lifecycle = current and not running)
552
- const isRestartEnabled = React.useCallback(() => {
553
- // Check if any item indicates that job is running (job-level state)
554
- const isJobRunning = items.some(
555
- (item) => item?.state === TaskState.RUNNING
556
- );
557
-
558
- // Check if any task has explicit running status (not derived from activeIndex)
559
- const hasRunningTask = items.some(
560
- (item) => item?.status === TaskState.RUNNING
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
- const jobLifecycle = items[0]?.lifecycle || "current";
664
+ return deriveAllowedActions(adaptedJob, pipelineTasks);
665
+ }, [items, pipelineTasks]);
564
666
 
565
- return jobLifecycle === "current" && !isJobRunning && !hasRunningTask;
566
- }, [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
+ );
567
682
 
568
- // Get disabled reason for tooltip
683
+ // Get disabled reason for tooltip using adapter logic
569
684
  const getRestartDisabledReason = React.useCallback(() => {
570
- // Check if any item indicates that job is running (job-level state)
571
- const isJobRunning = items.some(
572
- (item) => item?.state === TaskState.RUNNING
573
- );
685
+ if (allowedActions.restart) return "";
686
+ return "Job is currently running";
687
+ }, [allowedActions]);
574
688
 
575
- // Check if any task has explicit running status (not derived from activeIndex)
576
- const hasRunningTask = items.some(
577
- (item) => item?.status === TaskState.RUNNING
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
- 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
+ }
581
701
 
582
- if (isJobRunning || hasRunningTask) return "Job is currently running";
583
- if (jobLifecycle !== "current") return "Job must be in current lifecycle";
584
- return "";
585
- }, [items]);
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
- getRestartDisabledReason={getRestartDisabledReason}
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 and body */}
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
- 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
  >