@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.
- package/package.json +11 -1
- package/src/cli/analyze-task.js +51 -0
- package/src/cli/index.js +8 -0
- package/src/components/AddPipelineSidebar.jsx +144 -0
- package/src/components/AnalysisProgressTray.jsx +87 -0
- package/src/components/DAGGrid.jsx +157 -47
- package/src/components/JobTable.jsx +4 -3
- package/src/components/Layout.jsx +142 -139
- package/src/components/MarkdownRenderer.jsx +149 -0
- package/src/components/PipelineDAGGrid.jsx +404 -0
- package/src/components/PipelineTypeTaskSidebar.jsx +96 -0
- package/src/components/SchemaPreviewPanel.jsx +97 -0
- package/src/components/StageTimeline.jsx +36 -0
- package/src/components/TaskAnalysisDisplay.jsx +227 -0
- package/src/components/TaskCreationSidebar.jsx +447 -0
- package/src/components/TaskDetailSidebar.jsx +119 -117
- package/src/components/TaskFilePane.jsx +94 -39
- package/src/components/ui/RestartJobModal.jsx +26 -6
- package/src/components/ui/StopJobModal.jsx +183 -0
- package/src/components/ui/button.jsx +59 -27
- package/src/components/ui/sidebar.jsx +118 -0
- package/src/config/models.js +99 -67
- package/src/core/config.js +11 -4
- 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/llm/index.js +129 -9
- package/src/pages/Code.jsx +8 -1
- package/src/pages/PipelineDetail.jsx +84 -2
- package/src/pages/PipelineList.jsx +214 -0
- package/src/pages/PipelineTypeDetail.jsx +234 -0
- package/src/pages/PromptPipelineDashboard.jsx +10 -11
- package/src/providers/deepseek.js +76 -16
- package/src/providers/openai.js +61 -34
- package/src/task-analysis/enrichers/analysis-writer.js +62 -0
- package/src/task-analysis/enrichers/schema-deducer.js +145 -0
- package/src/task-analysis/enrichers/schema-writer.js +74 -0
- package/src/task-analysis/extractors/artifacts.js +137 -0
- package/src/task-analysis/extractors/llm-calls.js +176 -0
- package/src/task-analysis/extractors/stages.js +51 -0
- package/src/task-analysis/index.js +103 -0
- package/src/task-analysis/parser.js +28 -0
- package/src/task-analysis/utils/ast.js +43 -0
- package/src/ui/client/adapters/job-adapter.js +60 -0
- package/src/ui/client/api.js +233 -8
- package/src/ui/client/hooks/useAnalysisProgress.js +145 -0
- package/src/ui/client/hooks/useJobList.js +14 -1
- package/src/ui/client/index.css +64 -0
- package/src/ui/client/main.jsx +4 -0
- package/src/ui/client/sse-fetch.js +120 -0
- package/src/ui/dist/app.js +262 -0
- package/src/ui/dist/assets/index-cjHV9mYW.js +82578 -0
- package/src/ui/dist/assets/index-cjHV9mYW.js.map +1 -0
- package/src/ui/dist/assets/style-CoM9SoQF.css +180 -0
- package/src/ui/dist/favicon.svg +12 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/endpoints/create-pipeline-endpoint.js +194 -0
- 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/pipeline-analysis-endpoint.js +246 -0
- package/src/ui/endpoints/pipeline-type-detail-endpoint.js +181 -0
- package/src/ui/endpoints/pipelines-endpoint.js +133 -0
- package/src/ui/endpoints/schema-file-endpoint.js +105 -0
- package/src/ui/endpoints/sse-endpoints.js +223 -0
- package/src/ui/endpoints/state-endpoint.js +85 -0
- package/src/ui/endpoints/task-analysis-endpoint.js +104 -0
- package/src/ui/endpoints/task-creation-endpoint.js +114 -0
- package/src/ui/endpoints/task-save-endpoint.js +101 -0
- package/src/ui/endpoints/upload-endpoints.js +406 -0
- package/src/ui/express-app.js +227 -0
- package/src/ui/lib/analysis-lock.js +67 -0
- package/src/ui/lib/sse.js +30 -0
- package/src/ui/server.js +42 -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/utils/slug.js +31 -0
- package/src/ui/vite.config.js +22 -0
- package/src/ui/watcher.js +28 -2
- package/src/utils/jobs.js +39 -0
- package/src/ui/dist/assets/index-DeDzq-Kk.js +0 -23863
- 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.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
|
-
{/*
|
|
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
|
);
|