@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.
- package/package.json +3 -1
- package/src/api/index.js +38 -1
- package/src/components/DAGGrid.jsx +180 -53
- package/src/components/JobDetail.jsx +11 -0
- package/src/components/TaskDetailSidebar.jsx +27 -3
- package/src/components/UploadSeed.jsx +2 -2
- package/src/components/ui/RestartJobModal.jsx +26 -6
- package/src/components/ui/StopJobModal.jsx +183 -0
- package/src/core/config.js +7 -3
- package/src/core/lifecycle-policy.js +62 -0
- package/src/core/orchestrator.js +32 -0
- package/src/core/pipeline-runner.js +312 -217
- package/src/core/status-initializer.js +155 -0
- package/src/core/status-writer.js +235 -13
- package/src/pages/Code.jsx +8 -1
- package/src/pages/PipelineDetail.jsx +85 -3
- package/src/pages/PromptPipelineDashboard.jsx +10 -11
- package/src/ui/client/adapters/job-adapter.js +81 -2
- package/src/ui/client/api.js +233 -8
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +92 -0
- package/src/ui/client/hooks/useJobList.js +14 -1
- package/src/ui/dist/app.js +262 -0
- package/src/ui/dist/assets/{index-DqkbzXZ1.js → index-B320avRx.js} +5051 -2186
- package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
- package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
- package/src/ui/dist/favicon.svg +12 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/endpoints/file-endpoints.js +330 -0
- package/src/ui/endpoints/job-control-endpoints.js +1001 -0
- package/src/ui/endpoints/job-endpoints.js +62 -0
- package/src/ui/endpoints/sse-endpoints.js +223 -0
- package/src/ui/endpoints/state-endpoint.js +85 -0
- package/src/ui/endpoints/upload-endpoints.js +406 -0
- package/src/ui/express-app.js +182 -0
- package/src/ui/server.js +38 -1788
- package/src/ui/sse-broadcast.js +93 -0
- package/src/ui/utils/http-utils.js +139 -0
- package/src/ui/utils/mime-types.js +196 -0
- package/src/ui/vite.config.js +22 -0
- package/src/ui/zip-utils.js +103 -0
- package/src/utils/jobs.js +39 -0
- package/src/ui/dist/assets/style-DBF9NQGk.css +0 -62
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Flex, Text, Heading, Select } from "@radix-ui/themes";
|
|
3
|
+
import { Button } from "./button.jsx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* StopJobModal component for confirming job stop
|
|
7
|
+
* @param {Object} props
|
|
8
|
+
* @param {boolean} props.isOpen - Whether the modal is open
|
|
9
|
+
* @param {Function} props.onClose - Function to call when modal is closed
|
|
10
|
+
* @param {Function} props.onConfirm - Function to call when stop is confirmed (receives jobId)
|
|
11
|
+
* @param {Array} props.runningJobs - Array of running jobs with {id, name, progress?}
|
|
12
|
+
* @param {string} [props.defaultJobId] - Default job ID to pre-select
|
|
13
|
+
* @param {boolean} props.isSubmitting - Whether the stop action is in progress
|
|
14
|
+
*/
|
|
15
|
+
export function StopJobModal({
|
|
16
|
+
isOpen,
|
|
17
|
+
onClose,
|
|
18
|
+
onConfirm,
|
|
19
|
+
runningJobs,
|
|
20
|
+
defaultJobId,
|
|
21
|
+
isSubmitting = false,
|
|
22
|
+
}) {
|
|
23
|
+
const modalRef = useRef(null);
|
|
24
|
+
const [selectedJobId, setSelectedJobId] = useState(defaultJobId || "");
|
|
25
|
+
|
|
26
|
+
// Reset selected job when modal opens/closes
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (isOpen) {
|
|
29
|
+
setSelectedJobId(
|
|
30
|
+
defaultJobId || (runningJobs.length === 1 ? runningJobs[0].id : "")
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}, [isOpen, defaultJobId]);
|
|
34
|
+
|
|
35
|
+
// Handle Escape key to close modal
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleKeyDown = (e) => {
|
|
38
|
+
if (e.key === "Escape" && isOpen) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
onClose();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (isOpen) {
|
|
45
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
46
|
+
// Focus the modal for accessibility
|
|
47
|
+
if (modalRef.current) {
|
|
48
|
+
modalRef.current.focus();
|
|
49
|
+
}
|
|
50
|
+
return () => {
|
|
51
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}, [isOpen, onClose]);
|
|
55
|
+
|
|
56
|
+
// Handle Enter key to confirm when modal is focused
|
|
57
|
+
const handleKeyDown = (e) => {
|
|
58
|
+
if (e.key === "Enter" && !isSubmitting && isOpen && selectedJobId) {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
onConfirm(selectedJobId);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!isOpen) return null;
|
|
65
|
+
|
|
66
|
+
const handleConfirm = () => {
|
|
67
|
+
if (selectedJobId) {
|
|
68
|
+
onConfirm(selectedJobId);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const selectedJob = runningJobs.find((job) => job.id === selectedJobId);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<div
|
|
77
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
78
|
+
aria-hidden={!isOpen}
|
|
79
|
+
>
|
|
80
|
+
{/* Backdrop */}
|
|
81
|
+
<div
|
|
82
|
+
className="absolute inset-0 bg-black/50"
|
|
83
|
+
onClick={onClose}
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
/>
|
|
86
|
+
|
|
87
|
+
{/* Modal */}
|
|
88
|
+
<div
|
|
89
|
+
ref={modalRef}
|
|
90
|
+
role="dialog"
|
|
91
|
+
aria-modal="true"
|
|
92
|
+
aria-labelledby="stop-modal-title"
|
|
93
|
+
aria-describedby="stop-modal-description"
|
|
94
|
+
className="relative bg-white rounded-lg shadow-2xl border border-gray-200 max-w-lg w-full mx-4 outline-none"
|
|
95
|
+
style={{ minWidth: "320px", maxWidth: "560px" }}
|
|
96
|
+
tabIndex={-1}
|
|
97
|
+
onKeyDown={handleKeyDown}
|
|
98
|
+
>
|
|
99
|
+
<div className="p-6">
|
|
100
|
+
{/* Header */}
|
|
101
|
+
<Heading
|
|
102
|
+
id="stop-modal-title"
|
|
103
|
+
as="h2"
|
|
104
|
+
size="5"
|
|
105
|
+
className="mb-4 text-gray-900"
|
|
106
|
+
>
|
|
107
|
+
Stop pipeline?
|
|
108
|
+
</Heading>
|
|
109
|
+
|
|
110
|
+
{/* Body */}
|
|
111
|
+
<Box id="stop-modal-description" className="mb-6">
|
|
112
|
+
<Text as="p" className="text-gray-700 mb-4">
|
|
113
|
+
This will stop the running pipeline and reset the current task
|
|
114
|
+
to pending. The pipeline will remain stopped until explicitly
|
|
115
|
+
started or restarted. Files and artifacts are preserved. This
|
|
116
|
+
cannot be undone.
|
|
117
|
+
</Text>
|
|
118
|
+
|
|
119
|
+
{runningJobs.length > 1 && !defaultJobId && (
|
|
120
|
+
<Box className="mb-4">
|
|
121
|
+
<Text as="p" className="text-sm text-gray-600 mb-2">
|
|
122
|
+
Select which job to stop:
|
|
123
|
+
</Text>
|
|
124
|
+
<Select.Root
|
|
125
|
+
value={selectedJobId}
|
|
126
|
+
onValueChange={setSelectedJobId}
|
|
127
|
+
disabled={isSubmitting}
|
|
128
|
+
>
|
|
129
|
+
<Select.Trigger className="w-full" />
|
|
130
|
+
<Select.Content>
|
|
131
|
+
{runningJobs.map((job) => (
|
|
132
|
+
<Select.Item key={job.id} value={job.id}>
|
|
133
|
+
{job.name}{" "}
|
|
134
|
+
{job.progress !== undefined &&
|
|
135
|
+
`(${Math.round(job.progress)}%)`}
|
|
136
|
+
</Select.Item>
|
|
137
|
+
))}
|
|
138
|
+
</Select.Content>
|
|
139
|
+
</Select.Root>
|
|
140
|
+
</Box>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{selectedJob && (
|
|
144
|
+
<Text as="p" className="text-sm text-blue-600 mb-3">
|
|
145
|
+
<strong>Job to stop:</strong> {selectedJob.name}
|
|
146
|
+
{selectedJob.progress !== undefined &&
|
|
147
|
+
` (${Math.round(selectedJob.progress)}%)`}
|
|
148
|
+
</Text>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
<Text as="p" className="text-sm text-gray-500 italic">
|
|
152
|
+
Note: The job must be currently running to be stopped.
|
|
153
|
+
</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
|
|
156
|
+
{/* Actions */}
|
|
157
|
+
<Flex gap="3" justify="end">
|
|
158
|
+
<Button
|
|
159
|
+
variant="outline"
|
|
160
|
+
onClick={onClose}
|
|
161
|
+
disabled={isSubmitting}
|
|
162
|
+
className="min-w-[80px]"
|
|
163
|
+
>
|
|
164
|
+
Cancel
|
|
165
|
+
</Button>
|
|
166
|
+
|
|
167
|
+
<Button
|
|
168
|
+
variant="destructive"
|
|
169
|
+
onClick={handleConfirm}
|
|
170
|
+
disabled={!selectedJobId || isSubmitting}
|
|
171
|
+
className="min-w-[80px]"
|
|
172
|
+
>
|
|
173
|
+
{isSubmitting ? "Stopping..." : "Stop"}
|
|
174
|
+
</Button>
|
|
175
|
+
</Flex>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default StopJobModal;
|
package/src/core/config.js
CHANGED
|
@@ -515,9 +515,13 @@ export function getConfig() {
|
|
|
515
515
|
Object.keys(currentConfig.pipelines).length === 0
|
|
516
516
|
) {
|
|
517
517
|
const repoRoot = resolveRepoRoot(currentConfig);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
518
|
+
// In test environment, we might start without pipelines and add them later
|
|
519
|
+
// so we just warn instead of throwing, or handle it gracefully
|
|
520
|
+
if (process.env.NODE_ENV !== "test") {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`No pipelines are registered. Create pipeline-config/registry.json in ${repoRoot} to register pipelines.`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
521
525
|
}
|
|
522
526
|
}
|
|
523
527
|
return currentConfig;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static Lifecycle Policy - Pure decision engine for task transitions
|
|
3
|
+
*
|
|
4
|
+
* This module implements centralized lifecycle rules without configuration
|
|
5
|
+
* or runtime toggles, following the principle of explicit failure.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decide if a task transition is allowed based on static lifecycle rules
|
|
10
|
+
* @param {Object} params - Decision parameters
|
|
11
|
+
* @param {string} params.op - Operation: "start" | "restart"
|
|
12
|
+
* @param {string} params.taskState - Current task state
|
|
13
|
+
* @param {boolean} params.dependenciesReady - Whether all upstream dependencies are satisfied
|
|
14
|
+
* @returns {Object} Decision result - { ok: true } | { ok: false, code: "unsupported_lifecycle", reason: "dependencies"|"policy" }
|
|
15
|
+
*/
|
|
16
|
+
export function decideTransition({ op, taskState, dependenciesReady }) {
|
|
17
|
+
// Validate inputs early - let it crash on invalid data
|
|
18
|
+
if (typeof op !== "string" || !["start", "restart"].includes(op)) {
|
|
19
|
+
throw new Error(`Invalid operation: ${op}. Must be "start" or "restart"`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof taskState !== "string") {
|
|
23
|
+
throw new Error(`Invalid taskState: ${taskState}. Must be a string`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof dependenciesReady !== "boolean") {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Invalid dependenciesReady: ${dependenciesReady}. Must be boolean`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Handle start operation
|
|
33
|
+
if (op === "start") {
|
|
34
|
+
if (!dependenciesReady) {
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
ok: false,
|
|
37
|
+
code: "unsupported_lifecycle",
|
|
38
|
+
reason: "dependencies",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({ ok: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle restart operation
|
|
45
|
+
if (op === "restart") {
|
|
46
|
+
if (taskState === "completed") {
|
|
47
|
+
return Object.freeze({ ok: true });
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
ok: false,
|
|
51
|
+
code: "unsupported_lifecycle",
|
|
52
|
+
reason: "policy",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// This should never be reached due to input validation
|
|
57
|
+
return Object.freeze({
|
|
58
|
+
ok: false,
|
|
59
|
+
code: "unsupported_lifecycle",
|
|
60
|
+
reason: "policy",
|
|
61
|
+
});
|
|
62
|
+
}
|
package/src/core/orchestrator.js
CHANGED
|
@@ -154,6 +154,38 @@ export async function startOrchestrator(opts) {
|
|
|
154
154
|
tasks: {}, // Initialize empty tasks object for pipeline runner
|
|
155
155
|
};
|
|
156
156
|
await fs.writeFile(statusPath, JSON.stringify(status, null, 2));
|
|
157
|
+
|
|
158
|
+
// Initialize status from artifacts if any exist
|
|
159
|
+
try {
|
|
160
|
+
const { initializeStatusFromArtifacts } = await import(
|
|
161
|
+
"./status-initializer.js"
|
|
162
|
+
);
|
|
163
|
+
const pipelineConfig = getPipelineConfig(seed?.pipeline || "default");
|
|
164
|
+
const pipelineSnapshot = JSON.parse(
|
|
165
|
+
await fs.readFile(pipelineConfig.pipelineJsonPath, "utf8")
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const applyArtifacts = await initializeStatusFromArtifacts({
|
|
169
|
+
jobDir: workDir,
|
|
170
|
+
pipeline: pipelineSnapshot,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Apply artifact initialization to the status
|
|
174
|
+
const updatedStatus = applyArtifacts(status);
|
|
175
|
+
await fs.writeFile(statusPath, JSON.stringify(updatedStatus, null, 2));
|
|
176
|
+
|
|
177
|
+
logger.log("Initialized status from upload artifacts", {
|
|
178
|
+
jobId,
|
|
179
|
+
pipeline: seed?.pipeline,
|
|
180
|
+
artifactsCount: updatedStatus.files?.artifacts?.length || 0,
|
|
181
|
+
});
|
|
182
|
+
} catch (artifactError) {
|
|
183
|
+
// Don't fail job startup if artifact initialization fails, just log
|
|
184
|
+
logger.warn("Failed to initialize status from artifacts", {
|
|
185
|
+
jobId,
|
|
186
|
+
error: artifactError.message,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
157
189
|
}
|
|
158
190
|
// Create fileIO for orchestrator-level logging
|
|
159
191
|
const fileIO = createTaskFileIO({
|