@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.4.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/README.md +415 -24
- package/package.json +46 -8
- package/src/api/files.js +48 -0
- package/src/api/index.js +149 -53
- package/src/api/validators/seed.js +141 -0
- package/src/cli/index.js +444 -29
- package/src/cli/run-orchestrator.js +39 -0
- package/src/cli/update-pipeline-json.js +47 -0
- package/src/components/DAGGrid.jsx +649 -0
- package/src/components/JobCard.jsx +96 -0
- package/src/components/JobDetail.jsx +159 -0
- package/src/components/JobTable.jsx +202 -0
- package/src/components/Layout.jsx +134 -0
- package/src/components/TaskFilePane.jsx +570 -0
- package/src/components/UploadSeed.jsx +239 -0
- package/src/components/ui/badge.jsx +20 -0
- package/src/components/ui/button.jsx +43 -0
- package/src/components/ui/card.jsx +20 -0
- package/src/components/ui/focus-styles.css +60 -0
- package/src/components/ui/progress.jsx +26 -0
- package/src/components/ui/select.jsx +27 -0
- package/src/components/ui/separator.jsx +6 -0
- package/src/config/paths.js +99 -0
- package/src/core/config.js +270 -9
- package/src/core/file-io.js +202 -0
- package/src/core/module-loader.js +157 -0
- package/src/core/orchestrator.js +275 -294
- package/src/core/pipeline-runner.js +95 -41
- package/src/core/progress.js +66 -0
- package/src/core/status-writer.js +331 -0
- package/src/core/task-runner.js +719 -73
- package/src/core/validation.js +120 -1
- package/src/lib/utils.js +6 -0
- package/src/llm/README.md +139 -30
- package/src/llm/index.js +222 -72
- package/src/pages/PipelineDetail.jsx +111 -0
- package/src/pages/PromptPipelineDashboard.jsx +223 -0
- package/src/providers/deepseek.js +3 -15
- package/src/ui/client/adapters/job-adapter.js +258 -0
- package/src/ui/client/bootstrap.js +120 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
- package/src/ui/client/hooks/useJobList.js +50 -0
- package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
- package/src/ui/client/hooks/useTicker.js +26 -0
- package/src/ui/client/index.css +31 -0
- package/src/ui/client/index.html +18 -0
- package/src/ui/client/main.jsx +38 -0
- package/src/ui/config-bridge.browser.js +149 -0
- package/src/ui/config-bridge.js +149 -0
- package/src/ui/config-bridge.node.js +310 -0
- package/src/ui/dist/assets/index-CxcrauYR.js +22702 -0
- package/src/ui/dist/assets/style-D6K_oQ12.css +62 -0
- package/src/ui/dist/index.html +19 -0
- package/src/ui/endpoints/job-endpoints.js +300 -0
- package/src/ui/file-reader.js +216 -0
- package/src/ui/job-change-detector.js +83 -0
- package/src/ui/job-index.js +231 -0
- package/src/ui/job-reader.js +274 -0
- package/src/ui/job-scanner.js +188 -0
- package/src/ui/public/app.js +3 -1
- package/src/ui/server.js +1636 -59
- package/src/ui/sse-enhancer.js +149 -0
- package/src/ui/sse.js +204 -0
- package/src/ui/state-snapshot.js +252 -0
- package/src/ui/transformers/list-transformer.js +347 -0
- package/src/ui/transformers/status-transformer.js +307 -0
- package/src/ui/watcher.js +61 -7
- package/src/utils/dag.js +101 -0
- package/src/utils/duration.js +126 -0
- package/src/utils/id-generator.js +30 -0
- package/src/utils/jobs.js +7 -0
- package/src/utils/pipelines.js +44 -0
- package/src/utils/task-files.js +271 -0
- package/src/utils/ui.jsx +76 -0
- package/src/ui/public/index.html +0 -53
- package/src/ui/public/style.css +0 -341
package/src/ui/server.js
CHANGED
|
@@ -6,49 +6,328 @@
|
|
|
6
6
|
import http from "http";
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
|
-
import url from "url";
|
|
10
9
|
import { fileURLToPath } from "url";
|
|
11
10
|
import { start as startWatcher, stop as stopWatcher } from "./watcher.js";
|
|
12
11
|
import * as state from "./state.js";
|
|
12
|
+
// Import orchestrator-related functions only in non-test mode
|
|
13
|
+
let submitJobWithValidation;
|
|
14
|
+
import { sseRegistry } from "./sse.js";
|
|
15
|
+
import {
|
|
16
|
+
getPendingSeedPath,
|
|
17
|
+
resolvePipelinePaths,
|
|
18
|
+
getJobDirectoryPath,
|
|
19
|
+
getJobMetadataPath,
|
|
20
|
+
getJobPipelinePath,
|
|
21
|
+
} from "../config/paths.js";
|
|
22
|
+
import { handleJobList, handleJobDetail } from "./endpoints/job-endpoints.js";
|
|
23
|
+
import { generateJobId } from "../utils/id-generator.js";
|
|
13
24
|
|
|
14
25
|
// Get __dirname equivalent in ES modules
|
|
15
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
27
|
const __dirname = path.dirname(__filename);
|
|
17
28
|
|
|
29
|
+
// Vite dev server instance (populated in development mode)
|
|
30
|
+
let viteServer = null;
|
|
31
|
+
|
|
18
32
|
// Configuration
|
|
19
33
|
const PORT = process.env.PORT || 4000;
|
|
20
|
-
const WATCHED_PATHS = (
|
|
34
|
+
const WATCHED_PATHS = (
|
|
35
|
+
process.env.WATCHED_PATHS ||
|
|
36
|
+
(process.env.NODE_ENV === "test"
|
|
37
|
+
? "pipeline-config,runs"
|
|
38
|
+
: "pipeline-config,pipeline-data,runs")
|
|
39
|
+
)
|
|
21
40
|
.split(",")
|
|
22
41
|
.map((p) => p.trim());
|
|
23
42
|
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
|
24
|
-
|
|
25
|
-
// SSE clients management
|
|
26
|
-
const sseClients = new Set();
|
|
27
|
-
let heartbeatTimer = null;
|
|
43
|
+
const DATA_DIR = process.env.PO_ROOT || process.cwd();
|
|
28
44
|
|
|
29
45
|
/**
|
|
30
|
-
*
|
|
46
|
+
* Resolve job lifecycle directory deterministically
|
|
47
|
+
* @param {string} dataDir - Base data directory
|
|
48
|
+
* @param {string} jobId - Job identifier
|
|
49
|
+
* @returns {Promise<string|null>} One of "current", "complete", "rejected", or null if job not found
|
|
31
50
|
*/
|
|
32
|
-
function
|
|
33
|
-
|
|
51
|
+
async function resolveJobLifecycle(dataDir, jobId) {
|
|
52
|
+
const currentJobDir = getJobDirectoryPath(dataDir, jobId, "current");
|
|
53
|
+
const completeJobDir = getJobDirectoryPath(dataDir, jobId, "complete");
|
|
54
|
+
const rejectedJobDir = getJobDirectoryPath(dataDir, jobId, "rejected");
|
|
55
|
+
|
|
56
|
+
// Check in order of preference: current > complete > rejected
|
|
57
|
+
if (await exists(currentJobDir)) {
|
|
58
|
+
return "current";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (await exists(completeJobDir)) {
|
|
62
|
+
return "complete";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (await exists(rejectedJobDir)) {
|
|
66
|
+
return "rejected";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Job not found in any lifecycle
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasValidPayload(seed) {
|
|
74
|
+
if (!seed || typeof seed !== "object") return false;
|
|
75
|
+
const hasData = seed.data && typeof seed.data === "object";
|
|
76
|
+
const hasPipelineParams =
|
|
77
|
+
typeof seed.pipeline === "string" &&
|
|
78
|
+
seed.params &&
|
|
79
|
+
typeof seed.params === "object";
|
|
80
|
+
return hasData || hasPipelineParams;
|
|
34
81
|
}
|
|
35
82
|
|
|
36
83
|
/**
|
|
37
|
-
*
|
|
84
|
+
* Handle seed upload directly without starting orchestrator (for test environment)
|
|
85
|
+
* @param {Object} seedObject - Seed object to upload
|
|
86
|
+
* @param {string} dataDir - Base data directory
|
|
87
|
+
* @returns {Promise<Object>} Result object
|
|
38
88
|
*/
|
|
39
|
-
function
|
|
40
|
-
|
|
89
|
+
async function handleSeedUploadDirect(seedObject, dataDir) {
|
|
90
|
+
let partialFiles = [];
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Basic validation
|
|
94
|
+
if (
|
|
95
|
+
!seedObject.name ||
|
|
96
|
+
typeof seedObject.name !== "string" ||
|
|
97
|
+
seedObject.name.trim() === ""
|
|
98
|
+
) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
message: "Required fields missing",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!hasValidPayload(seedObject)) {
|
|
106
|
+
return { success: false, message: "Required fields missing" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Validate name format
|
|
110
|
+
const nameRegex = /^[a-zA-Z0-9_-]+$/;
|
|
111
|
+
if (!nameRegex.test(seedObject.name)) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
message:
|
|
115
|
+
"name must contain only alphanumeric characters, hyphens, and underscores",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Generate a random job ID
|
|
120
|
+
const jobId = generateJobId();
|
|
121
|
+
|
|
122
|
+
// Get the paths
|
|
123
|
+
const paths = resolvePipelinePaths(dataDir);
|
|
124
|
+
const pendingPath = getPendingSeedPath(dataDir, jobId);
|
|
125
|
+
const currentJobDir = getJobDirectoryPath(dataDir, jobId, "current");
|
|
126
|
+
const jobMetadataPath = getJobMetadataPath(dataDir, jobId, "current");
|
|
127
|
+
const jobPipelinePath = getJobPipelinePath(dataDir, jobId, "current");
|
|
41
128
|
|
|
42
|
-
|
|
129
|
+
// Ensure directories exist
|
|
130
|
+
await fs.promises.mkdir(paths.pending, { recursive: true });
|
|
131
|
+
await fs.promises.mkdir(currentJobDir, { recursive: true });
|
|
132
|
+
|
|
133
|
+
// Create job metadata
|
|
134
|
+
const jobMetadata = {
|
|
135
|
+
id: jobId,
|
|
136
|
+
name: seedObject.name,
|
|
137
|
+
pipeline: seedObject.pipeline || "default",
|
|
138
|
+
createdAt: new Date().toISOString(),
|
|
139
|
+
status: "pending",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Read pipeline configuration for snapshot
|
|
143
|
+
let pipelineSnapshot = null;
|
|
43
144
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
145
|
+
const pipelineConfigPath = path.join(
|
|
146
|
+
dataDir,
|
|
147
|
+
"pipeline-config",
|
|
148
|
+
"pipeline.json"
|
|
149
|
+
);
|
|
150
|
+
const pipelineContent = await fs.promises.readFile(
|
|
151
|
+
pipelineConfigPath,
|
|
152
|
+
"utf8"
|
|
153
|
+
);
|
|
154
|
+
pipelineSnapshot = JSON.parse(pipelineContent);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// If pipeline config doesn't exist, create a minimal snapshot
|
|
157
|
+
pipelineSnapshot = {
|
|
158
|
+
tasks: [],
|
|
159
|
+
name: seedObject.pipeline || "default",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Write files atomically
|
|
164
|
+
partialFiles.push(pendingPath);
|
|
165
|
+
await fs.promises.writeFile(
|
|
166
|
+
pendingPath,
|
|
167
|
+
JSON.stringify(seedObject, null, 2)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
partialFiles.push(jobMetadataPath);
|
|
171
|
+
await fs.promises.writeFile(
|
|
172
|
+
jobMetadataPath,
|
|
173
|
+
JSON.stringify(jobMetadata, null, 2)
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
partialFiles.push(jobPipelinePath);
|
|
177
|
+
await fs.promises.writeFile(
|
|
178
|
+
jobPipelinePath,
|
|
179
|
+
JSON.stringify(pipelineSnapshot, null, 2)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
success: true,
|
|
184
|
+
jobId,
|
|
185
|
+
jobName: seedObject.name,
|
|
186
|
+
message: "Seed file uploaded successfully",
|
|
187
|
+
};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// Clean up any partial files on failure
|
|
190
|
+
for (const filePath of partialFiles) {
|
|
191
|
+
try {
|
|
192
|
+
await fs.promises.unlink(filePath);
|
|
193
|
+
} catch (cleanupError) {
|
|
194
|
+
// Ignore cleanup errors
|
|
195
|
+
}
|
|
47
196
|
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
message: error.message || "Internal server error",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// SSE clients management
|
|
206
|
+
let heartbeatTimer = null;
|
|
207
|
+
|
|
208
|
+
// Helper functions for consistent API responses
|
|
209
|
+
const sendJson = (res, code, obj) => {
|
|
210
|
+
res.writeHead(code, {
|
|
211
|
+
"content-type": "application/json",
|
|
212
|
+
connection: "close",
|
|
48
213
|
});
|
|
214
|
+
res.end(JSON.stringify(obj));
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const exists = async (p) =>
|
|
218
|
+
fs.promises
|
|
219
|
+
.access(p)
|
|
220
|
+
.then(() => true)
|
|
221
|
+
.catch(() => false);
|
|
222
|
+
|
|
223
|
+
async function readRawBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
224
|
+
// 2MB guard
|
|
225
|
+
const chunks = [];
|
|
226
|
+
let total = 0;
|
|
227
|
+
for await (const chunk of req) {
|
|
228
|
+
total += chunk.length;
|
|
229
|
+
if (total > maxBytes) throw new Error("Payload too large");
|
|
230
|
+
chunks.push(chunk);
|
|
231
|
+
}
|
|
232
|
+
return Buffer.concat(chunks);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function extractJsonFromMultipart(raw, contentType) {
|
|
236
|
+
const m = /boundary=([^;]+)/i.exec(contentType || "");
|
|
237
|
+
if (!m) throw new Error("Missing multipart boundary");
|
|
238
|
+
const boundary = `--${m[1]}`;
|
|
239
|
+
const parts = raw.toString("utf8").split(boundary);
|
|
240
|
+
const filePart = parts.find((p) => /name="file"/i.test(p));
|
|
241
|
+
if (!filePart) throw new Error("Missing file part");
|
|
242
|
+
const [, , body] = filePart.split(/\r\n\r\n/);
|
|
243
|
+
if (!body) throw new Error("Empty file part");
|
|
244
|
+
// strip trailing CRLF + terminating dashes
|
|
245
|
+
return body.replace(/\r\n--\s*$/, "").trim();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Broadcast state update to all SSE clients
|
|
250
|
+
*
|
|
251
|
+
* NOTE: Per plan, SSE should emit compact, incremental events rather than
|
|
252
|
+
* streaming the full application state. Use /api/state for full snapshot
|
|
253
|
+
* retrieval on client bootstrap. This function will emit only the most
|
|
254
|
+
* recent change when available (type: "state:change") and fall back to a
|
|
255
|
+
* lightweight summary event if no recent change is present.
|
|
256
|
+
*/
|
|
257
|
+
function decorateChangeWithJobId(change) {
|
|
258
|
+
if (!change || typeof change !== "object") return change;
|
|
259
|
+
const normalizedPath = String(change.path || "").replace(/\\/g, "/");
|
|
260
|
+
const match = normalizedPath.match(
|
|
261
|
+
/pipeline-data\/(current|complete|pending|rejected)\/([^/]+)/
|
|
262
|
+
);
|
|
263
|
+
if (!match) {
|
|
264
|
+
return change;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
...change,
|
|
268
|
+
lifecycle: match[1],
|
|
269
|
+
jobId: match[2],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
49
272
|
|
|
50
|
-
|
|
51
|
-
|
|
273
|
+
function prioritizeJobStatusChange(changes = []) {
|
|
274
|
+
const normalized = changes.map((change) => decorateChangeWithJobId(change));
|
|
275
|
+
const statusChange = normalized.find(
|
|
276
|
+
(change) =>
|
|
277
|
+
typeof change?.path === "string" &&
|
|
278
|
+
/tasks-status\.json$/.test(change.path)
|
|
279
|
+
);
|
|
280
|
+
return statusChange || normalized[0] || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function broadcastStateUpdate(currentState) {
|
|
284
|
+
try {
|
|
285
|
+
const recentChanges = (currentState && currentState.recentChanges) || [];
|
|
286
|
+
const latest = prioritizeJobStatusChange(recentChanges);
|
|
287
|
+
console.debug("[Server] Broadcasting state update:", {
|
|
288
|
+
latest,
|
|
289
|
+
currentState,
|
|
290
|
+
});
|
|
291
|
+
if (latest) {
|
|
292
|
+
// Emit only the most recent change as a compact, typed event
|
|
293
|
+
const eventData = { type: "state:change", data: latest };
|
|
294
|
+
console.debug("[Server] Broadcasting event:", eventData);
|
|
295
|
+
sseRegistry.broadcast(eventData);
|
|
296
|
+
} else {
|
|
297
|
+
// Fallback: emit a minimal summary so clients can observe a state "tick"
|
|
298
|
+
const eventData = {
|
|
299
|
+
type: "state:summary",
|
|
300
|
+
data: {
|
|
301
|
+
changeCount:
|
|
302
|
+
currentState && currentState.changeCount
|
|
303
|
+
? currentState.changeCount
|
|
304
|
+
: 0,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
console.debug("[Server] Broadcasting summary event:", eventData);
|
|
308
|
+
sseRegistry.broadcast(eventData);
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
// Defensive: if something unexpected happens, fall back to a lightweight notification
|
|
312
|
+
try {
|
|
313
|
+
console.error("[Server] Error in broadcastStateUpdate:", err);
|
|
314
|
+
sseRegistry.broadcast({
|
|
315
|
+
type: "state:summary",
|
|
316
|
+
data: {
|
|
317
|
+
changeCount:
|
|
318
|
+
currentState && currentState.changeCount
|
|
319
|
+
? currentState.changeCount
|
|
320
|
+
: 0,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
} catch (fallbackErr) {
|
|
324
|
+
// Log the error to aid debugging; this should never happen unless sseRegistry.broadcast is broken
|
|
325
|
+
console.error(
|
|
326
|
+
"Failed to broadcast fallback state summary in broadcastStateUpdate:",
|
|
327
|
+
fallbackErr
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
52
331
|
}
|
|
53
332
|
|
|
54
333
|
/**
|
|
@@ -58,22 +337,705 @@ function startHeartbeat() {
|
|
|
58
337
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
59
338
|
|
|
60
339
|
heartbeatTimer = setInterval(() => {
|
|
61
|
-
|
|
340
|
+
sseRegistry.broadcast({
|
|
341
|
+
type: "heartbeat",
|
|
342
|
+
data: { timestamp: Date.now() },
|
|
343
|
+
});
|
|
344
|
+
}, HEARTBEAT_INTERVAL);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Parse multipart form data
|
|
349
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
350
|
+
* @returns {Promise<Object>} Parsed form data with file content
|
|
351
|
+
*/
|
|
352
|
+
function parseMultipartFormData(req) {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
const chunks = [];
|
|
355
|
+
let boundary = null;
|
|
356
|
+
|
|
357
|
+
// Extract boundary from content-type header
|
|
358
|
+
const contentType = req.headers["content-type"];
|
|
359
|
+
if (!contentType || !contentType.includes("multipart/form-data")) {
|
|
360
|
+
reject(new Error("Invalid content-type: expected multipart/form-data"));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
|
365
|
+
if (!boundaryMatch) {
|
|
366
|
+
reject(new Error("Missing boundary in content-type"));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
boundary = `--${boundaryMatch[1].trim()}`;
|
|
371
|
+
|
|
372
|
+
req.on("data", (chunk) => {
|
|
373
|
+
chunks.push(chunk);
|
|
374
|
+
});
|
|
62
375
|
|
|
63
|
-
|
|
376
|
+
req.on("end", () => {
|
|
64
377
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
378
|
+
const buffer = Buffer.concat(chunks);
|
|
379
|
+
const data = buffer.toString("utf8");
|
|
380
|
+
console.log("Raw multipart data length:", data.length);
|
|
381
|
+
console.log("Boundary:", JSON.stringify(boundary));
|
|
382
|
+
|
|
383
|
+
// Simple multipart parsing - look for file field
|
|
384
|
+
const parts = data.split(boundary);
|
|
385
|
+
console.log("Number of parts:", parts.length);
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i < parts.length; i++) {
|
|
388
|
+
const part = parts[i];
|
|
389
|
+
console.log(`Part ${i} length:`, part.length);
|
|
390
|
+
console.log(
|
|
391
|
+
`Part ${i} starts with:`,
|
|
392
|
+
JSON.stringify(part.substring(0, 50))
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
if (part.includes('name="file"') && part.includes("filename")) {
|
|
396
|
+
console.log("Found file part at index", i);
|
|
397
|
+
// Extract filename
|
|
398
|
+
const filenameMatch = part.match(/filename="([^"]+)"/);
|
|
399
|
+
console.log("Filename match:", filenameMatch);
|
|
400
|
+
if (!filenameMatch) continue;
|
|
401
|
+
|
|
402
|
+
// Extract content type
|
|
403
|
+
const contentTypeMatch = part.match(/Content-Type:\s*([^\r\n]+)/);
|
|
404
|
+
console.log("Content-Type match:", contentTypeMatch);
|
|
405
|
+
|
|
406
|
+
// Extract file content (everything after the headers)
|
|
407
|
+
const contentStart = part.indexOf("\r\n\r\n") + 4;
|
|
408
|
+
const contentEnd = part.lastIndexOf("\r\n");
|
|
409
|
+
console.log(
|
|
410
|
+
"Content start:",
|
|
411
|
+
contentStart,
|
|
412
|
+
"Content end:",
|
|
413
|
+
contentEnd
|
|
414
|
+
);
|
|
415
|
+
const fileContent = part.substring(contentStart, contentEnd);
|
|
416
|
+
console.log("File content length:", fileContent.length);
|
|
417
|
+
console.log(
|
|
418
|
+
"File content:",
|
|
419
|
+
JSON.stringify(fileContent.substring(0, 100))
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
resolve({
|
|
423
|
+
filename: filenameMatch[1],
|
|
424
|
+
contentType: contentTypeMatch
|
|
425
|
+
? contentTypeMatch[1]
|
|
426
|
+
: "application/octet-stream",
|
|
427
|
+
content: fileContent,
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
console.log("No file field found in form data");
|
|
434
|
+
reject(new Error("No file field found in form data"));
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.error("Error parsing multipart:", error);
|
|
437
|
+
reject(error);
|
|
68
438
|
}
|
|
69
439
|
});
|
|
70
440
|
|
|
71
|
-
|
|
72
|
-
}
|
|
441
|
+
req.on("error", reject);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Handle seed file upload
|
|
447
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
448
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
449
|
+
*/
|
|
450
|
+
async function handleSeedUpload(req, res) {
|
|
451
|
+
try {
|
|
452
|
+
const ct = req.headers["content-type"] || "";
|
|
453
|
+
let seedObject;
|
|
454
|
+
if (ct.includes("application/json")) {
|
|
455
|
+
const raw = await readRawBody(req);
|
|
456
|
+
try {
|
|
457
|
+
seedObject = JSON.parse(raw.toString("utf8") || "{}");
|
|
458
|
+
} catch {
|
|
459
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
460
|
+
res.end(JSON.stringify({ success: false, message: "Invalid JSON" }));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
// Parse multipart form data (existing behavior)
|
|
465
|
+
const formData = await parseMultipartFormData(req);
|
|
466
|
+
if (!formData.content) {
|
|
467
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
468
|
+
res.end(
|
|
469
|
+
JSON.stringify({ success: false, message: "No file content found" })
|
|
470
|
+
);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
seedObject = JSON.parse(formData.content);
|
|
475
|
+
} catch {
|
|
476
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
477
|
+
res.end(JSON.stringify({ success: false, message: "Invalid JSON" }));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Use current PO_ROOT or fallback to DATA_DIR
|
|
483
|
+
const currentDataDir = process.env.PO_ROOT || DATA_DIR;
|
|
484
|
+
|
|
485
|
+
// For test environment, use simplified validation without starting orchestrator
|
|
486
|
+
console.log("NODE_ENV:", process.env.NODE_ENV);
|
|
487
|
+
if (process.env.NODE_ENV === "test") {
|
|
488
|
+
console.log("Using test mode for seed upload");
|
|
489
|
+
// Simplified validation for tests - just write to pending directory
|
|
490
|
+
const result = await handleSeedUploadDirect(seedObject, currentDataDir);
|
|
491
|
+
console.log("handleSeedUploadDirect result:", result);
|
|
492
|
+
|
|
493
|
+
// Return appropriate status code based on success
|
|
494
|
+
if (result.success) {
|
|
495
|
+
console.log("Sending 200 response");
|
|
496
|
+
res.writeHead(200, {
|
|
497
|
+
"Content-Type": "application/json",
|
|
498
|
+
Connection: "close",
|
|
499
|
+
});
|
|
500
|
+
res.end(JSON.stringify(result));
|
|
501
|
+
console.log("Response sent successfully");
|
|
502
|
+
|
|
503
|
+
// Broadcast SSE event for successful upload
|
|
504
|
+
sseRegistry.broadcast({
|
|
505
|
+
type: "seed:uploaded",
|
|
506
|
+
data: { name: result.jobName },
|
|
507
|
+
});
|
|
508
|
+
} else {
|
|
509
|
+
console.log("Sending 400 response");
|
|
510
|
+
res.writeHead(400, {
|
|
511
|
+
"Content-Type": "application/json",
|
|
512
|
+
Connection: "close",
|
|
513
|
+
});
|
|
514
|
+
res.end(JSON.stringify(result));
|
|
515
|
+
console.log("Response sent successfully");
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
} else {
|
|
519
|
+
console.log("Using production mode for seed upload");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Submit job with validation (for production)
|
|
523
|
+
// Dynamically import only in non-test mode
|
|
524
|
+
if (process.env.NODE_ENV !== "test") {
|
|
525
|
+
if (!submitJobWithValidation) {
|
|
526
|
+
({ submitJobWithValidation } = await import("../api/index.js"));
|
|
527
|
+
}
|
|
528
|
+
const result = await submitJobWithValidation({
|
|
529
|
+
dataDir: currentDataDir,
|
|
530
|
+
seedObject,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Send appropriate response
|
|
534
|
+
if (result.success) {
|
|
535
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
536
|
+
res.end(JSON.stringify(result));
|
|
537
|
+
|
|
538
|
+
// Broadcast SSE event for successful upload
|
|
539
|
+
sseRegistry.broadcast({
|
|
540
|
+
type: "seed:uploaded",
|
|
541
|
+
data: { name: result.jobName },
|
|
542
|
+
});
|
|
543
|
+
} else {
|
|
544
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
545
|
+
res.end(JSON.stringify(result));
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
// In test mode, we should never reach here, but handle gracefully
|
|
549
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
550
|
+
res.end(
|
|
551
|
+
JSON.stringify({
|
|
552
|
+
success: false,
|
|
553
|
+
message:
|
|
554
|
+
"Test environment error - should not reach production code path",
|
|
555
|
+
})
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
} catch (error) {
|
|
559
|
+
console.error("Upload error:", error);
|
|
560
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
561
|
+
res.end(
|
|
562
|
+
JSON.stringify({
|
|
563
|
+
success: false,
|
|
564
|
+
message: "Internal server error",
|
|
565
|
+
})
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// MIME type detection map
|
|
571
|
+
const MIME_MAP = {
|
|
572
|
+
// Text types
|
|
573
|
+
".txt": "text/plain",
|
|
574
|
+
".log": "text/plain",
|
|
575
|
+
".md": "text/markdown",
|
|
576
|
+
".csv": "text/csv",
|
|
577
|
+
".json": "application/json",
|
|
578
|
+
".xml": "application/xml",
|
|
579
|
+
".yaml": "application/x-yaml",
|
|
580
|
+
".yml": "application/x-yaml",
|
|
581
|
+
".toml": "application/toml",
|
|
582
|
+
".ini": "text/plain",
|
|
583
|
+
".conf": "text/plain",
|
|
584
|
+
".config": "text/plain",
|
|
585
|
+
".env": "text/plain",
|
|
586
|
+
".gitignore": "text/plain",
|
|
587
|
+
".dockerfile": "text/plain",
|
|
588
|
+
".sh": "application/x-sh",
|
|
589
|
+
".bash": "application/x-sh",
|
|
590
|
+
".zsh": "application/x-sh",
|
|
591
|
+
".fish": "application/x-fish",
|
|
592
|
+
".ps1": "application/x-powershell",
|
|
593
|
+
".bat": "application/x-bat",
|
|
594
|
+
".cmd": "application/x-cmd",
|
|
595
|
+
|
|
596
|
+
// Code types
|
|
597
|
+
".js": "application/javascript",
|
|
598
|
+
".mjs": "application/javascript",
|
|
599
|
+
".cjs": "application/javascript",
|
|
600
|
+
".ts": "application/typescript",
|
|
601
|
+
".mts": "application/typescript",
|
|
602
|
+
".cts": "application/typescript",
|
|
603
|
+
".jsx": "application/javascript",
|
|
604
|
+
".tsx": "application/typescript",
|
|
605
|
+
".py": "text/x-python",
|
|
606
|
+
".rb": "text/x-ruby",
|
|
607
|
+
".php": "application/x-php",
|
|
608
|
+
".java": "text/x-java-source",
|
|
609
|
+
".c": "text/x-c",
|
|
610
|
+
".cpp": "text/x-c++",
|
|
611
|
+
".cc": "text/x-c++",
|
|
612
|
+
".cxx": "text/x-c++",
|
|
613
|
+
".h": "text/x-c",
|
|
614
|
+
".hpp": "text/x-c++",
|
|
615
|
+
".cs": "text/x-csharp",
|
|
616
|
+
".go": "text/x-go",
|
|
617
|
+
".rs": "text/x-rust",
|
|
618
|
+
".swift": "text/x-swift",
|
|
619
|
+
".kt": "text/x-kotlin",
|
|
620
|
+
".scala": "text/x-scala",
|
|
621
|
+
".r": "text/x-r",
|
|
622
|
+
".sql": "application/sql",
|
|
623
|
+
".pl": "text/x-perl",
|
|
624
|
+
".lua": "text/x-lua",
|
|
625
|
+
".vim": "text/x-vim",
|
|
626
|
+
".el": "text/x-elisp",
|
|
627
|
+
".lisp": "text/x-lisp",
|
|
628
|
+
".hs": "text/x-haskell",
|
|
629
|
+
".ml": "text/x-ocaml",
|
|
630
|
+
".ex": "text/x-elixir",
|
|
631
|
+
".exs": "text/x-elixir",
|
|
632
|
+
".erl": "text/x-erlang",
|
|
633
|
+
".beam": "application/x-erlang-beam",
|
|
634
|
+
|
|
635
|
+
// Web types
|
|
636
|
+
".html": "text/html",
|
|
637
|
+
".htm": "text/html",
|
|
638
|
+
".xhtml": "application/xhtml+xml",
|
|
639
|
+
".css": "text/css",
|
|
640
|
+
".scss": "text/x-scss",
|
|
641
|
+
".sass": "text/x-sass",
|
|
642
|
+
".less": "text/x-less",
|
|
643
|
+
".styl": "text/x-stylus",
|
|
644
|
+
".vue": "text/x-vue",
|
|
645
|
+
".svelte": "text/x-svelte",
|
|
646
|
+
|
|
647
|
+
// Data formats
|
|
648
|
+
".pdf": "application/pdf",
|
|
649
|
+
".doc": "application/msword",
|
|
650
|
+
".docx":
|
|
651
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
652
|
+
".xls": "application/vnd.ms-excel",
|
|
653
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
654
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
655
|
+
".pptx":
|
|
656
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
657
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
658
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
659
|
+
".odp": "application/vnd.oasis.opendocument.presentation",
|
|
660
|
+
|
|
661
|
+
// Images
|
|
662
|
+
".png": "image/png",
|
|
663
|
+
".jpg": "image/jpeg",
|
|
664
|
+
".jpeg": "image/jpeg",
|
|
665
|
+
".gif": "image/gif",
|
|
666
|
+
".bmp": "image/bmp",
|
|
667
|
+
".webp": "image/webp",
|
|
668
|
+
".svg": "image/svg+xml",
|
|
669
|
+
".ico": "image/x-icon",
|
|
670
|
+
".tiff": "image/tiff",
|
|
671
|
+
".tif": "image/tiff",
|
|
672
|
+
".psd": "image/vnd.adobe.photoshop",
|
|
673
|
+
".ai": "application/pdf", // Illustrator files often saved as PDF
|
|
674
|
+
".eps": "application/postscript",
|
|
675
|
+
|
|
676
|
+
// Audio
|
|
677
|
+
".mp3": "audio/mpeg",
|
|
678
|
+
".wav": "audio/wav",
|
|
679
|
+
".ogg": "audio/ogg",
|
|
680
|
+
".flac": "audio/flac",
|
|
681
|
+
".aac": "audio/aac",
|
|
682
|
+
".m4a": "audio/mp4",
|
|
683
|
+
".wma": "audio/x-ms-wma",
|
|
684
|
+
|
|
685
|
+
// Video
|
|
686
|
+
".mp4": "video/mp4",
|
|
687
|
+
".avi": "video/x-msvideo",
|
|
688
|
+
".mov": "video/quicktime",
|
|
689
|
+
".wmv": "video/x-ms-wmv",
|
|
690
|
+
".flv": "video/x-flv",
|
|
691
|
+
".webm": "video/webm",
|
|
692
|
+
".mkv": "video/x-matroska",
|
|
693
|
+
".m4v": "video/mp4",
|
|
694
|
+
|
|
695
|
+
// Archives
|
|
696
|
+
".zip": "application/zip",
|
|
697
|
+
".rar": "application/x-rar-compressed",
|
|
698
|
+
".tar": "application/x-tar",
|
|
699
|
+
".gz": "application/gzip",
|
|
700
|
+
".tgz": "application/gzip",
|
|
701
|
+
".bz2": "application/x-bzip2",
|
|
702
|
+
".xz": "application/x-xz",
|
|
703
|
+
".7z": "application/x-7z-compressed",
|
|
704
|
+
".deb": "application/x-debian-package",
|
|
705
|
+
".rpm": "application/x-rpm",
|
|
706
|
+
".dmg": "application/x-apple-diskimage",
|
|
707
|
+
".iso": "application/x-iso9660-image",
|
|
708
|
+
|
|
709
|
+
// Fonts
|
|
710
|
+
".ttf": "font/ttf",
|
|
711
|
+
".otf": "font/otf",
|
|
712
|
+
".woff": "font/woff",
|
|
713
|
+
".woff2": "font/woff2",
|
|
714
|
+
".eot": "application/vnd.ms-fontobject",
|
|
715
|
+
|
|
716
|
+
// Misc
|
|
717
|
+
".bin": "application/octet-stream",
|
|
718
|
+
".exe": "application/x-msdownload",
|
|
719
|
+
".dll": "application/x-msdownload",
|
|
720
|
+
".so": "application/x-sharedlib",
|
|
721
|
+
".dylib": "application/x-mach-binary",
|
|
722
|
+
".class": "application/java-vm",
|
|
723
|
+
".jar": "application/java-archive",
|
|
724
|
+
".war": "application/java-archive",
|
|
725
|
+
".ear": "application/java-archive",
|
|
726
|
+
".apk": "application/vnd.android.package-archive",
|
|
727
|
+
".ipa": "application/x-itunes-ipa",
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Determine MIME type from file extension
|
|
732
|
+
* @param {string} filename - File name
|
|
733
|
+
* @returns {string} MIME type
|
|
734
|
+
*/
|
|
735
|
+
function getMimeType(filename) {
|
|
736
|
+
const ext = path.extname(filename).toLowerCase();
|
|
737
|
+
return MIME_MAP[ext] || "application/octet-stream";
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Check if MIME type should be treated as text
|
|
742
|
+
* @param {string} mime - MIME type
|
|
743
|
+
* @returns {boolean} True if text-like
|
|
744
|
+
*/
|
|
745
|
+
function isTextMime(mime) {
|
|
746
|
+
return (
|
|
747
|
+
mime.startsWith("text/") ||
|
|
748
|
+
mime === "application/json" ||
|
|
749
|
+
mime === "application/javascript" ||
|
|
750
|
+
mime === "application/xml" ||
|
|
751
|
+
mime === "application/x-yaml" ||
|
|
752
|
+
mime === "application/x-sh" ||
|
|
753
|
+
mime === "application/x-bat" ||
|
|
754
|
+
mime === "application/x-cmd" ||
|
|
755
|
+
mime === "application/x-powershell" ||
|
|
756
|
+
mime === "image/svg+xml" ||
|
|
757
|
+
mime === "application/x-ndjson" ||
|
|
758
|
+
mime === "text/csv" ||
|
|
759
|
+
mime === "text/markdown"
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Handle task file list request with validation and security checks
|
|
765
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
766
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
767
|
+
* @param {Object} params - Request parameters
|
|
768
|
+
*/
|
|
769
|
+
async function handleTaskFileListRequest(req, res, { jobId, taskId, type }) {
|
|
770
|
+
const dataDir = process.env.PO_ROOT || DATA_DIR;
|
|
771
|
+
|
|
772
|
+
// Resolve job lifecycle deterministically
|
|
773
|
+
const lifecycle = await resolveJobLifecycle(dataDir, jobId);
|
|
774
|
+
if (!lifecycle) {
|
|
775
|
+
// Job not found, return empty list
|
|
776
|
+
sendJson(res, 200, {
|
|
777
|
+
ok: true,
|
|
778
|
+
data: {
|
|
779
|
+
files: [],
|
|
780
|
+
jobId,
|
|
781
|
+
taskId,
|
|
782
|
+
type,
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Use single lifecycle directory
|
|
789
|
+
const jobDir = getJobDirectoryPath(dataDir, jobId, lifecycle);
|
|
790
|
+
const taskDir = path.join(jobDir, "files", type);
|
|
791
|
+
|
|
792
|
+
// Use path.relative for stricter jail enforcement
|
|
793
|
+
const resolvedPath = path.resolve(taskDir);
|
|
794
|
+
const relativePath = path.relative(jobDir, resolvedPath);
|
|
795
|
+
|
|
796
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
797
|
+
console.error("Path security: directory traversal detected", {
|
|
798
|
+
taskDir,
|
|
799
|
+
relativePath,
|
|
800
|
+
});
|
|
801
|
+
sendJson(res, 403, {
|
|
802
|
+
ok: false,
|
|
803
|
+
error: "forbidden",
|
|
804
|
+
message: "Path validation failed",
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Check if directory exists
|
|
810
|
+
if (!(await exists(taskDir))) {
|
|
811
|
+
// Directory doesn't exist, return empty list
|
|
812
|
+
sendJson(res, 200, {
|
|
813
|
+
ok: true,
|
|
814
|
+
data: {
|
|
815
|
+
files: [],
|
|
816
|
+
jobId,
|
|
817
|
+
taskId,
|
|
818
|
+
type,
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
// Read directory contents
|
|
826
|
+
const entries = await fs.promises.readdir(taskDir, {
|
|
827
|
+
withFileTypes: true,
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// Filter and map to file list
|
|
831
|
+
const files = [];
|
|
832
|
+
for (const entry of entries) {
|
|
833
|
+
if (entry.isFile()) {
|
|
834
|
+
// Validate each filename using the consolidated function
|
|
835
|
+
const validation = validateFilePath(entry.name);
|
|
836
|
+
if (validation) {
|
|
837
|
+
console.error("Path security: skipping invalid file", {
|
|
838
|
+
filename: entry.name,
|
|
839
|
+
reason: validation.message,
|
|
840
|
+
});
|
|
841
|
+
continue; // Skip files that fail validation
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const filePath = path.join(taskDir, entry.name);
|
|
845
|
+
const stats = await fs.promises.stat(filePath);
|
|
846
|
+
|
|
847
|
+
files.push({
|
|
848
|
+
name: entry.name,
|
|
849
|
+
size: stats.size,
|
|
850
|
+
mtime: stats.mtime.toISOString(),
|
|
851
|
+
mime: getMimeType(entry.name),
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Sort files by name
|
|
857
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
858
|
+
|
|
859
|
+
// Send successful response
|
|
860
|
+
sendJson(res, 200, {
|
|
861
|
+
ok: true,
|
|
862
|
+
data: {
|
|
863
|
+
files,
|
|
864
|
+
jobId,
|
|
865
|
+
taskId,
|
|
866
|
+
type,
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
} catch (error) {
|
|
870
|
+
console.error("Error listing files:", error);
|
|
871
|
+
sendJson(res, 500, {
|
|
872
|
+
ok: false,
|
|
873
|
+
error: "internal_error",
|
|
874
|
+
message: "Failed to list files",
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Consolidated path jail security validation with generic error messages
|
|
881
|
+
* @param {string} filename - Filename to validate
|
|
882
|
+
* @returns {Object|null} Validation result or null if valid
|
|
883
|
+
*/
|
|
884
|
+
function validateFilePath(filename) {
|
|
885
|
+
// Check for path traversal patterns
|
|
886
|
+
if (filename.includes("..")) {
|
|
887
|
+
console.error("Path security: path traversal detected", { filename });
|
|
888
|
+
return {
|
|
889
|
+
allowed: false,
|
|
890
|
+
message: "Path validation failed",
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Check for absolute paths (POSIX, Windows, backslashes, ~)
|
|
895
|
+
if (
|
|
896
|
+
path.isAbsolute(filename) ||
|
|
897
|
+
/^[a-zA-Z]:/.test(filename) ||
|
|
898
|
+
filename.includes("\\") ||
|
|
899
|
+
filename.startsWith("~")
|
|
900
|
+
) {
|
|
901
|
+
console.error("Path security: absolute path detected", { filename });
|
|
902
|
+
return {
|
|
903
|
+
allowed: false,
|
|
904
|
+
message: "Path validation failed",
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Check for empty filename
|
|
909
|
+
if (!filename || filename.trim() === "") {
|
|
910
|
+
console.error("Path security: empty filename detected");
|
|
911
|
+
return {
|
|
912
|
+
allowed: false,
|
|
913
|
+
message: "Path validation failed",
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Path is valid
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Handle task file request with validation, jail checks, and proper encoding
|
|
923
|
+
* @param {http.IncomingMessage} req - HTTP request
|
|
924
|
+
* @param {http.ServerResponse} res - HTTP response
|
|
925
|
+
* @param {Object} params - Request parameters
|
|
926
|
+
*/
|
|
927
|
+
async function handleTaskFileRequest(
|
|
928
|
+
req,
|
|
929
|
+
res,
|
|
930
|
+
{ jobId, taskId, type, filename }
|
|
931
|
+
) {
|
|
932
|
+
const dataDir = process.env.PO_ROOT || DATA_DIR;
|
|
933
|
+
|
|
934
|
+
// Unified security validation
|
|
935
|
+
const validation = validateFilePath(filename);
|
|
936
|
+
if (validation) {
|
|
937
|
+
sendJson(res, 403, {
|
|
938
|
+
ok: false,
|
|
939
|
+
error: "forbidden",
|
|
940
|
+
message: validation.message,
|
|
941
|
+
});
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Resolve job lifecycle deterministically
|
|
946
|
+
const lifecycle = await resolveJobLifecycle(dataDir, jobId);
|
|
947
|
+
if (!lifecycle) {
|
|
948
|
+
sendJson(res, 404, {
|
|
949
|
+
ok: false,
|
|
950
|
+
error: "not_found",
|
|
951
|
+
message: "Job not found",
|
|
952
|
+
});
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Use single lifecycle directory
|
|
957
|
+
const jobDir = getJobDirectoryPath(dataDir, jobId, lifecycle);
|
|
958
|
+
const taskDir = path.join(jobDir, "files", type);
|
|
959
|
+
const filePath = path.join(taskDir, filename);
|
|
960
|
+
|
|
961
|
+
// Use path.relative for stricter jail enforcement
|
|
962
|
+
const resolvedPath = path.resolve(filePath);
|
|
963
|
+
const relativePath = path.relative(jobDir, resolvedPath);
|
|
964
|
+
|
|
965
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
966
|
+
sendJson(res, 403, {
|
|
967
|
+
ok: false,
|
|
968
|
+
error: "forbidden",
|
|
969
|
+
message: "Path resolves outside allowed directory",
|
|
970
|
+
});
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Check if file exists
|
|
975
|
+
if (!(await exists(filePath))) {
|
|
976
|
+
sendJson(res, 404, {
|
|
977
|
+
ok: false,
|
|
978
|
+
error: "not_found",
|
|
979
|
+
message: "File not found",
|
|
980
|
+
filePath,
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
try {
|
|
986
|
+
// Get file stats
|
|
987
|
+
const stats = await fs.promises.stat(filePath);
|
|
988
|
+
if (!stats.isFile()) {
|
|
989
|
+
sendJson(res, 404, {
|
|
990
|
+
ok: false,
|
|
991
|
+
error: "not_found",
|
|
992
|
+
message: "Not a regular file",
|
|
993
|
+
});
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Determine MIME type and encoding
|
|
998
|
+
const mime = getMimeType(filename);
|
|
999
|
+
const isText = isTextMime(mime);
|
|
1000
|
+
const encoding = isText ? "utf8" : "base64";
|
|
1001
|
+
|
|
1002
|
+
// Read file content
|
|
1003
|
+
let content;
|
|
1004
|
+
if (isText) {
|
|
1005
|
+
content = await fs.promises.readFile(filePath, "utf8");
|
|
1006
|
+
} else {
|
|
1007
|
+
const buffer = await fs.promises.readFile(filePath);
|
|
1008
|
+
content = buffer.toString("base64");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Build relative path for response
|
|
1012
|
+
const relativePath = path.join("tasks", taskId, type, filename);
|
|
1013
|
+
|
|
1014
|
+
// Send successful response
|
|
1015
|
+
sendJson(res, 200, {
|
|
1016
|
+
ok: true,
|
|
1017
|
+
jobId,
|
|
1018
|
+
taskId,
|
|
1019
|
+
type,
|
|
1020
|
+
path: relativePath,
|
|
1021
|
+
mime,
|
|
1022
|
+
size: stats.size,
|
|
1023
|
+
mtime: stats.mtime.toISOString(),
|
|
1024
|
+
encoding,
|
|
1025
|
+
content,
|
|
1026
|
+
});
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
console.error("Error reading file:", error);
|
|
1029
|
+
sendJson(res, 500, {
|
|
1030
|
+
ok: false,
|
|
1031
|
+
error: "internal_error",
|
|
1032
|
+
message: "Failed to read file",
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
73
1035
|
}
|
|
74
1036
|
|
|
75
1037
|
/**
|
|
76
|
-
* Serve static files from
|
|
1038
|
+
* Serve static files from dist directory (built React app)
|
|
77
1039
|
*/
|
|
78
1040
|
function serveStatic(res, filePath) {
|
|
79
1041
|
const ext = path.extname(filePath);
|
|
@@ -81,6 +1043,10 @@ function serveStatic(res, filePath) {
|
|
|
81
1043
|
".html": "text/html",
|
|
82
1044
|
".js": "application/javascript",
|
|
83
1045
|
".css": "text/css",
|
|
1046
|
+
".json": "application/json",
|
|
1047
|
+
".png": "image/png",
|
|
1048
|
+
".jpg": "image/jpeg",
|
|
1049
|
+
".svg": "image/svg+xml",
|
|
84
1050
|
};
|
|
85
1051
|
|
|
86
1052
|
fs.readFile(filePath, (err, content) => {
|
|
@@ -98,14 +1064,20 @@ function serveStatic(res, filePath) {
|
|
|
98
1064
|
* Create and start the HTTP server
|
|
99
1065
|
*/
|
|
100
1066
|
function createServer() {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
1067
|
+
console.log("Creating HTTP server...");
|
|
1068
|
+
const server = http.createServer(async (req, res) => {
|
|
1069
|
+
// Use WHATWG URL API instead of deprecated url.parse
|
|
1070
|
+
const { pathname, searchParams } = new URL(
|
|
1071
|
+
req.url,
|
|
1072
|
+
`http://${req.headers.host}`
|
|
1073
|
+
);
|
|
104
1074
|
|
|
105
1075
|
// CORS headers for API endpoints
|
|
106
1076
|
if (pathname.startsWith("/api/")) {
|
|
1077
|
+
// Important for tests: avoid idle keep-alive sockets on short API calls
|
|
1078
|
+
res.setHeader("Connection", "close");
|
|
107
1079
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
108
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
1080
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
109
1081
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
110
1082
|
|
|
111
1083
|
if (req.method === "OPTIONS") {
|
|
@@ -116,14 +1088,101 @@ function createServer() {
|
|
|
116
1088
|
}
|
|
117
1089
|
|
|
118
1090
|
// Route: GET /api/state
|
|
119
|
-
if (pathname === "/api/state"
|
|
120
|
-
|
|
121
|
-
|
|
1091
|
+
if (pathname === "/api/state") {
|
|
1092
|
+
if (req.method !== "GET") {
|
|
1093
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1094
|
+
res.end(
|
|
1095
|
+
JSON.stringify({
|
|
1096
|
+
success: false,
|
|
1097
|
+
error: "Method not allowed",
|
|
1098
|
+
allowed: ["GET"],
|
|
1099
|
+
})
|
|
1100
|
+
);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Prefer returning the in-memory state when available (tests and runtime rely on state.getState()).
|
|
1105
|
+
// If in-memory state is available, return it directly; otherwise fall back to
|
|
1106
|
+
// building a filesystem-backed snapshot for client bootstrap.
|
|
1107
|
+
try {
|
|
1108
|
+
try {
|
|
1109
|
+
if (state && typeof state.getState === "function") {
|
|
1110
|
+
const inMemory = state.getState();
|
|
1111
|
+
if (inMemory) {
|
|
1112
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1113
|
+
res.end(JSON.stringify(inMemory));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
} catch (innerErr) {
|
|
1118
|
+
// If reading in-memory state throws for some reason, fall back to snapshot
|
|
1119
|
+
console.warn(
|
|
1120
|
+
"Warning: failed to retrieve in-memory state:",
|
|
1121
|
+
innerErr
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// Build a filesystem-backed snapshot for client bootstrap.
|
|
1126
|
+
// Dynamically import the composer and dependencies to avoid circular import issues.
|
|
1127
|
+
const [
|
|
1128
|
+
{ buildSnapshotFromFilesystem },
|
|
1129
|
+
jobScannerModule,
|
|
1130
|
+
jobReaderModule,
|
|
1131
|
+
statusTransformerModule,
|
|
1132
|
+
configBridgeModule,
|
|
1133
|
+
] = await Promise.all([
|
|
1134
|
+
import("./state-snapshot.js"),
|
|
1135
|
+
import("./job-scanner.js").catch(() => null),
|
|
1136
|
+
import("./job-reader.js").catch(() => null),
|
|
1137
|
+
import("./transformers/status-transformer.js").catch(() => null),
|
|
1138
|
+
import("./config-bridge.js").catch(() => null),
|
|
1139
|
+
]);
|
|
1140
|
+
|
|
1141
|
+
const snapshot = await buildSnapshotFromFilesystem({
|
|
1142
|
+
listAllJobs:
|
|
1143
|
+
jobScannerModule && jobScannerModule.listAllJobs
|
|
1144
|
+
? jobScannerModule.listAllJobs
|
|
1145
|
+
: undefined,
|
|
1146
|
+
readJob:
|
|
1147
|
+
jobReaderModule && jobReaderModule.readJob
|
|
1148
|
+
? jobReaderModule.readJob
|
|
1149
|
+
: undefined,
|
|
1150
|
+
transformMultipleJobs:
|
|
1151
|
+
statusTransformerModule &&
|
|
1152
|
+
statusTransformerModule.transformMultipleJobs
|
|
1153
|
+
? statusTransformerModule.transformMultipleJobs
|
|
1154
|
+
: undefined,
|
|
1155
|
+
now: () => new Date(),
|
|
1156
|
+
paths: (configBridgeModule && configBridgeModule.PATHS) || undefined,
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1160
|
+
res.end(JSON.stringify(snapshot));
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
console.error("Failed to build /api/state snapshot:", err);
|
|
1163
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1164
|
+
res.end(
|
|
1165
|
+
JSON.stringify({
|
|
1166
|
+
ok: false,
|
|
1167
|
+
code: "snapshot_error",
|
|
1168
|
+
message: "Failed to build state snapshot",
|
|
1169
|
+
details: err && err.message ? err.message : String(err),
|
|
1170
|
+
})
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
122
1174
|
return;
|
|
123
1175
|
}
|
|
124
1176
|
|
|
125
1177
|
// Route: GET /api/events (SSE)
|
|
126
|
-
if (
|
|
1178
|
+
if (
|
|
1179
|
+
(pathname === "/api/events" || pathname === "/api/sse") &&
|
|
1180
|
+
req.method === "GET"
|
|
1181
|
+
) {
|
|
1182
|
+
// Parse jobId from query parameters for filtering
|
|
1183
|
+
const jobId = searchParams.get("jobId");
|
|
1184
|
+
|
|
1185
|
+
// Set SSE headers
|
|
127
1186
|
res.writeHead(200, {
|
|
128
1187
|
"Content-Type": "text/event-stream",
|
|
129
1188
|
"Cache-Control": "no-cache",
|
|
@@ -131,30 +1190,313 @@ function createServer() {
|
|
|
131
1190
|
"Access-Control-Allow-Origin": "*",
|
|
132
1191
|
});
|
|
133
1192
|
|
|
134
|
-
//
|
|
135
|
-
|
|
1193
|
+
// Flush headers immediately
|
|
1194
|
+
res.flushHeaders();
|
|
136
1195
|
|
|
137
|
-
//
|
|
138
|
-
|
|
1196
|
+
// Initial full-state is no longer sent over the SSE stream.
|
|
1197
|
+
// Clients should fetch the snapshot from GET /api/state during bootstrap
|
|
1198
|
+
// and then rely on SSE incremental events (state:change/state:summary).
|
|
1199
|
+
// Keep headers flushed; sseRegistry.addClient will optionally send an initial ping.
|
|
1200
|
+
// (Previously sent full state here; removed to reduce SSE payloads.)
|
|
1201
|
+
|
|
1202
|
+
// Add to SSE registry with jobId metadata for filtering
|
|
1203
|
+
sseRegistry.addClient(res, { jobId });
|
|
1204
|
+
|
|
1205
|
+
// Start heartbeat for this connection
|
|
1206
|
+
const heartbeatInterval = setInterval(() => {
|
|
1207
|
+
try {
|
|
1208
|
+
res.write(
|
|
1209
|
+
`event: heartbeat\ndata: ${JSON.stringify({ timestamp: Date.now() })}\n\n`
|
|
1210
|
+
);
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
// Client disconnected, stop heartbeat
|
|
1213
|
+
clearInterval(heartbeatInterval);
|
|
1214
|
+
}
|
|
1215
|
+
}, 30000);
|
|
139
1216
|
|
|
140
1217
|
// Remove client on disconnect
|
|
141
1218
|
req.on("close", () => {
|
|
142
|
-
|
|
1219
|
+
clearInterval(heartbeatInterval);
|
|
1220
|
+
sseRegistry.removeClient(res);
|
|
143
1221
|
});
|
|
144
1222
|
|
|
145
1223
|
return;
|
|
146
1224
|
}
|
|
147
1225
|
|
|
148
|
-
//
|
|
149
|
-
if (pathname === "/
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1226
|
+
// Route: POST /api/upload/seed
|
|
1227
|
+
if (pathname === "/api/upload/seed") {
|
|
1228
|
+
if (req.method !== "POST") {
|
|
1229
|
+
return sendJson(res, 405, {
|
|
1230
|
+
success: false,
|
|
1231
|
+
error: "Method not allowed",
|
|
1232
|
+
allowed: ["POST"],
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Use the handleSeedUpload function which properly parses multipart data
|
|
1237
|
+
await handleSeedUpload(req, res);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Route: GET /api/jobs/:jobId/tasks/:taskId/files (must come before generic /api/jobs/:jobId)
|
|
1242
|
+
if (
|
|
1243
|
+
pathname.startsWith("/api/jobs/") &&
|
|
1244
|
+
pathname.includes("/tasks/") &&
|
|
1245
|
+
pathname.endsWith("/files") &&
|
|
1246
|
+
req.method === "GET"
|
|
1247
|
+
) {
|
|
1248
|
+
const pathMatch = pathname.match(
|
|
1249
|
+
/^\/api\/jobs\/([^\/]+)\/tasks\/([^\/]+)\/files$/
|
|
1250
|
+
);
|
|
1251
|
+
if (!pathMatch) {
|
|
1252
|
+
sendJson(res, 400, {
|
|
1253
|
+
ok: false,
|
|
1254
|
+
error: "bad_request",
|
|
1255
|
+
message: "Invalid path format",
|
|
1256
|
+
});
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const [, jobId, taskId] = pathMatch;
|
|
1261
|
+
const type = searchParams.get("type");
|
|
1262
|
+
|
|
1263
|
+
// Validate parameters
|
|
1264
|
+
if (!jobId || typeof jobId !== "string" || jobId.trim() === "") {
|
|
1265
|
+
sendJson(res, 400, {
|
|
1266
|
+
ok: false,
|
|
1267
|
+
error: "bad_request",
|
|
1268
|
+
message: "jobId is required",
|
|
1269
|
+
});
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (!taskId || typeof taskId !== "string" || taskId.trim() === "") {
|
|
1274
|
+
sendJson(res, 400, {
|
|
1275
|
+
ok: false,
|
|
1276
|
+
error: "bad_request",
|
|
1277
|
+
message: "taskId is required",
|
|
1278
|
+
});
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (!type || !["artifacts", "logs", "tmp"].includes(type)) {
|
|
1283
|
+
sendJson(res, 400, {
|
|
1284
|
+
ok: false,
|
|
1285
|
+
error: "bad_request",
|
|
1286
|
+
message: "type must be one of: artifacts, logs, tmp",
|
|
1287
|
+
});
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
await handleTaskFileListRequest(req, res, {
|
|
1293
|
+
jobId,
|
|
1294
|
+
taskId,
|
|
1295
|
+
type,
|
|
1296
|
+
});
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
console.error(`Error handling task file list request:`, error);
|
|
1299
|
+
sendJson(res, 500, {
|
|
1300
|
+
ok: false,
|
|
1301
|
+
error: "internal_error",
|
|
1302
|
+
message: "Internal server error",
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Route: GET /api/jobs/:jobId/tasks/:taskId/file (must come before generic /api/jobs/:jobId)
|
|
1309
|
+
if (
|
|
1310
|
+
pathname.startsWith("/api/jobs/") &&
|
|
1311
|
+
pathname.includes("/tasks/") &&
|
|
1312
|
+
pathname.endsWith("/file") &&
|
|
1313
|
+
req.method === "GET"
|
|
1314
|
+
) {
|
|
1315
|
+
const pathMatch = pathname.match(
|
|
1316
|
+
/^\/api\/jobs\/([^\/]+)\/tasks\/([^\/]+)\/file$/
|
|
1317
|
+
);
|
|
1318
|
+
if (!pathMatch) {
|
|
1319
|
+
sendJson(res, 400, {
|
|
1320
|
+
ok: false,
|
|
1321
|
+
error: "bad_request",
|
|
1322
|
+
message: "Invalid path format",
|
|
1323
|
+
});
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const [, jobId, taskId] = pathMatch;
|
|
1328
|
+
const type = searchParams.get("type");
|
|
1329
|
+
const filename = searchParams.get("filename");
|
|
1330
|
+
|
|
1331
|
+
// Validate parameters
|
|
1332
|
+
if (!jobId || typeof jobId !== "string" || jobId.trim() === "") {
|
|
1333
|
+
sendJson(res, 400, {
|
|
1334
|
+
ok: false,
|
|
1335
|
+
error: "bad_request",
|
|
1336
|
+
message: "jobId is required",
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (!taskId || typeof taskId !== "string" || taskId.trim() === "") {
|
|
1342
|
+
sendJson(res, 400, {
|
|
1343
|
+
ok: false,
|
|
1344
|
+
error: "bad_request",
|
|
1345
|
+
message: "taskId is required",
|
|
1346
|
+
});
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
if (!type || !["artifacts", "logs", "tmp"].includes(type)) {
|
|
1351
|
+
sendJson(res, 400, {
|
|
1352
|
+
ok: false,
|
|
1353
|
+
error: "bad_request",
|
|
1354
|
+
message: "type must be one of: artifacts, logs, tmp",
|
|
1355
|
+
});
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (!filename || typeof filename !== "string" || filename.trim() === "") {
|
|
1360
|
+
sendJson(res, 400, {
|
|
1361
|
+
ok: false,
|
|
1362
|
+
error: "bad_request",
|
|
1363
|
+
message: "filename is required",
|
|
1364
|
+
});
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
await handleTaskFileRequest(req, res, {
|
|
1370
|
+
jobId,
|
|
1371
|
+
taskId,
|
|
1372
|
+
type,
|
|
1373
|
+
filename,
|
|
1374
|
+
});
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
console.error(`Error handling task file request:`, error);
|
|
1377
|
+
sendJson(res, 500, {
|
|
1378
|
+
ok: false,
|
|
1379
|
+
error: "internal_error",
|
|
1380
|
+
message: "Internal server error",
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Route: GET /api/jobs
|
|
1387
|
+
if (pathname === "/api/jobs" && req.method === "GET") {
|
|
1388
|
+
try {
|
|
1389
|
+
const result = await handleJobList();
|
|
1390
|
+
|
|
1391
|
+
if (result.ok) {
|
|
1392
|
+
sendJson(res, 200, result.data);
|
|
1393
|
+
} else {
|
|
1394
|
+
sendJson(res, 500, result);
|
|
1395
|
+
}
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
console.error("Error handling /api/jobs:", error);
|
|
1398
|
+
sendJson(res, 500, {
|
|
1399
|
+
ok: false,
|
|
1400
|
+
code: "internal_error",
|
|
1401
|
+
message: "Internal server error",
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Route: GET /api/jobs/:jobId
|
|
1408
|
+
if (pathname.startsWith("/api/jobs/") && req.method === "GET") {
|
|
1409
|
+
const jobId = pathname.substring("/api/jobs/".length);
|
|
1410
|
+
|
|
1411
|
+
try {
|
|
1412
|
+
const result = await handleJobDetail(jobId);
|
|
1413
|
+
|
|
1414
|
+
if (result.ok) {
|
|
1415
|
+
sendJson(res, 200, result);
|
|
1416
|
+
} else {
|
|
1417
|
+
switch (result.code) {
|
|
1418
|
+
case "job_not_found":
|
|
1419
|
+
sendJson(res, 404, result);
|
|
1420
|
+
break;
|
|
1421
|
+
case "bad_request":
|
|
1422
|
+
sendJson(res, 400, result);
|
|
1423
|
+
break;
|
|
1424
|
+
default:
|
|
1425
|
+
sendJson(res, 500, result);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
} catch (error) {
|
|
1429
|
+
console.error(`Error handling /api/jobs/${jobId}:`, error);
|
|
1430
|
+
sendJson(res, 500, {
|
|
1431
|
+
ok: false,
|
|
1432
|
+
code: "internal_error",
|
|
1433
|
+
message: "Internal server error",
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Unknown API endpoint fallback (keep API responses in JSON)
|
|
1440
|
+
if (pathname.startsWith("/api/")) {
|
|
1441
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1442
|
+
res.end(
|
|
1443
|
+
JSON.stringify({
|
|
1444
|
+
success: false,
|
|
1445
|
+
error: "Not found",
|
|
1446
|
+
path: pathname,
|
|
1447
|
+
method: req.method,
|
|
1448
|
+
})
|
|
1449
|
+
);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Prefer Vite middleware in development for non-API routes (HMR & asset serving)
|
|
1454
|
+
if (viteServer && viteServer.middlewares) {
|
|
1455
|
+
try {
|
|
1456
|
+
// Let Vite handle all non-API requests (including assets). If Vite calls next,
|
|
1457
|
+
// fall back to the static handlers below.
|
|
1458
|
+
return viteServer.middlewares(req, res, () => {
|
|
1459
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
1460
|
+
serveStatic(res, path.join(__dirname, "dist", "index.html"));
|
|
1461
|
+
} else if (pathname.startsWith("/assets/")) {
|
|
1462
|
+
const assetPath = pathname.substring(1); // Remove leading slash
|
|
1463
|
+
serveStatic(res, path.join(__dirname, "dist", assetPath));
|
|
1464
|
+
} else if (pathname.startsWith("/public/")) {
|
|
1465
|
+
const publicPath = pathname.substring(1); // Remove leading slash
|
|
1466
|
+
serveStatic(
|
|
1467
|
+
res,
|
|
1468
|
+
path.join(__dirname, "public", publicPath.replace("public/", ""))
|
|
1469
|
+
);
|
|
1470
|
+
} else {
|
|
1471
|
+
// Fallback to index.html for client-side routing
|
|
1472
|
+
serveStatic(res, path.join(__dirname, "dist", "index.html"));
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
console.error("Vite middleware error:", err);
|
|
1477
|
+
// Fallback to serving built assets
|
|
1478
|
+
serveStatic(res, path.join(__dirname, "dist", "index.html"));
|
|
1479
|
+
}
|
|
155
1480
|
} else {
|
|
156
|
-
|
|
157
|
-
|
|
1481
|
+
// No Vite dev server available; serve static files from dist/public as before
|
|
1482
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
1483
|
+
serveStatic(res, path.join(__dirname, "dist", "index.html"));
|
|
1484
|
+
} else if (pathname.startsWith("/assets/")) {
|
|
1485
|
+
// Serve assets from dist/assets
|
|
1486
|
+
const assetPath = pathname.substring(1); // Remove leading slash
|
|
1487
|
+
serveStatic(res, path.join(__dirname, "dist", assetPath));
|
|
1488
|
+
} else if (pathname.startsWith("/public/")) {
|
|
1489
|
+
// Serve static files from public directory
|
|
1490
|
+
const publicPath = pathname.substring(1); // Remove leading slash
|
|
1491
|
+
serveStatic(
|
|
1492
|
+
res,
|
|
1493
|
+
path.join(__dirname, "public", publicPath.replace("public/", ""))
|
|
1494
|
+
);
|
|
1495
|
+
} else {
|
|
1496
|
+
// For any other route, serve the React app's index.html
|
|
1497
|
+
// This allows client-side routing to work
|
|
1498
|
+
serveStatic(res, path.join(__dirname, "dist", "index.html"));
|
|
1499
|
+
}
|
|
158
1500
|
}
|
|
159
1501
|
});
|
|
160
1502
|
|
|
@@ -167,17 +1509,65 @@ function createServer() {
|
|
|
167
1509
|
let watcher = null;
|
|
168
1510
|
|
|
169
1511
|
function initializeWatcher() {
|
|
1512
|
+
// Require PO_ROOT for non-test runs
|
|
1513
|
+
const base = process.env.PO_ROOT;
|
|
1514
|
+
if (!base) {
|
|
1515
|
+
if (process.env.NODE_ENV !== "test") {
|
|
1516
|
+
console.error(
|
|
1517
|
+
"ERROR: PO_ROOT environment variable is required for non-test runs"
|
|
1518
|
+
);
|
|
1519
|
+
throw new Error(
|
|
1520
|
+
"PO_ROOT environment variable is required for non-test runs"
|
|
1521
|
+
);
|
|
1522
|
+
} else {
|
|
1523
|
+
console.warn(
|
|
1524
|
+
"WARNING: PO_ROOT not set, using process.cwd() in test mode"
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const effectiveBase = base || process.cwd();
|
|
1530
|
+
|
|
1531
|
+
// Derive paths via resolvePipelinePaths to obtain absolute dirs for pipeline lifecycle directories
|
|
1532
|
+
const paths = resolvePipelinePaths(effectiveBase);
|
|
1533
|
+
|
|
1534
|
+
// Build absolute paths array including pipeline-config and all lifecycle directories
|
|
1535
|
+
const absolutePaths = [
|
|
1536
|
+
path.join(effectiveBase, "pipeline-config"),
|
|
1537
|
+
paths.current,
|
|
1538
|
+
paths.complete,
|
|
1539
|
+
paths.pending,
|
|
1540
|
+
paths.rejected,
|
|
1541
|
+
];
|
|
1542
|
+
|
|
1543
|
+
// Log effective configuration
|
|
1544
|
+
console.log(`Watching directories under PO_ROOT=${effectiveBase}`);
|
|
1545
|
+
console.log("Final absolute paths:", absolutePaths);
|
|
1546
|
+
|
|
1547
|
+
// Keep original WATCHED_PATHS in state for display/tests; watcher receives absolute paths.
|
|
170
1548
|
state.setWatchedPaths(WATCHED_PATHS);
|
|
171
1549
|
|
|
172
|
-
watcher = startWatcher(
|
|
173
|
-
|
|
174
|
-
changes
|
|
175
|
-
state.
|
|
176
|
-
|
|
1550
|
+
watcher = startWatcher(
|
|
1551
|
+
absolutePaths,
|
|
1552
|
+
(changes) => {
|
|
1553
|
+
// Update state for each change and capture the last returned state.
|
|
1554
|
+
// Prefer broadcasting the state returned by recordChange (if available)
|
|
1555
|
+
// to ensure tests and callers receive an up-to-date snapshot without
|
|
1556
|
+
// relying on mocked module-level getState behavior.
|
|
1557
|
+
let lastState = null;
|
|
1558
|
+
changes.forEach(({ path, type }) => {
|
|
1559
|
+
try {
|
|
1560
|
+
lastState = state.recordChange(path, type);
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
// Don't let a single change handler error prevent broadcasting
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
177
1565
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
1566
|
+
// Broadcast updated state: prefer the result returned by recordChange when available
|
|
1567
|
+
broadcastStateUpdate(lastState || state.getState());
|
|
1568
|
+
},
|
|
1569
|
+
{ baseDir: effectiveBase, debounceMs: 200 }
|
|
1570
|
+
);
|
|
181
1571
|
}
|
|
182
1572
|
|
|
183
1573
|
/**
|
|
@@ -202,8 +1592,7 @@ function start(customPort) {
|
|
|
202
1592
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
203
1593
|
if (watcher) await stopWatcher(watcher);
|
|
204
1594
|
|
|
205
|
-
|
|
206
|
-
sseClients.clear();
|
|
1595
|
+
sseRegistry.closeAll();
|
|
207
1596
|
|
|
208
1597
|
server.close(() => {
|
|
209
1598
|
console.log("Server closed");
|
|
@@ -214,14 +1603,202 @@ function start(customPort) {
|
|
|
214
1603
|
return server;
|
|
215
1604
|
}
|
|
216
1605
|
|
|
1606
|
+
/**
|
|
1607
|
+
* Start server with configurable data directory and port
|
|
1608
|
+
* @param {Object} options - Server options
|
|
1609
|
+
* @param {string} options.dataDir - Base data directory for pipeline data
|
|
1610
|
+
* @param {number} [options.port] - Optional port (defaults to PORT env var or 4000)
|
|
1611
|
+
* @returns {Promise<{url: string, close: function}>} Server instance with URL and close method
|
|
1612
|
+
*/
|
|
1613
|
+
async function startServer({ dataDir, port: customPort }) {
|
|
1614
|
+
try {
|
|
1615
|
+
console.log(
|
|
1616
|
+
"DEBUG: startServer called with dataDir:",
|
|
1617
|
+
dataDir,
|
|
1618
|
+
"customPort:",
|
|
1619
|
+
customPort
|
|
1620
|
+
);
|
|
1621
|
+
|
|
1622
|
+
// Initialize config-bridge paths early to ensure consistent path resolution
|
|
1623
|
+
// This prevents path caching issues when dataDir changes between tests
|
|
1624
|
+
const { initPATHS } = await import("./config-bridge.node.js");
|
|
1625
|
+
initPATHS(dataDir);
|
|
1626
|
+
|
|
1627
|
+
// Set the data directory environment variable
|
|
1628
|
+
if (dataDir) {
|
|
1629
|
+
process.env.PO_ROOT = dataDir;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Require PO_ROOT for non-test runs
|
|
1633
|
+
if (!process.env.PO_ROOT) {
|
|
1634
|
+
if (process.env.NODE_ENV !== "test") {
|
|
1635
|
+
console.error(
|
|
1636
|
+
"ERROR: PO_ROOT environment variable is required for non-test runs"
|
|
1637
|
+
);
|
|
1638
|
+
throw new Error(
|
|
1639
|
+
"PO_ROOT environment variable is required for non-test runs"
|
|
1640
|
+
);
|
|
1641
|
+
} else {
|
|
1642
|
+
console.warn(
|
|
1643
|
+
"WARNING: PO_ROOT not set, using process.cwd() in test mode"
|
|
1644
|
+
);
|
|
1645
|
+
process.env.PO_ROOT = process.cwd();
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Use customPort if provided, otherwise use PORT env var, otherwise use 0 for ephemeral port
|
|
1650
|
+
const port =
|
|
1651
|
+
customPort !== undefined
|
|
1652
|
+
? customPort
|
|
1653
|
+
: process.env.PORT
|
|
1654
|
+
? parseInt(process.env.PORT)
|
|
1655
|
+
: 0;
|
|
1656
|
+
|
|
1657
|
+
console.log("DEBUG: About to create server...");
|
|
1658
|
+
|
|
1659
|
+
// In development, start Vite in middlewareMode so the Node server can serve
|
|
1660
|
+
// the client with HMR in a single process. We dynamically import Vite here
|
|
1661
|
+
// to avoid including it in production bundles.
|
|
1662
|
+
// Skip Vite entirely for API-only tests when DISABLE_VITE=1 is set.
|
|
1663
|
+
// Do not start Vite in tests to avoid dep-scan errors during teardown.
|
|
1664
|
+
if (
|
|
1665
|
+
process.env.NODE_ENV === "development" &&
|
|
1666
|
+
process.env.DISABLE_VITE !== "1"
|
|
1667
|
+
) {
|
|
1668
|
+
try {
|
|
1669
|
+
// Import createServer under an alias to avoid collision with our createServer()
|
|
1670
|
+
const { createServer: createViteServer } = await import("vite");
|
|
1671
|
+
viteServer = await createViteServer({
|
|
1672
|
+
root: path.join(__dirname, "client"),
|
|
1673
|
+
server: { middlewareMode: true },
|
|
1674
|
+
appType: "custom",
|
|
1675
|
+
});
|
|
1676
|
+
console.log("DEBUG: Vite dev server started (middleware mode)");
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
console.error("Failed to start Vite dev server:", err);
|
|
1679
|
+
viteServer = null;
|
|
1680
|
+
}
|
|
1681
|
+
} else if (process.env.NODE_ENV === "test") {
|
|
1682
|
+
console.log("DEBUG: Vite disabled in test mode (API-only mode)");
|
|
1683
|
+
} else if (process.env.DISABLE_VITE === "1") {
|
|
1684
|
+
console.log("DEBUG: Vite disabled via DISABLE_VITE=1 (API-only mode)");
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const server = createServer();
|
|
1688
|
+
console.log("DEBUG: Server created successfully");
|
|
1689
|
+
|
|
1690
|
+
// Robust promise with proper error handling and race condition prevention
|
|
1691
|
+
console.log(`Attempting to start server on port ${port}...`);
|
|
1692
|
+
await new Promise((resolve, reject) => {
|
|
1693
|
+
let settled = false;
|
|
1694
|
+
|
|
1695
|
+
const errorHandler = (error) => {
|
|
1696
|
+
if (!settled) {
|
|
1697
|
+
settled = true;
|
|
1698
|
+
server.removeListener("error", errorHandler);
|
|
1699
|
+
|
|
1700
|
+
// Enhance error with structured information for better test assertions
|
|
1701
|
+
if (error.code === "EADDRINUSE") {
|
|
1702
|
+
error.message = `Port ${port} is already in use`;
|
|
1703
|
+
error.port = port;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
console.error(`Server error on port ${port}:`, error);
|
|
1707
|
+
reject(error);
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
const successHandler = () => {
|
|
1712
|
+
if (!settled) {
|
|
1713
|
+
settled = true;
|
|
1714
|
+
server.removeListener("error", errorHandler);
|
|
1715
|
+
console.log(`Server successfully started on port ${port}`);
|
|
1716
|
+
resolve();
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
// Attach error handler BEFORE attempting to listen
|
|
1721
|
+
server.on("error", errorHandler);
|
|
1722
|
+
|
|
1723
|
+
// Add timeout to prevent hanging
|
|
1724
|
+
const timeout = setTimeout(() => {
|
|
1725
|
+
if (!settled) {
|
|
1726
|
+
settled = true;
|
|
1727
|
+
server.removeListener("error", errorHandler);
|
|
1728
|
+
reject(new Error(`Server startup timeout on port ${port}`));
|
|
1729
|
+
}
|
|
1730
|
+
}, 5000); // 5 second timeout
|
|
1731
|
+
|
|
1732
|
+
server.listen(port, () => {
|
|
1733
|
+
clearTimeout(timeout);
|
|
1734
|
+
successHandler();
|
|
1735
|
+
});
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
const address = server.address();
|
|
1739
|
+
const baseUrl = `http://localhost:${address.port}`;
|
|
1740
|
+
|
|
1741
|
+
console.log(`Server running at ${baseUrl}`);
|
|
1742
|
+
if (dataDir) {
|
|
1743
|
+
console.log(`Data directory: ${dataDir}`);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Only initialize watcher and heartbeat in non-test environments
|
|
1747
|
+
if (process.env.NODE_ENV !== "test") {
|
|
1748
|
+
console.log(`Watching paths: ${WATCHED_PATHS.join(", ")}`);
|
|
1749
|
+
initializeWatcher();
|
|
1750
|
+
startHeartbeat();
|
|
1751
|
+
} else {
|
|
1752
|
+
console.log("Server started in test mode - skipping watcher/heartbeat");
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
return {
|
|
1756
|
+
url: baseUrl,
|
|
1757
|
+
close: async () => {
|
|
1758
|
+
// Clean up all resources
|
|
1759
|
+
if (heartbeatTimer) {
|
|
1760
|
+
clearInterval(heartbeatTimer);
|
|
1761
|
+
heartbeatTimer = null;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (watcher) {
|
|
1765
|
+
await stopWatcher(watcher);
|
|
1766
|
+
watcher = null;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
sseRegistry.closeAll();
|
|
1770
|
+
|
|
1771
|
+
// Close Vite dev server if running (development single-process mode)
|
|
1772
|
+
if (viteServer && typeof viteServer.close === "function") {
|
|
1773
|
+
try {
|
|
1774
|
+
await viteServer.close();
|
|
1775
|
+
viteServer = null;
|
|
1776
|
+
console.log("DEBUG: Vite dev server closed");
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
console.error("Error closing Vite dev server:", err);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// Close the HTTP server
|
|
1783
|
+
return new Promise((resolve) => server.close(resolve));
|
|
1784
|
+
},
|
|
1785
|
+
};
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
console.error("Failed to start server:", error);
|
|
1788
|
+
throw error; // Re-throw so tests can handle it
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
217
1792
|
// Export for testing
|
|
218
1793
|
export {
|
|
219
1794
|
createServer,
|
|
220
1795
|
start,
|
|
1796
|
+
startServer,
|
|
221
1797
|
broadcastStateUpdate,
|
|
222
|
-
|
|
1798
|
+
sseRegistry,
|
|
223
1799
|
initializeWatcher,
|
|
224
1800
|
state,
|
|
1801
|
+
resolveJobLifecycle,
|
|
225
1802
|
};
|
|
226
1803
|
|
|
227
1804
|
// Start server if run directly
|