@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,406 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { sseRegistry } from "../sse.js";
5
+ import { initializeJobArtifacts } from "../../core/status-writer.js";
6
+ import {
7
+ resolvePipelinePaths,
8
+ getPendingSeedPath,
9
+ getJobDirectoryPath,
10
+ getJobMetadataPath,
11
+ getJobPipelinePath,
12
+ } from "../../config/paths.js";
13
+ import { generateJobId } from "../../utils/id-generator.js";
14
+ import { extractSeedZip } from "../zip-utils.js";
15
+ import {
16
+ sendJson,
17
+ readRawBody,
18
+ parseMultipartFormData,
19
+ } from "../utils/http-utils.js";
20
+
21
+ // Get __dirname equivalent in ES modules
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = path.dirname(__filename);
24
+
25
+ const DATA_DIR = process.env.PO_ROOT || process.cwd();
26
+
27
+ const exists = async (p) =>
28
+ fs.promises
29
+ .access(p)
30
+ .then(() => true)
31
+ .catch(() => false);
32
+
33
+ /**
34
+ * Normalize seed upload from various input formats
35
+ * @param {http.IncomingMessage} req - HTTP request
36
+ * @param {string} contentTypeHeader - Content-Type header
37
+ * @returns {Promise<{seedObject: Object, uploadArtifacts: Array<{filename: string, content: Buffer}>}>}
38
+ */
39
+ async function normalizeSeedUpload({ req, contentTypeHeader }) {
40
+ // Handle application/json uploads
41
+ if (contentTypeHeader.includes("application/json")) {
42
+ const buffer = await readRawBody(req);
43
+ try {
44
+ const seedObject = JSON.parse(buffer.toString("utf8") || "{}");
45
+ return {
46
+ seedObject,
47
+ uploadArtifacts: [{ filename: "seed.json", content: buffer }],
48
+ };
49
+ } catch (error) {
50
+ throw new Error("Invalid JSON");
51
+ }
52
+ }
53
+
54
+ // Handle multipart form data uploads
55
+ const formData = await parseMultipartFormData(req);
56
+ if (!formData.contentBuffer) {
57
+ throw new Error("No file content found");
58
+ }
59
+
60
+ // Check if this is a zip file
61
+ const isZipFile =
62
+ formData.contentType === "application/zip" ||
63
+ formData.filename?.toLowerCase().endsWith(".zip");
64
+
65
+ if (isZipFile) {
66
+ console.log("[UPLOAD] Detected zip upload", {
67
+ filename: formData.filename,
68
+ contentType: formData.contentType,
69
+ bufferSize: formData.contentBuffer.length,
70
+ });
71
+
72
+ // Handle zip upload
73
+ try {
74
+ const { seedObject, artifacts } = await extractSeedZip(
75
+ formData.contentBuffer
76
+ );
77
+ console.log("[UPLOAD] Zip extraction completed", {
78
+ artifactCount: artifacts.length,
79
+ artifactNames: artifacts.map((a) => a.filename),
80
+ seedKeys: Object.keys(seedObject),
81
+ });
82
+ return {
83
+ seedObject,
84
+ uploadArtifacts: artifacts,
85
+ };
86
+ } catch (error) {
87
+ console.log("[UPLOAD] Zip extraction failed", {
88
+ error: error.message,
89
+ filename: formData.filename,
90
+ });
91
+ // Re-throw zip-specific errors with clear messages
92
+ throw new Error(error.message);
93
+ }
94
+ } else {
95
+ // Handle regular JSON file upload
96
+ try {
97
+ const seedObject = JSON.parse(formData.contentBuffer.toString("utf8"));
98
+ const filename = formData.filename || "seed.json";
99
+ return {
100
+ seedObject,
101
+ uploadArtifacts: [{ filename, content: formData.contentBuffer }],
102
+ };
103
+ } catch (error) {
104
+ throw new Error("Invalid JSON");
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Handle seed file upload
111
+ * @param {http.IncomingMessage} req - HTTP request
112
+ * @param {http.ServerResponse} res - HTTP response
113
+ */
114
+ async function handleSeedUpload(req, res) {
115
+ // Add logging at the very start of the upload handler
116
+ console.log("[UPLOAD] Incoming seed upload", {
117
+ method: req.method,
118
+ url: req.url,
119
+ contentType: req.headers["content-type"],
120
+ userAgent: req.headers["user-agent"],
121
+ });
122
+
123
+ try {
124
+ const ct = req.headers["content-type"] || "";
125
+
126
+ // Use the new normalization function to handle all upload formats
127
+ let normalizedUpload;
128
+ try {
129
+ normalizedUpload = await normalizeSeedUpload({
130
+ req,
131
+ contentTypeHeader: ct,
132
+ });
133
+ } catch (error) {
134
+ console.log("[UPLOAD] Normalization failed", {
135
+ error: error.message,
136
+ contentType: ct,
137
+ });
138
+
139
+ // Handle specific zip-related errors with appropriate messages
140
+ let errorMessage = error.message;
141
+ if (error.message === "Invalid JSON") {
142
+ errorMessage = "Invalid JSON";
143
+ } else if (error.message === "seed.json not found in zip") {
144
+ errorMessage = "seed.json not found in zip";
145
+ }
146
+
147
+ res.writeHead(400, { "Content-Type": "application/json" });
148
+ res.end(JSON.stringify({ success: false, message: errorMessage }));
149
+ return;
150
+ }
151
+
152
+ const { seedObject, uploadArtifacts } = normalizedUpload;
153
+
154
+ // Use current PO_ROOT or fallback to DATA_DIR
155
+ const currentDataDir = process.env.PO_ROOT || DATA_DIR;
156
+
157
+ // For test environment, use simplified validation without starting orchestrator
158
+ if (process.env.NODE_ENV === "test") {
159
+ // Simplified validation for tests - just write to pending directory
160
+ const result = await handleSeedUploadDirect(
161
+ seedObject,
162
+ currentDataDir,
163
+ uploadArtifacts
164
+ );
165
+
166
+ // Return appropriate status code based on success
167
+ if (result.success) {
168
+ res.writeHead(200, {
169
+ "Content-Type": "application/json",
170
+ Connection: "close",
171
+ });
172
+ res.end(JSON.stringify(result));
173
+
174
+ // Broadcast SSE event for successful upload
175
+ sseRegistry.broadcast({
176
+ type: "seed:uploaded",
177
+ data: { name: result.jobName },
178
+ });
179
+ } else {
180
+ res.writeHead(400, {
181
+ "Content-Type": "application/json",
182
+ Connection: "close",
183
+ });
184
+ res.end(JSON.stringify(result));
185
+ }
186
+ return;
187
+ }
188
+
189
+ // Submit job with validation (for production)
190
+ // Dynamically import only in non-test mode
191
+ if (process.env.NODE_ENV !== "test") {
192
+ const { submitJobWithValidation } = await import("../../api/index.js");
193
+ const result = await submitJobWithValidation({
194
+ dataDir: currentDataDir,
195
+ seedObject,
196
+ uploadArtifacts,
197
+ });
198
+
199
+ // Send appropriate response
200
+ if (result.success) {
201
+ res.writeHead(200, { "Content-Type": "application/json" });
202
+ res.end(JSON.stringify(result));
203
+
204
+ // Broadcast SSE event for successful upload
205
+ sseRegistry.broadcast({
206
+ type: "seed:uploaded",
207
+ data: { name: result.jobName },
208
+ });
209
+ } else {
210
+ res.writeHead(400, { "Content-Type": "application/json" });
211
+ res.end(JSON.stringify(result));
212
+ }
213
+ } else {
214
+ // In test mode, we should never reach here, but handle gracefully
215
+ res.writeHead(500, { "Content-Type": "application/json" });
216
+ res.end(
217
+ JSON.stringify({
218
+ success: false,
219
+ message:
220
+ "Test environment error - should not reach production code path",
221
+ })
222
+ );
223
+ }
224
+ } catch (error) {
225
+ console.error("Upload error:", error);
226
+ res.writeHead(500, { "Content-Type": "application/json" });
227
+ res.end(
228
+ JSON.stringify({
229
+ success: false,
230
+ message: "Internal server error",
231
+ })
232
+ );
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Handle seed upload directly without starting orchestrator (for test environment)
238
+ * @param {Object} seedObject - Seed object to upload
239
+ * @param {string} dataDir - Base data directory
240
+ * @param {Array} uploadArtifacts - Array of {filename, content} objects
241
+ * @returns {Promise<Object>} Result object
242
+ */
243
+ async function handleSeedUploadDirect(
244
+ seedObject,
245
+ dataDir,
246
+ uploadArtifacts = []
247
+ ) {
248
+ let partialFiles = [];
249
+
250
+ try {
251
+ // Basic validation
252
+ if (
253
+ !seedObject.name ||
254
+ typeof seedObject.name !== "string" ||
255
+ seedObject.name.trim() === ""
256
+ ) {
257
+ return {
258
+ success: false,
259
+ message: "Required fields missing",
260
+ };
261
+ }
262
+
263
+ const hasValidPayload = (seed) => {
264
+ if (!seed || typeof seed !== "object") return false;
265
+ const hasData = seed.data && typeof seed.data === "object";
266
+ const hasPipelineParams =
267
+ typeof seed.pipeline === "string" &&
268
+ seed.params &&
269
+ typeof seed.params === "object";
270
+ return hasData || hasPipelineParams;
271
+ };
272
+
273
+ if (!hasValidPayload(seedObject)) {
274
+ return { success: false, message: "Required fields missing" };
275
+ }
276
+
277
+ // Validate name format using the same logic as seed validator
278
+ if (
279
+ !seedObject.name ||
280
+ typeof seedObject.name !== "string" ||
281
+ seedObject.name.trim() === ""
282
+ ) {
283
+ return {
284
+ success: false,
285
+ message: "name field is required",
286
+ };
287
+ }
288
+
289
+ const trimmedName = seedObject.name.trim();
290
+ if (trimmedName.length > 120) {
291
+ return {
292
+ success: false,
293
+ message: "name must be 120 characters or less",
294
+ };
295
+ }
296
+
297
+ // Allow spaces and common punctuation for better UX
298
+ // Still disallow control characters and path traversal patterns
299
+ const dangerousPattern = /[\x00-\x1f\x7f-\x9f]/;
300
+ if (dangerousPattern.test(trimmedName)) {
301
+ return {
302
+ success: false,
303
+ message: "name must contain only printable characters",
304
+ };
305
+ }
306
+
307
+ // Update seedObject with validated trimmed name
308
+ seedObject.name = trimmedName;
309
+
310
+ // Generate a random job ID
311
+ const jobId = generateJobId();
312
+
313
+ // Get the paths
314
+ const paths = resolvePipelinePaths(dataDir);
315
+ const pendingPath = getPendingSeedPath(dataDir, jobId);
316
+ const currentJobDir = getJobDirectoryPath(dataDir, jobId, "current");
317
+ const jobMetadataPath = getJobMetadataPath(dataDir, jobId, "current");
318
+ const jobPipelinePath = getJobPipelinePath(dataDir, jobId, "current");
319
+
320
+ // Ensure directories exist
321
+ await fs.promises.mkdir(paths.pending, { recursive: true });
322
+ await fs.promises.mkdir(currentJobDir, { recursive: true });
323
+
324
+ // Create job metadata
325
+ const jobMetadata = {
326
+ id: jobId,
327
+ name: seedObject.name,
328
+ pipeline: seedObject.pipeline || "default",
329
+ createdAt: new Date().toISOString(),
330
+ status: "pending",
331
+ };
332
+
333
+ // Read pipeline configuration for snapshot
334
+ let pipelineSnapshot = null;
335
+ try {
336
+ const pipelineConfigPath = path.join(
337
+ dataDir,
338
+ "pipeline-config",
339
+ "pipeline.json"
340
+ );
341
+ const pipelineContent = await fs.promises.readFile(
342
+ pipelineConfigPath,
343
+ "utf8"
344
+ );
345
+ pipelineSnapshot = JSON.parse(pipelineContent);
346
+ } catch (error) {
347
+ // If pipeline config doesn't exist, create a minimal snapshot
348
+ pipelineSnapshot = {
349
+ tasks: [],
350
+ name: seedObject.pipeline || "default",
351
+ };
352
+ }
353
+
354
+ // Write files atomically
355
+ partialFiles.push(pendingPath);
356
+ await fs.promises.writeFile(
357
+ pendingPath,
358
+ JSON.stringify(seedObject, null, 2)
359
+ );
360
+
361
+ partialFiles.push(jobMetadataPath);
362
+ await fs.promises.writeFile(
363
+ jobMetadataPath,
364
+ JSON.stringify(jobMetadata, null, 2)
365
+ );
366
+
367
+ partialFiles.push(jobPipelinePath);
368
+ await fs.promises.writeFile(
369
+ jobPipelinePath,
370
+ JSON.stringify(pipelineSnapshot, null, 2)
371
+ );
372
+
373
+ // Initialize job artifacts if any provided
374
+ if (uploadArtifacts.length > 0) {
375
+ try {
376
+ await initializeJobArtifacts(currentJobDir, uploadArtifacts);
377
+ } catch (artifactError) {
378
+ // Don't fail the upload if artifact initialization fails, just log the error
379
+ console.error("Failed to initialize job artifacts:", artifactError);
380
+ }
381
+ }
382
+
383
+ return {
384
+ success: true,
385
+ jobId,
386
+ jobName: seedObject.name,
387
+ message: "Seed file uploaded successfully",
388
+ };
389
+ } catch (error) {
390
+ // Clean up any partial files on failure
391
+ for (const filePath of partialFiles) {
392
+ try {
393
+ await fs.promises.unlink(filePath);
394
+ } catch (cleanupError) {
395
+ // Ignore cleanup errors
396
+ }
397
+ }
398
+
399
+ return {
400
+ success: false,
401
+ message: error.message || "Internal server error",
402
+ };
403
+ }
404
+ }
405
+
406
+ export { handleSeedUpload, normalizeSeedUpload, handleSeedUploadDirect };
@@ -0,0 +1,182 @@
1
+ import express from "express";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { sseRegistry } from "./sse.js";
5
+ import { handleApiState } from "./endpoints/state-endpoint.js";
6
+ import { handleSeedUpload } from "./endpoints/upload-endpoints.js";
7
+ import {
8
+ handleJobListRequest,
9
+ handleJobDetailRequest,
10
+ } from "./endpoints/job-endpoints.js";
11
+ import {
12
+ handleJobRescan,
13
+ handleJobRestart,
14
+ handleJobStop,
15
+ handleTaskStart,
16
+ } from "./endpoints/job-control-endpoints.js";
17
+ import {
18
+ handleTaskFileListRequest,
19
+ handleTaskFileRequest,
20
+ } from "./endpoints/file-endpoints.js";
21
+ import { sendJson } from "./utils/http-utils.js";
22
+ import { PROVIDER_FUNCTIONS } from "../config/models.js";
23
+
24
+ // Get __dirname equivalent in ES modules
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+
28
+ /**
29
+ * Build Express application with API routes, SSE, and static serving
30
+ * @param {Object} params - Configuration parameters
31
+ * @param {string} params.dataDir - Base data directory
32
+ * @param {Object} [params.viteServer] - Vite dev server instance (optional)
33
+ * @returns {express.Application} Configured Express app
34
+ */
35
+ export function buildExpressApp({ dataDir, viteServer }) {
36
+ const app = express();
37
+
38
+ // API guard middleware mounted on /api
39
+ app.use("/api", (req, res, next) => {
40
+ // Set CORS headers
41
+ res.setHeader("Access-Control-Allow-Origin", "*");
42
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
43
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
44
+
45
+ // Handle OPTIONS preflight
46
+ if (req.method === "OPTIONS") {
47
+ return res.status(204).end();
48
+ }
49
+
50
+ // Set Connection: close for non-SSE requests
51
+ const isSSE = req.path === "/events" || req.path === "/sse";
52
+ if (!isSSE) {
53
+ res.setHeader("Connection", "close");
54
+ }
55
+
56
+ next();
57
+ });
58
+
59
+ // SSE routes
60
+ app.get(["/api/events", "/api/sse"], (req, res) => {
61
+ // Set SSE headers
62
+ res.setHeader("Content-Type", "text/event-stream");
63
+ res.setHeader("Cache-Control", "no-cache");
64
+ res.setHeader("Connection", "keep-alive");
65
+ res.setHeader("X-Accel-Buffering", "no");
66
+
67
+ // Flush headers if available
68
+ if (typeof res.flushHeaders === "function") {
69
+ res.flushHeaders();
70
+ }
71
+
72
+ const jobId = req.query.jobId;
73
+ sseRegistry.addClient(res, { jobId });
74
+
75
+ req.on("close", () => {
76
+ sseRegistry.removeClient(res);
77
+ });
78
+ });
79
+
80
+ // REST routes
81
+
82
+ // GET /api/state
83
+ app.get("/api/state", async (req, res) => {
84
+ await handleApiState(req, res);
85
+ });
86
+
87
+ // POST /api/upload/seed
88
+ app.post("/api/upload/seed", async (req, res) => {
89
+ await handleSeedUpload(req, res);
90
+ });
91
+
92
+ // GET /api/llm/functions
93
+ app.get("/api/llm/functions", (req, res) => {
94
+ try {
95
+ sendJson(res, 200, { ok: true, data: PROVIDER_FUNCTIONS });
96
+ } catch (error) {
97
+ console.error("Error serving LLM functions:", error);
98
+ sendJson(res, 500, {
99
+ ok: false,
100
+ error: "internal_error",
101
+ message: "Failed to load LLM functions",
102
+ });
103
+ }
104
+ });
105
+
106
+ // GET /api/jobs
107
+ app.get("/api/jobs", async (req, res) => {
108
+ await handleJobListRequest(req, res);
109
+ });
110
+
111
+ // GET /api/jobs/:jobId
112
+ app.get("/api/jobs/:jobId", async (req, res) => {
113
+ const { jobId } = req.params;
114
+ await handleJobDetailRequest(req, res, jobId);
115
+ });
116
+
117
+ // POST /api/jobs/:jobId/rescan
118
+ app.post("/api/jobs/:jobId/rescan", async (req, res) => {
119
+ const { jobId } = req.params;
120
+ await handleJobRescan(req, res, jobId, dataDir, sendJson);
121
+ });
122
+
123
+ // POST /api/jobs/:jobId/restart
124
+ app.post("/api/jobs/:jobId/restart", async (req, res) => {
125
+ const { jobId } = req.params;
126
+ await handleJobRestart(req, res, jobId, dataDir, sendJson);
127
+ });
128
+
129
+ // POST /api/jobs/:jobId/stop
130
+ app.post("/api/jobs/:jobId/stop", async (req, res) => {
131
+ const { jobId } = req.params;
132
+ await handleJobStop(req, res, jobId, dataDir, sendJson);
133
+ });
134
+
135
+ // POST /api/jobs/:jobId/tasks/:taskId/start
136
+ app.post("/api/jobs/:jobId/tasks/:taskId/start", async (req, res) => {
137
+ const { jobId, taskId } = req.params;
138
+ await handleTaskStart(req, res, jobId, taskId, dataDir, sendJson);
139
+ });
140
+
141
+ // GET /api/jobs/:jobId/tasks/:taskId/files
142
+ app.get("/api/jobs/:jobId/tasks/:taskId/files", async (req, res) => {
143
+ const { jobId, taskId } = req.params;
144
+ const { type } = req.query;
145
+ await handleTaskFileListRequest(req, res, {
146
+ jobId,
147
+ taskId,
148
+ type,
149
+ dataDir,
150
+ });
151
+ });
152
+
153
+ // GET /api/jobs/:jobId/tasks/:taskId/file
154
+ app.get("/api/jobs/:jobId/tasks/:taskId/file", async (req, res) => {
155
+ const { jobId, taskId } = req.params;
156
+ const { type, filename } = req.query;
157
+ await handleTaskFileRequest(req, res, {
158
+ jobId,
159
+ taskId,
160
+ type,
161
+ filename,
162
+ dataDir,
163
+ });
164
+ });
165
+
166
+ // Dev middleware (mount after all API routes)
167
+ if (viteServer && viteServer.middlewares) {
168
+ app.use(viteServer.middlewares);
169
+ } else {
170
+ // Production static serving
171
+ app.use("/public", express.static(path.join(__dirname, "public")));
172
+ app.use("/assets", express.static(path.join(__dirname, "dist", "assets")));
173
+ app.use(express.static(path.join(__dirname, "dist")));
174
+
175
+ // SPA fallback
176
+ app.get("*", (_, res) => {
177
+ res.sendFile(path.join(__dirname, "dist", "index.html"));
178
+ });
179
+ }
180
+
181
+ return app;
182
+ }