@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
|
@@ -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 };
|