@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
@@ -0,0 +1,101 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { getConfig } from "../../core/config.js";
4
+ import { sendJson } from "../utils/http-utils.js";
5
+
6
+ /**
7
+ * Handle task creation requests
8
+ *
9
+ * POST /api/tasks/create
10
+ * Body: { pipelineSlug, filename, taskName, code }
11
+ *
12
+ * Creates a new task file and updates the pipeline's task registry index.js
13
+ */
14
+ export async function handleTaskSave(req, res) {
15
+ try {
16
+ const { pipelineSlug, filename, taskName, code } = req.body;
17
+
18
+ if (!pipelineSlug) {
19
+ return sendJson(res, 400, { error: "pipelineSlug is required" });
20
+ }
21
+ // Validate filename ends with .js
22
+ if (!filename || !filename.endsWith(".js")) {
23
+ return sendJson(res, 400, { error: "Filename must end with .js" });
24
+ }
25
+
26
+ // Validate taskName is kebab-case
27
+ const kebabCaseRegex = /^[a-z][a-z0-9-]*$/;
28
+ if (!taskName || !kebabCaseRegex.test(taskName)) {
29
+ return sendJson(res, 400, { error: "TaskName must be kebab-case" });
30
+ }
31
+
32
+ // Get configuration and root directory
33
+ const config = getConfig();
34
+ const rootDir = config.paths.root;
35
+
36
+ // Read registry.json to find pipeline's taskRegistryPath
37
+ const registryPath = path.join(rootDir, "pipeline-config", "registry.json");
38
+ const registryData = JSON.parse(await fs.readFile(registryPath, "utf8"));
39
+
40
+ // Look up pipeline in registry
41
+ const pipelineEntry = registryData.pipelines[pipelineSlug];
42
+ if (!pipelineEntry) {
43
+ return sendJson(res, 404, { error: "Pipeline not found" });
44
+ }
45
+
46
+ // Get task registry path (relative to root)
47
+ const taskRegistryPath = path.join(rootDir, pipelineEntry.taskRegistryPath);
48
+ const tasksDir = path.dirname(taskRegistryPath);
49
+
50
+ // Write task file (prevent path traversal by validating resolved path)
51
+ const taskFilePath = path.resolve(tasksDir, filename);
52
+ if (!taskFilePath.startsWith(tasksDir)) {
53
+ return sendJson(res, 400, { error: "Invalid filename" });
54
+ }
55
+ await fs.writeFile(taskFilePath, code, "utf8");
56
+
57
+ // Update index.js to export new task
58
+ const indexPath = taskRegistryPath;
59
+ let indexContent = await fs.readFile(indexPath, "utf8");
60
+
61
+ // Check if task name already exists in the index
62
+ const taskNamePattern = new RegExp(`^\\s*${taskName}\\s*:`, "m");
63
+ if (taskNamePattern.test(indexContent)) {
64
+ return sendJson(res, 400, {
65
+ error: `Task "${taskName}" already exists in the registry`,
66
+ });
67
+ }
68
+
69
+ // Find the line containing "export default {"
70
+ const exportLine = "export default {";
71
+ const exportLineIndex = indexContent.indexOf(exportLine);
72
+
73
+ if (exportLineIndex === -1) {
74
+ return sendJson(res, 500, {
75
+ error: "Failed to find export default line in index.js",
76
+ });
77
+ }
78
+
79
+ // Insert new task entry after the export line
80
+ const insertPosition = indexContent.indexOf("\n", exportLineIndex) + 1;
81
+ const newEntry = ` ${taskName}: "./${filename}",\n`;
82
+
83
+ indexContent =
84
+ indexContent.slice(0, insertPosition) +
85
+ newEntry +
86
+ indexContent.slice(insertPosition);
87
+
88
+ // Write updated index.js
89
+ await fs.writeFile(indexPath, indexContent, "utf8");
90
+
91
+ return sendJson(res, 200, {
92
+ ok: true,
93
+ path: taskFilePath,
94
+ });
95
+ } catch (error) {
96
+ console.error("Error creating task:", error);
97
+ return sendJson(res, 500, {
98
+ error: error.message || "Failed to create task",
99
+ });
100
+ }
101
+ }
@@ -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 };