@ryanfw/prompt-orchestration-pipeline 0.11.0 → 0.13.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 (83) hide show
  1. package/package.json +11 -1
  2. package/src/cli/analyze-task.js +51 -0
  3. package/src/cli/index.js +8 -0
  4. package/src/components/AddPipelineSidebar.jsx +144 -0
  5. package/src/components/AnalysisProgressTray.jsx +87 -0
  6. package/src/components/DAGGrid.jsx +157 -47
  7. package/src/components/JobTable.jsx +4 -3
  8. package/src/components/Layout.jsx +142 -139
  9. package/src/components/MarkdownRenderer.jsx +149 -0
  10. package/src/components/PipelineDAGGrid.jsx +404 -0
  11. package/src/components/PipelineTypeTaskSidebar.jsx +96 -0
  12. package/src/components/SchemaPreviewPanel.jsx +97 -0
  13. package/src/components/StageTimeline.jsx +36 -0
  14. package/src/components/TaskAnalysisDisplay.jsx +227 -0
  15. package/src/components/TaskCreationSidebar.jsx +447 -0
  16. package/src/components/TaskDetailSidebar.jsx +119 -117
  17. package/src/components/TaskFilePane.jsx +94 -39
  18. package/src/components/ui/RestartJobModal.jsx +26 -6
  19. package/src/components/ui/StopJobModal.jsx +183 -0
  20. package/src/components/ui/button.jsx +59 -27
  21. package/src/components/ui/sidebar.jsx +118 -0
  22. package/src/config/models.js +99 -67
  23. package/src/core/config.js +11 -4
  24. package/src/core/lifecycle-policy.js +62 -0
  25. package/src/core/pipeline-runner.js +312 -217
  26. package/src/core/status-writer.js +84 -0
  27. package/src/llm/index.js +129 -9
  28. package/src/pages/Code.jsx +8 -1
  29. package/src/pages/PipelineDetail.jsx +84 -2
  30. package/src/pages/PipelineList.jsx +214 -0
  31. package/src/pages/PipelineTypeDetail.jsx +234 -0
  32. package/src/pages/PromptPipelineDashboard.jsx +10 -11
  33. package/src/providers/deepseek.js +76 -16
  34. package/src/providers/openai.js +61 -34
  35. package/src/task-analysis/enrichers/analysis-writer.js +62 -0
  36. package/src/task-analysis/enrichers/schema-deducer.js +145 -0
  37. package/src/task-analysis/enrichers/schema-writer.js +74 -0
  38. package/src/task-analysis/extractors/artifacts.js +137 -0
  39. package/src/task-analysis/extractors/llm-calls.js +176 -0
  40. package/src/task-analysis/extractors/stages.js +51 -0
  41. package/src/task-analysis/index.js +103 -0
  42. package/src/task-analysis/parser.js +28 -0
  43. package/src/task-analysis/utils/ast.js +43 -0
  44. package/src/ui/client/adapters/job-adapter.js +60 -0
  45. package/src/ui/client/api.js +233 -8
  46. package/src/ui/client/hooks/useAnalysisProgress.js +145 -0
  47. package/src/ui/client/hooks/useJobList.js +14 -1
  48. package/src/ui/client/index.css +64 -0
  49. package/src/ui/client/main.jsx +4 -0
  50. package/src/ui/client/sse-fetch.js +120 -0
  51. package/src/ui/dist/app.js +262 -0
  52. package/src/ui/dist/assets/index-cjHV9mYW.js +82578 -0
  53. package/src/ui/dist/assets/index-cjHV9mYW.js.map +1 -0
  54. package/src/ui/dist/assets/style-CoM9SoQF.css +180 -0
  55. package/src/ui/dist/favicon.svg +12 -0
  56. package/src/ui/dist/index.html +2 -2
  57. package/src/ui/endpoints/create-pipeline-endpoint.js +194 -0
  58. package/src/ui/endpoints/file-endpoints.js +330 -0
  59. package/src/ui/endpoints/job-control-endpoints.js +1001 -0
  60. package/src/ui/endpoints/job-endpoints.js +62 -0
  61. package/src/ui/endpoints/pipeline-analysis-endpoint.js +246 -0
  62. package/src/ui/endpoints/pipeline-type-detail-endpoint.js +181 -0
  63. package/src/ui/endpoints/pipelines-endpoint.js +133 -0
  64. package/src/ui/endpoints/schema-file-endpoint.js +105 -0
  65. package/src/ui/endpoints/sse-endpoints.js +223 -0
  66. package/src/ui/endpoints/state-endpoint.js +85 -0
  67. package/src/ui/endpoints/task-analysis-endpoint.js +104 -0
  68. package/src/ui/endpoints/task-creation-endpoint.js +114 -0
  69. package/src/ui/endpoints/task-save-endpoint.js +101 -0
  70. package/src/ui/endpoints/upload-endpoints.js +406 -0
  71. package/src/ui/express-app.js +227 -0
  72. package/src/ui/lib/analysis-lock.js +67 -0
  73. package/src/ui/lib/sse.js +30 -0
  74. package/src/ui/server.js +42 -1880
  75. package/src/ui/sse-broadcast.js +93 -0
  76. package/src/ui/utils/http-utils.js +139 -0
  77. package/src/ui/utils/mime-types.js +196 -0
  78. package/src/ui/utils/slug.js +31 -0
  79. package/src/ui/vite.config.js +22 -0
  80. package/src/ui/watcher.js +28 -2
  81. package/src/utils/jobs.js +39 -0
  82. package/src/ui/dist/assets/index-DeDzq-Kk.js +0 -23863
  83. 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.13.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",
@@ -20,6 +20,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "scripts": {
23
+ "analyze": "node src/cli/index.js analyze",
24
+ "deduce-schemas": "node scripts/deduce-schemas.js",
23
25
  "test": "vitest run --config ./vite.config.js --root .",
24
26
  "lint": "eslint . --ext .js,.jsx",
25
27
  "backend": "NODE_ENV=development nodemon src/ui/server.js",
@@ -34,6 +36,9 @@
34
36
  "demo:prod": "npm run ui:build && NODE_ENV=production PO_ROOT=demo node src/ui/server.js"
35
37
  },
36
38
  "dependencies": {
39
+ "@babel/parser": "^7.28.5",
40
+ "@babel/traverse": "^7.28.5",
41
+ "@babel/types": "^7.28.5",
37
42
  "@radix-ui/react-progress": "^1.1.7",
38
43
  "@radix-ui/react-tabs": "^1.1.13",
39
44
  "@radix-ui/react-toast": "^1.2.15",
@@ -43,12 +48,17 @@
43
48
  "chokidar": "^3.5.3",
44
49
  "commander": "^14.0.2",
45
50
  "dotenv": "^17.2.3",
51
+ "express": "^4.19.2",
46
52
  "fflate": "^0.8.2",
47
53
  "lucide-react": "^0.544.0",
48
54
  "openai": "^5.23.1",
49
55
  "react": "^19.2.0",
50
56
  "react-dom": "^19.2.0",
57
+ "react-markdown": "^10.1.0",
51
58
  "react-router-dom": "^7.9.4",
59
+ "react-syntax-highlighter": "^15.6.1",
60
+ "rehype-highlight": "^7.0.2",
61
+ "remark-gfm": "^4.0.1",
52
62
  "tslib": "^2.8.1"
53
63
  },
54
64
  "devDependencies": {
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Analyze a task file and output the analysis as JSON.
6
+ *
7
+ * @param {string} taskPath - Path to the task file
8
+ * @returns {Promise<void>}
9
+ */
10
+ export async function analyzeTaskFile(taskPath) {
11
+ try {
12
+ // Use dynamic import to handle ESM/CommonJS interop for @babel/traverse
13
+ const { analyzeTask } = await import("../task-analysis/index.js");
14
+
15
+ // Resolve the task path (handle both relative and absolute paths)
16
+ const absolutePath = path.isAbsolute(taskPath)
17
+ ? taskPath
18
+ : path.resolve(process.cwd(), taskPath);
19
+
20
+ // Read the task file
21
+ const code = await fs.readFile(absolutePath, "utf8");
22
+
23
+ // Run analysis
24
+ const analysis = analyzeTask(code, absolutePath);
25
+
26
+ // Output as JSON
27
+ console.log(JSON.stringify(analysis, null, 2));
28
+ } catch (error) {
29
+ if (error && error.code === "ENOENT") {
30
+ console.error(`Error: Task file not found: ${taskPath}`);
31
+ process.exit(1);
32
+ }
33
+
34
+ const isDev =
35
+ process.env.NODE_ENV === "development" ||
36
+ process.env.DEBUG_TASK_ANALYSIS === "1";
37
+
38
+ console.error("Error analyzing task:");
39
+
40
+ if (isDev) {
41
+ // In development/debug mode, preserve full error context (including stack trace)
42
+ console.error(error && error.stack ? error.stack : error);
43
+ } else if (error && typeof error.message === "string") {
44
+ // In normal mode, show a concise message while keeping the library's formatting
45
+ console.error(error.message);
46
+ } else {
47
+ console.error(String(error));
48
+ }
49
+ process.exit(1);
50
+ }
51
+ }
package/src/cli/index.js CHANGED
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawn } from "node:child_process";
9
9
  import { updatePipelineJson } from "./update-pipeline-json.js";
10
+ import { analyzeTaskFile } from "./analyze-task.js";
10
11
 
11
12
  // Derive package root for resolving internal paths regardless of host CWD
12
13
  const currentFile = fileURLToPath(import.meta.url);
@@ -368,6 +369,13 @@ program
368
369
  }
369
370
  });
370
371
 
372
+ program
373
+ .command("analyze <task-path>")
374
+ .description("Analyze a task file and output metadata")
375
+ .action(async (taskPath) => {
376
+ await analyzeTaskFile(taskPath);
377
+ });
378
+
371
379
  program
372
380
  .command("add-pipeline-task <pipeline-slug> <task-slug>")
373
381
  .description("Add a new task to a pipeline")
@@ -0,0 +1,144 @@
1
+ import { useState, useEffect } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { Button } from "./ui/button.jsx";
4
+ import { Sidebar, SidebarFooter } from "./ui/sidebar.jsx";
5
+
6
+ export function AddPipelineSidebar({ open, onOpenChange }) {
7
+ const [name, setName] = useState("");
8
+ const [description, setDescription] = useState("");
9
+ const [error, setError] = useState(null);
10
+ const [submitting, setSubmitting] = useState(false);
11
+ const navigate = useNavigate();
12
+
13
+ useEffect(() => {
14
+ if (!open) {
15
+ setName("");
16
+ setDescription("");
17
+ setError(null);
18
+ }
19
+ }, [open]);
20
+
21
+ const handleSubmit = async (e) => {
22
+ e.preventDefault();
23
+ setError(null);
24
+ setSubmitting(true);
25
+
26
+ if (!name.trim() || !description.trim()) {
27
+ setError("Name and description are required");
28
+ setSubmitting(false);
29
+ return;
30
+ }
31
+
32
+ try {
33
+ const response = await fetch("/api/pipelines", {
34
+ method: "POST",
35
+ headers: {
36
+ "Content-Type": "application/json",
37
+ },
38
+ body: JSON.stringify({
39
+ name: name.trim(),
40
+ description: description.trim(),
41
+ }),
42
+ });
43
+
44
+ if (!response.ok) {
45
+ const result = await response.json();
46
+ throw new Error(result.error || "Failed to create pipeline");
47
+ }
48
+
49
+ const { slug } = await response.json();
50
+ onOpenChange(false);
51
+
52
+ // Wait for watcher to detect registry change and reload config
53
+ await new Promise((resolve) => setTimeout(resolve, 1000));
54
+
55
+ navigate(`/pipelines/${slug}`);
56
+ } catch (err) {
57
+ setError(err.message || "Failed to create pipeline");
58
+ } finally {
59
+ setSubmitting(false);
60
+ }
61
+ };
62
+
63
+ return (
64
+ <Sidebar
65
+ open={open}
66
+ onOpenChange={onOpenChange}
67
+ title="Add Pipeline Type"
68
+ description="Create a new pipeline type for your workflow"
69
+ >
70
+ <form onSubmit={handleSubmit}>
71
+ <div className="p-6 space-y-4">
72
+ <label className="block">
73
+ <span className="block text-sm font-medium text-foreground mb-1">
74
+ Name
75
+ </span>
76
+ <input
77
+ type="text"
78
+ value={name}
79
+ onChange={(e) => setName(e.target.value)}
80
+ className="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring bg-background"
81
+ placeholder="My Pipeline"
82
+ aria-describedby="name-description"
83
+ />
84
+ <span
85
+ id="name-description"
86
+ className="text-xs text-muted-foreground"
87
+ >
88
+ A unique identifier for this pipeline type
89
+ </span>
90
+ </label>
91
+
92
+ <label className="block">
93
+ <span className="block text-sm font-medium text-foreground mb-1">
94
+ Description
95
+ </span>
96
+ <textarea
97
+ value={description}
98
+ onChange={(e) => setDescription(e.target.value)}
99
+ rows={3}
100
+ className="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring bg-background resize-none"
101
+ placeholder="Describe what this pipeline does"
102
+ aria-describedby="description-description"
103
+ />
104
+ <span
105
+ id="description-description"
106
+ className="text-xs text-muted-foreground"
107
+ >
108
+ Explain the purpose and expected outcomes
109
+ </span>
110
+ </label>
111
+
112
+ {error && (
113
+ <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
114
+ <p className="text-sm text-destructive">{error}</p>
115
+ </div>
116
+ )}
117
+ </div>
118
+
119
+ <SidebarFooter>
120
+ <Button
121
+ variant="outline"
122
+ size="md"
123
+ type="button"
124
+ onClick={() => onOpenChange(false)}
125
+ className="flex-1"
126
+ >
127
+ Cancel
128
+ </Button>
129
+ <Button
130
+ variant="solid"
131
+ size="md"
132
+ type="submit"
133
+ loading={submitting}
134
+ className="flex-1"
135
+ >
136
+ Create
137
+ </Button>
138
+ </SidebarFooter>
139
+ </form>
140
+ </Sidebar>
141
+ );
142
+ }
143
+
144
+ export default AddPipelineSidebar;
@@ -0,0 +1,87 @@
1
+ import React from "react";
2
+ import { Progress } from "./ui/progress.jsx";
3
+ import { Button } from "./ui/button.jsx";
4
+
5
+ export function AnalysisProgressTray({
6
+ status,
7
+ pipelineSlug,
8
+ completedTasks = 0,
9
+ totalTasks = 0,
10
+ completedArtifacts = 0,
11
+ totalArtifacts = 0,
12
+ currentTask,
13
+ currentArtifact,
14
+ error,
15
+ onDismiss,
16
+ }) {
17
+ if (status === "idle") return null;
18
+
19
+ const progressPct = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
20
+ const progressVariant =
21
+ status === "error"
22
+ ? "error"
23
+ : status === "complete"
24
+ ? "completed"
25
+ : "running";
26
+
27
+ return (
28
+ <div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border bg-white shadow-lg dark:bg-gray-800 dark:border-gray-700">
29
+ <div className="flex items-center justify-between border-b p-3 dark:border-gray-700">
30
+ <h3 className="font-semibold text-sm">Analyzing {pipelineSlug}</h3>
31
+ <Button
32
+ variant="ghost"
33
+ size="sm"
34
+ onClick={onDismiss}
35
+ className="h-6 w-6 p-0"
36
+ >
37
+ ×
38
+ </Button>
39
+ </div>
40
+
41
+ <div className="p-3">
42
+ {status === "running" && (
43
+ <>
44
+ <div className="mb-2 flex items-center justify-between text-xs">
45
+ <span className="text-muted-foreground">
46
+ {completedTasks} of {totalTasks} tasks
47
+ </span>
48
+ </div>
49
+ <Progress
50
+ value={progressPct}
51
+ variant={progressVariant}
52
+ className="mb-3"
53
+ />
54
+
55
+ {currentArtifact && (
56
+ <p className="text-xs text-muted-foreground">
57
+ Deducing schema for {currentArtifact}...
58
+ </p>
59
+ )}
60
+ {currentTask && !currentArtifact && (
61
+ <p className="text-xs text-muted-foreground">
62
+ Analyzing {currentTask}...
63
+ </p>
64
+ )}
65
+ </>
66
+ )}
67
+
68
+ {status === "complete" && (
69
+ <div className="flex items-center gap-2 text-sm">
70
+ <span className="text-green-600 dark:text-green-400">✓</span>
71
+ <span>Analysis complete</span>
72
+ </div>
73
+ )}
74
+
75
+ {status === "error" && (
76
+ <div className="text-sm text-red-600 dark:text-red-400">
77
+ {error || "Analysis failed"}
78
+ </div>
79
+ )}
80
+
81
+ {status === "connecting" && (
82
+ <div className="text-sm text-muted-foreground">Connecting...</div>
83
+ )}
84
+ </div>
85
+ </div>
86
+ );
87
+ }
@@ -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
  );