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