@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +415 -24
  2. package/package.json +45 -8
  3. package/src/api/files.js +48 -0
  4. package/src/api/index.js +149 -53
  5. package/src/api/validators/seed.js +141 -0
  6. package/src/cli/index.js +456 -29
  7. package/src/cli/run-orchestrator.js +39 -0
  8. package/src/cli/update-pipeline-json.js +47 -0
  9. package/src/components/DAGGrid.jsx +649 -0
  10. package/src/components/JobCard.jsx +96 -0
  11. package/src/components/JobDetail.jsx +159 -0
  12. package/src/components/JobTable.jsx +202 -0
  13. package/src/components/Layout.jsx +134 -0
  14. package/src/components/TaskFilePane.jsx +570 -0
  15. package/src/components/UploadSeed.jsx +239 -0
  16. package/src/components/ui/badge.jsx +20 -0
  17. package/src/components/ui/button.jsx +43 -0
  18. package/src/components/ui/card.jsx +20 -0
  19. package/src/components/ui/focus-styles.css +60 -0
  20. package/src/components/ui/progress.jsx +26 -0
  21. package/src/components/ui/select.jsx +27 -0
  22. package/src/components/ui/separator.jsx +6 -0
  23. package/src/config/paths.js +99 -0
  24. package/src/core/config.js +270 -9
  25. package/src/core/file-io.js +202 -0
  26. package/src/core/module-loader.js +157 -0
  27. package/src/core/orchestrator.js +275 -294
  28. package/src/core/pipeline-runner.js +95 -41
  29. package/src/core/progress.js +66 -0
  30. package/src/core/status-writer.js +331 -0
  31. package/src/core/task-runner.js +719 -73
  32. package/src/core/validation.js +120 -1
  33. package/src/lib/utils.js +6 -0
  34. package/src/llm/README.md +139 -30
  35. package/src/llm/index.js +222 -72
  36. package/src/pages/PipelineDetail.jsx +111 -0
  37. package/src/pages/PromptPipelineDashboard.jsx +223 -0
  38. package/src/providers/deepseek.js +3 -15
  39. package/src/ui/client/adapters/job-adapter.js +258 -0
  40. package/src/ui/client/bootstrap.js +120 -0
  41. package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
  42. package/src/ui/client/hooks/useJobList.js +50 -0
  43. package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
  44. package/src/ui/client/hooks/useTicker.js +26 -0
  45. package/src/ui/client/index.css +31 -0
  46. package/src/ui/client/index.html +18 -0
  47. package/src/ui/client/main.jsx +38 -0
  48. package/src/ui/config-bridge.browser.js +149 -0
  49. package/src/ui/config-bridge.js +149 -0
  50. package/src/ui/config-bridge.node.js +310 -0
  51. package/src/ui/dist/assets/index-BDABnI-4.js +33399 -0
  52. package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -0
  53. package/src/ui/dist/index.html +19 -0
  54. package/src/ui/endpoints/job-endpoints.js +300 -0
  55. package/src/ui/file-reader.js +216 -0
  56. package/src/ui/job-change-detector.js +83 -0
  57. package/src/ui/job-index.js +231 -0
  58. package/src/ui/job-reader.js +274 -0
  59. package/src/ui/job-scanner.js +188 -0
  60. package/src/ui/public/app.js +3 -1
  61. package/src/ui/server.js +1636 -59
  62. package/src/ui/sse-enhancer.js +149 -0
  63. package/src/ui/sse.js +204 -0
  64. package/src/ui/state-snapshot.js +252 -0
  65. package/src/ui/transformers/list-transformer.js +347 -0
  66. package/src/ui/transformers/status-transformer.js +307 -0
  67. package/src/ui/watcher.js +61 -7
  68. package/src/utils/dag.js +101 -0
  69. package/src/utils/duration.js +126 -0
  70. package/src/utils/id-generator.js +30 -0
  71. package/src/utils/jobs.js +7 -0
  72. package/src/utils/pipelines.js +44 -0
  73. package/src/utils/task-files.js +271 -0
  74. package/src/utils/ui.jsx +76 -0
  75. package/src/ui/public/index.html +0 -53
  76. 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 = (process.env.WATCHED_PATHS || "pipeline-config,runs")
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
- * Send SSE message to a client
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 sendSSE(res, event, data) {
33
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
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
- * Broadcast state update to all SSE clients
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 broadcastStateUpdate(currentState) {
40
- const deadClients = new Set();
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
- sseClients.forEach((client) => {
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
- sendSSE(client, "state", currentState);
45
- } catch (err) {
46
- deadClients.add(client);
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
- // Clean up dead connections
51
- deadClients.forEach((client) => sseClients.delete(client));
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
- const deadClients = new Set();
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
- sseClients.forEach((client) => {
376
+ req.on("end", () => {
64
377
  try {
65
- client.write(":heartbeat\n\n");
66
- } catch (err) {
67
- deadClients.add(client);
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
- deadClients.forEach((client) => sseClients.delete(client));
72
- }, HEARTBEAT_INTERVAL);
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 public directory
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
- const server = http.createServer((req, res) => {
102
- const parsedUrl = url.parse(req.url, true);
103
- const pathname = parsedUrl.pathname;
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" && req.method === "GET") {
120
- res.writeHead(200, { "Content-Type": "application/json" });
121
- res.end(JSON.stringify(state.getState()));
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 (pathname === "/api/events" && req.method === "GET") {
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
- // Send initial state
135
- sendSSE(res, "state", state.getState());
1193
+ // Flush headers immediately
1194
+ res.flushHeaders();
136
1195
 
137
- // Add to clients
138
- sseClients.add(res);
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
- sseClients.delete(res);
1219
+ clearInterval(heartbeatInterval);
1220
+ sseRegistry.removeClient(res);
143
1221
  });
144
1222
 
145
1223
  return;
146
1224
  }
147
1225
 
148
- // Serve static files
149
- if (pathname === "/" || pathname === "/index.html") {
150
- serveStatic(res, path.join(__dirname, "public", "index.html"));
151
- } else if (pathname === "/app.js") {
152
- serveStatic(res, path.join(__dirname, "public", "app.js"));
153
- } else if (pathname === "/style.css") {
154
- serveStatic(res, path.join(__dirname, "public", "style.css"));
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
- res.writeHead(404);
157
- res.end("Not Found");
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(WATCHED_PATHS, (changes) => {
173
- // Update state for each change
174
- changes.forEach(({ path, type }) => {
175
- state.recordChange(path, type);
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
- // Broadcast updated state
179
- broadcastStateUpdate(state.getState());
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
- sseClients.forEach((client) => client.end());
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
- sseClients,
1798
+ sseRegistry,
223
1799
  initializeWatcher,
224
1800
  state,
1801
+ resolveJobLifecycle,
225
1802
  };
226
1803
 
227
1804
  // Start server if run directly