@ryanfw/prompt-orchestration-pipeline 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +11 -1
  2. package/src/cli/analyze-task.js +51 -0
  3. package/src/cli/index.js +8 -0
  4. package/src/components/AddPipelineSidebar.jsx +144 -0
  5. package/src/components/AnalysisProgressTray.jsx +87 -0
  6. package/src/components/DAGGrid.jsx +157 -47
  7. package/src/components/JobTable.jsx +4 -3
  8. package/src/components/Layout.jsx +142 -139
  9. package/src/components/MarkdownRenderer.jsx +149 -0
  10. package/src/components/PipelineDAGGrid.jsx +404 -0
  11. package/src/components/PipelineTypeTaskSidebar.jsx +96 -0
  12. package/src/components/SchemaPreviewPanel.jsx +97 -0
  13. package/src/components/StageTimeline.jsx +36 -0
  14. package/src/components/TaskAnalysisDisplay.jsx +227 -0
  15. package/src/components/TaskCreationSidebar.jsx +447 -0
  16. package/src/components/TaskDetailSidebar.jsx +119 -117
  17. package/src/components/TaskFilePane.jsx +94 -39
  18. package/src/components/ui/RestartJobModal.jsx +26 -6
  19. package/src/components/ui/StopJobModal.jsx +183 -0
  20. package/src/components/ui/button.jsx +59 -27
  21. package/src/components/ui/sidebar.jsx +118 -0
  22. package/src/config/models.js +99 -67
  23. package/src/core/config.js +11 -4
  24. package/src/core/lifecycle-policy.js +62 -0
  25. package/src/core/pipeline-runner.js +312 -217
  26. package/src/core/status-writer.js +84 -0
  27. package/src/llm/index.js +129 -9
  28. package/src/pages/Code.jsx +8 -1
  29. package/src/pages/PipelineDetail.jsx +84 -2
  30. package/src/pages/PipelineList.jsx +214 -0
  31. package/src/pages/PipelineTypeDetail.jsx +234 -0
  32. package/src/pages/PromptPipelineDashboard.jsx +10 -11
  33. package/src/providers/deepseek.js +76 -16
  34. package/src/providers/openai.js +61 -34
  35. package/src/task-analysis/enrichers/analysis-writer.js +62 -0
  36. package/src/task-analysis/enrichers/schema-deducer.js +145 -0
  37. package/src/task-analysis/enrichers/schema-writer.js +74 -0
  38. package/src/task-analysis/extractors/artifacts.js +137 -0
  39. package/src/task-analysis/extractors/llm-calls.js +176 -0
  40. package/src/task-analysis/extractors/stages.js +51 -0
  41. package/src/task-analysis/index.js +103 -0
  42. package/src/task-analysis/parser.js +28 -0
  43. package/src/task-analysis/utils/ast.js +43 -0
  44. package/src/ui/client/adapters/job-adapter.js +60 -0
  45. package/src/ui/client/api.js +233 -8
  46. package/src/ui/client/hooks/useAnalysisProgress.js +145 -0
  47. package/src/ui/client/hooks/useJobList.js +14 -1
  48. package/src/ui/client/index.css +64 -0
  49. package/src/ui/client/main.jsx +4 -0
  50. package/src/ui/client/sse-fetch.js +120 -0
  51. package/src/ui/dist/app.js +262 -0
  52. package/src/ui/dist/assets/index-cjHV9mYW.js +82578 -0
  53. package/src/ui/dist/assets/index-cjHV9mYW.js.map +1 -0
  54. package/src/ui/dist/assets/style-CoM9SoQF.css +180 -0
  55. package/src/ui/dist/favicon.svg +12 -0
  56. package/src/ui/dist/index.html +2 -2
  57. package/src/ui/endpoints/create-pipeline-endpoint.js +194 -0
  58. package/src/ui/endpoints/file-endpoints.js +330 -0
  59. package/src/ui/endpoints/job-control-endpoints.js +1001 -0
  60. package/src/ui/endpoints/job-endpoints.js +62 -0
  61. package/src/ui/endpoints/pipeline-analysis-endpoint.js +246 -0
  62. package/src/ui/endpoints/pipeline-type-detail-endpoint.js +181 -0
  63. package/src/ui/endpoints/pipelines-endpoint.js +133 -0
  64. package/src/ui/endpoints/schema-file-endpoint.js +105 -0
  65. package/src/ui/endpoints/sse-endpoints.js +223 -0
  66. package/src/ui/endpoints/state-endpoint.js +85 -0
  67. package/src/ui/endpoints/task-analysis-endpoint.js +104 -0
  68. package/src/ui/endpoints/task-creation-endpoint.js +114 -0
  69. package/src/ui/endpoints/task-save-endpoint.js +101 -0
  70. package/src/ui/endpoints/upload-endpoints.js +406 -0
  71. package/src/ui/express-app.js +227 -0
  72. package/src/ui/lib/analysis-lock.js +67 -0
  73. package/src/ui/lib/sse.js +30 -0
  74. package/src/ui/server.js +42 -1880
  75. package/src/ui/sse-broadcast.js +93 -0
  76. package/src/ui/utils/http-utils.js +139 -0
  77. package/src/ui/utils/mime-types.js +196 -0
  78. package/src/ui/utils/slug.js +31 -0
  79. package/src/ui/vite.config.js +22 -0
  80. package/src/ui/watcher.js +28 -2
  81. package/src/utils/jobs.js +39 -0
  82. package/src/ui/dist/assets/index-DeDzq-Kk.js +0 -23863
  83. package/src/ui/dist/assets/style-aBtD_Yrs.css +0 -62
@@ -0,0 +1,12 @@
1
+ <svg
2
+ xmlns="http://www.w3.org/2000/svg"
3
+ width="32"
4
+ height="32"
5
+ viewBox="0 0 1200 1200"
6
+ >
7
+ <path
8
+ fill="#009966"
9
+ d="M406.13 988.31c-17.297 75.047-84.562 131.11-164.86 131.11-93.375 0-169.18-75.797-169.18-169.18s75.797-169.18 169.18-169.18 169.18 75.797 169.18 169.18v1.266h447.74v-167.9H671.63c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.703-16.406-39.562v-37.312h-317.16c-10.312 0-18.656-8.344-18.656-18.656V355.78h-147.94c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.75-16.406-39.562v-111.94c0-14.859 5.906-29.109 16.406-39.562 10.5-10.5 24.75-16.406 39.562-16.406h391.78c14.859 0 29.062 5.906 39.562 16.406s16.406 24.75 16.406 39.562v37.312h202.4c9.281-84.609 81.094-150.52 168.14-150.52 93.375 0 169.18 75.797 169.18 169.18s-75.797 169.18-169.18 169.18c-87.047 0-158.86-65.906-168.14-150.52h-202.4v37.312c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-206.53v297.24h298.5v-37.312c0-14.859 5.906-29.062 16.406-39.562s24.703-16.406 39.562-16.406h392.63c14.859 0 29.062 5.906 39.562 16.406s16.406 24.703 16.406 39.562v111.94c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-168.74v186.56c0 10.312-8.344 18.656-18.656 18.656h-466.4c-1.5 0-2.906-.187-4.312-.516zM225.19 262.45h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H225.19c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H411.75c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm616.18 0h85.5c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-85.5l29.062-22.594c8.109-6.328 9.609-18.047 3.281-26.156s-18.047-9.609-26.156-3.281l-71.953 55.969a18.61 18.61 0 0 0 0 29.438l71.953 55.969c8.109 6.328 19.875 4.875 26.156-3.281 6.328-8.109 4.875-19.875-3.281-26.203l-29.062-22.594zm-779.95 696.66l50.391 50.391c7.266 7.313 19.078 7.313 26.391 0l100.73-100.73c7.266-7.266 7.266-19.078 0-26.391-7.266-7.266-19.078-7.266-26.391 0l-87.562 87.562-37.172-37.172c-7.266-7.266-19.078-7.266-26.391 0-7.266 7.266-7.266 19.078 0 26.391zm797.21-268.78h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H858.63c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656z"
10
+ fill-rule="evenodd"
11
+ />
12
+ </svg>
@@ -11,8 +11,8 @@
11
11
  />
12
12
  <title>Prompt Pipeline Dashboard</title>
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
- <script type="module" crossorigin src="/assets/index-DeDzq-Kk.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/style-aBtD_Yrs.css">
14
+ <script type="module" crossorigin src="/assets/index-cjHV9mYW.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/style-CoM9SoQF.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Create pipeline endpoint (logic-only)
3
+ *
4
+ * Exports:
5
+ * - handleCreatePipeline(req, res) -> HTTP request handler
6
+ *
7
+ * This function creates a new pipeline type by:
8
+ * - Validating name and description
9
+ * - Generating a slug from the provided name
10
+ * - Ensuring slug uniqueness in the registry
11
+ * - Creating directory structure and starter files
12
+ * - Updating the pipeline registry atomically
13
+ */
14
+
15
+ import { getConfig } from "../../core/config.js";
16
+ import { generateSlug, ensureUniqueSlug } from "../utils/slug.js";
17
+ import { promises as fs } from "node:fs";
18
+ import path from "node:path";
19
+
20
+ /**
21
+ * Create starter files for a new pipeline
22
+ */
23
+ async function createStarterFiles(pipelineDir, slug, name, description) {
24
+ // Create tasks directory
25
+ const tasksDir = path.join(pipelineDir, "tasks");
26
+ await fs.mkdir(tasksDir, { recursive: true });
27
+
28
+ // Create pipeline.json with correct schema
29
+ const pipelineJsonPath = path.join(pipelineDir, "pipeline.json");
30
+ const pipelineJsonContent = JSON.stringify(
31
+ {
32
+ name: slug,
33
+ version: "1.0.0",
34
+ description: description,
35
+ tasks: [],
36
+ },
37
+ null,
38
+ 2
39
+ );
40
+ await fs.writeFile(pipelineJsonPath, pipelineJsonContent, "utf8");
41
+
42
+ // Create tasks/index.js
43
+ const tasksIndexPath = path.join(tasksDir, "index.js");
44
+ const tasksIndexContent = `// Task registry for ${slug}\nmodule.exports = { tasks: {} };\n`;
45
+ await fs.writeFile(tasksIndexPath, tasksIndexContent, "utf8");
46
+ }
47
+
48
+ /**
49
+ * Handle pipeline creation request
50
+ *
51
+ * Behavior:
52
+ * - Validate name and description are present
53
+ * - Generate slug from name (kebab-case, max 47 chars)
54
+ * - Ensure slug uniqueness in registry
55
+ * - Create directory structure and starter files
56
+ * - Update registry.json atomically using temp file
57
+ * - Return slug on success
58
+ */
59
+ export async function handleCreatePipeline(req, res) {
60
+ console.log("[CreatePipelineEndpoint] POST /api/pipelines called");
61
+
62
+ try {
63
+ const { name, description } = req.body;
64
+
65
+ // Validate required fields
66
+ if (!name || typeof name !== "string" || name.trim() === "") {
67
+ res.status(400).json({ error: "Name and description are required" });
68
+ return;
69
+ }
70
+
71
+ if (
72
+ !description ||
73
+ typeof description !== "string" ||
74
+ description.trim() === ""
75
+ ) {
76
+ res.status(400).json({ error: "Name and description are required" });
77
+ return;
78
+ }
79
+
80
+ const config = getConfig();
81
+ const rootDir = config.paths?.root;
82
+
83
+ if (!rootDir) {
84
+ res.status(500).json({ error: "Failed to create pipeline" });
85
+ return;
86
+ }
87
+
88
+ const pipelineConfigDir = path.join(rootDir, "pipeline-config");
89
+ const registryPath = path.join(pipelineConfigDir, "registry.json");
90
+
91
+ // Read existing registry
92
+ let registryData;
93
+ try {
94
+ const contents = await fs.readFile(registryPath, "utf8");
95
+ registryData = JSON.parse(contents);
96
+ } catch (error) {
97
+ if (error.code === "ENOENT") {
98
+ // Create registry file with empty pipelines object
99
+ await fs.mkdir(pipelineConfigDir, { recursive: true });
100
+ registryData = { pipelines: {} };
101
+ } else if (error instanceof SyntaxError) {
102
+ console.error(
103
+ "[CreatePipelineEndpoint] Invalid JSON in registry:",
104
+ error
105
+ );
106
+ res.status(500).json({ error: "Failed to create pipeline" });
107
+ return;
108
+ } else {
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ // Validate registry structure
114
+ if (
115
+ !registryData ||
116
+ typeof registryData !== "object" ||
117
+ !registryData.pipelines ||
118
+ typeof registryData.pipelines !== "object"
119
+ ) {
120
+ console.error("[CreatePipelineEndpoint] Invalid registry structure");
121
+ res.status(500).json({ error: "Failed to create pipeline" });
122
+ return;
123
+ }
124
+
125
+ // Generate unique slug
126
+ const baseSlug = generateSlug(name.trim());
127
+ if (!baseSlug) {
128
+ res
129
+ .status(400)
130
+ .json({ error: "Invalid pipeline name; unable to generate slug" });
131
+ return;
132
+ }
133
+ const existingSlugs = new Set(Object.keys(registryData.pipelines));
134
+ const slug = ensureUniqueSlug(baseSlug, existingSlugs);
135
+
136
+ // Generate paths
137
+ const pipelineDir = path.join(pipelineConfigDir, slug);
138
+ const pipelinePath = path.join("pipeline-config", slug, "pipeline.json");
139
+ const taskRegistryPath = path.join(
140
+ "pipeline-config",
141
+ slug,
142
+ "tasks/index.js"
143
+ );
144
+
145
+ // Create starter files
146
+ try {
147
+ await createStarterFiles(
148
+ pipelineDir,
149
+ slug,
150
+ name.trim(),
151
+ description.trim()
152
+ );
153
+ } catch (error) {
154
+ console.error("[CreatePipelineEndpoint] Failed to create files:", error);
155
+ res.status(500).json({ error: "Failed to create pipeline" });
156
+ return;
157
+ }
158
+
159
+ // Update registry atomically using temp file
160
+ try {
161
+ registryData.pipelines[slug] = {
162
+ name: name.trim(),
163
+ description: description.trim(),
164
+ pipelinePath,
165
+ taskRegistryPath,
166
+ };
167
+
168
+ const tempPath = `${registryPath}.${Date.now()}.tmp`;
169
+ await fs.writeFile(
170
+ tempPath,
171
+ JSON.stringify(registryData, null, 2),
172
+ "utf8"
173
+ );
174
+ await fs.rename(tempPath, registryPath);
175
+ } catch (error) {
176
+ console.error(
177
+ "[CreatePipelineEndpoint] Failed to update registry:",
178
+ error
179
+ );
180
+ res.status(500).json({ error: "Failed to create pipeline" });
181
+ return;
182
+ }
183
+
184
+ console.log(
185
+ "[CreatePipelineEndpoint] Pipeline created successfully:",
186
+ slug
187
+ );
188
+
189
+ res.status(200).json({ slug });
190
+ } catch (err) {
191
+ console.error("[CreatePipelineEndpoint] Unexpected error:", err);
192
+ res.status(500).json({ error: "Failed to create pipeline" });
193
+ }
194
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * File endpoint handlers for task file operations
3
+ */
4
+
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { sendJson } from "../utils/http-utils.js";
8
+ import { getMimeType, isTextMime } from "../utils/mime-types.js";
9
+ import { getJobDirectoryPath } from "../../config/paths.js";
10
+
11
+ const exists = async (p) =>
12
+ fs.promises
13
+ .access(p)
14
+ .then(() => true)
15
+ .catch(() => false);
16
+
17
+ /**
18
+ * Resolve job lifecycle directory deterministically
19
+ * @param {string} dataDir - Base data directory
20
+ * @param {string} jobId - Job identifier
21
+ * @returns {Promise<string|null>} One of "current", "complete", "rejected", or null if job not found
22
+ */
23
+ async function resolveJobLifecycle(dataDir, jobId) {
24
+ const currentJobDir = getJobDirectoryPath(dataDir, jobId, "current");
25
+ const completeJobDir = getJobDirectoryPath(dataDir, jobId, "complete");
26
+ const rejectedJobDir = getJobDirectoryPath(dataDir, jobId, "rejected");
27
+
28
+ // Check in order of preference: current > complete > rejected
29
+ const currentExists = await exists(currentJobDir);
30
+ const completeExists = await exists(completeJobDir);
31
+ const rejectedExists = await exists(rejectedJobDir);
32
+
33
+ if (currentExists) {
34
+ return "current";
35
+ }
36
+
37
+ if (completeExists) {
38
+ return "complete";
39
+ }
40
+
41
+ if (rejectedExists) {
42
+ return "rejected";
43
+ }
44
+
45
+ // Job not found in any lifecycle
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Consolidated path jail security validation with generic error messages
51
+ * @param {string} filename - Filename to validate
52
+ * @returns {Object|null} Validation result or null if valid
53
+ */
54
+ export function validateFilePath(filename) {
55
+ // Check for path traversal patterns
56
+ if (filename.includes("..")) {
57
+ console.error("Path security: path traversal detected", { filename });
58
+ return {
59
+ allowed: false,
60
+ message: "Path validation failed",
61
+ };
62
+ }
63
+
64
+ // Check for absolute paths (POSIX, Windows, backslashes, ~)
65
+ if (
66
+ path.isAbsolute(filename) ||
67
+ /^[a-zA-Z]:/.test(filename) ||
68
+ filename.includes("\\") ||
69
+ filename.startsWith("~")
70
+ ) {
71
+ console.error("Path security: absolute path detected", { filename });
72
+ return {
73
+ allowed: false,
74
+ message: "Path validation failed",
75
+ };
76
+ }
77
+
78
+ // Check for empty filename
79
+ if (!filename || filename.trim() === "") {
80
+ console.error("Path security: empty filename detected");
81
+ return {
82
+ allowed: false,
83
+ message: "Path validation failed",
84
+ };
85
+ }
86
+
87
+ // Path is valid
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Handle task file list request with validation and security checks
93
+ * @param {http.IncomingMessage} req - HTTP request
94
+ * @param {http.ServerResponse} res - HTTP response
95
+ * @param {Object} params - Request parameters
96
+ * @param {string} params.jobId - Job ID
97
+ * @param {string} params.taskId - Task ID
98
+ * @param {string} params.type - File type (artifacts, logs, tmp)
99
+ * @param {string} params.dataDir - Data directory
100
+ */
101
+ export async function handleTaskFileListRequest(
102
+ req,
103
+ res,
104
+ { jobId, taskId, type, dataDir }
105
+ ) {
106
+ // Resolve job lifecycle deterministically
107
+ const lifecycle = await resolveJobLifecycle(dataDir, jobId);
108
+ if (!lifecycle) {
109
+ // Job not found, return empty list
110
+ sendJson(res, 200, {
111
+ ok: true,
112
+ data: {
113
+ files: [],
114
+ jobId,
115
+ taskId,
116
+ type,
117
+ },
118
+ });
119
+ return;
120
+ }
121
+
122
+ // Use single lifecycle directory
123
+ const jobDir = getJobDirectoryPath(dataDir, jobId, lifecycle);
124
+ const taskDir = path.join(jobDir, "files", type);
125
+
126
+ // Use path.relative for stricter jail enforcement
127
+ const resolvedPath = path.resolve(taskDir);
128
+ const relativePath = path.relative(jobDir, resolvedPath);
129
+
130
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
131
+ console.error("Path security: directory traversal detected", {
132
+ taskDir,
133
+ relativePath,
134
+ });
135
+ sendJson(res, 403, {
136
+ ok: false,
137
+ error: "forbidden",
138
+ message: "Path validation failed",
139
+ });
140
+ return;
141
+ }
142
+
143
+ // Check if directory exists
144
+ if (!(await exists(taskDir))) {
145
+ // Directory doesn't exist, return empty list
146
+ sendJson(res, 200, {
147
+ ok: true,
148
+ data: {
149
+ files: [],
150
+ jobId,
151
+ taskId,
152
+ type,
153
+ },
154
+ });
155
+ return;
156
+ }
157
+
158
+ try {
159
+ // Read directory contents
160
+ const entries = await fs.promises.readdir(taskDir, {
161
+ withFileTypes: true,
162
+ });
163
+
164
+ // Filter and map to file list
165
+ const files = [];
166
+ for (const entry of entries) {
167
+ if (entry.isFile()) {
168
+ // Validate each filename using the consolidated function
169
+ const validation = validateFilePath(entry.name);
170
+ if (validation) {
171
+ console.error("Path security: skipping invalid file", {
172
+ filename: entry.name,
173
+ reason: validation.message,
174
+ });
175
+ continue; // Skip files that fail validation
176
+ }
177
+
178
+ const filePath = path.join(taskDir, entry.name);
179
+ const stats = await fs.promises.stat(filePath);
180
+
181
+ files.push({
182
+ name: entry.name,
183
+ size: stats.size,
184
+ mtime: stats.mtime.toISOString(),
185
+ mime: getMimeType(entry.name),
186
+ });
187
+ }
188
+ }
189
+
190
+ // Sort files by name
191
+ files.sort((a, b) => a.name.localeCompare(b.name));
192
+
193
+ // Send successful response
194
+ sendJson(res, 200, {
195
+ ok: true,
196
+ data: {
197
+ files,
198
+ jobId,
199
+ taskId,
200
+ type,
201
+ },
202
+ });
203
+ } catch (error) {
204
+ console.error("Error listing files:", error);
205
+ sendJson(res, 500, {
206
+ ok: false,
207
+ error: "internal_error",
208
+ message: "Failed to list files",
209
+ });
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Handle task file request with validation, jail checks, and proper encoding
215
+ * @param {http.IncomingMessage} req - HTTP request
216
+ * @param {http.ServerResponse} res - HTTP response
217
+ * @param {Object} params - Request parameters
218
+ * @param {string} params.jobId - Job ID
219
+ * @param {string} params.taskId - Task ID
220
+ * @param {string} params.type - File type (artifacts, logs, tmp)
221
+ * @param {string} params.filename - Filename
222
+ * @param {string} params.dataDir - Data directory
223
+ */
224
+ export async function handleTaskFileRequest(
225
+ req,
226
+ res,
227
+ { jobId, taskId, type, filename, dataDir }
228
+ ) {
229
+ // Unified security validation
230
+ const validation = validateFilePath(filename);
231
+ if (validation) {
232
+ sendJson(res, 403, {
233
+ ok: false,
234
+ error: "forbidden",
235
+ message: validation.message,
236
+ });
237
+ return;
238
+ }
239
+
240
+ // Resolve job lifecycle deterministically
241
+ const lifecycle = await resolveJobLifecycle(dataDir, jobId);
242
+ if (!lifecycle) {
243
+ sendJson(res, 404, {
244
+ ok: false,
245
+ error: "not_found",
246
+ message: "Job not found",
247
+ });
248
+ return;
249
+ }
250
+
251
+ // Use single lifecycle directory
252
+ const jobDir = getJobDirectoryPath(dataDir, jobId, lifecycle);
253
+ const taskDir = path.join(jobDir, "files", type);
254
+ const filePath = path.join(taskDir, filename);
255
+
256
+ // Use path.relative for stricter jail enforcement
257
+ const resolvedPath = path.resolve(filePath);
258
+ const relativePath = path.relative(jobDir, resolvedPath);
259
+
260
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
261
+ sendJson(res, 403, {
262
+ ok: false,
263
+ error: "forbidden",
264
+ message: "Path resolves outside allowed directory",
265
+ });
266
+ return;
267
+ }
268
+
269
+ // Check if file exists
270
+ if (!(await exists(filePath))) {
271
+ sendJson(res, 404, {
272
+ ok: false,
273
+ error: "not_found",
274
+ message: "File not found",
275
+ filePath,
276
+ });
277
+ return;
278
+ }
279
+
280
+ try {
281
+ // Get file stats
282
+ const stats = await fs.promises.stat(filePath);
283
+ if (!stats.isFile()) {
284
+ sendJson(res, 404, {
285
+ ok: false,
286
+ error: "not_found",
287
+ message: "Not a regular file",
288
+ });
289
+ return;
290
+ }
291
+
292
+ // Determine MIME type and encoding
293
+ const mime = getMimeType(filename);
294
+ const isText = isTextMime(mime);
295
+ const encoding = isText ? "utf8" : "base64";
296
+
297
+ // Read file content
298
+ let content;
299
+ if (isText) {
300
+ content = await fs.promises.readFile(filePath, "utf8");
301
+ } else {
302
+ const buffer = await fs.promises.readFile(filePath);
303
+ content = buffer.toString("base64");
304
+ }
305
+
306
+ // Build relative path for response
307
+ const relativePath = path.join("tasks", taskId, type, filename);
308
+
309
+ // Send successful response
310
+ sendJson(res, 200, {
311
+ ok: true,
312
+ jobId,
313
+ taskId,
314
+ type,
315
+ path: relativePath,
316
+ mime,
317
+ size: stats.size,
318
+ mtime: stats.mtime.toISOString(),
319
+ encoding,
320
+ content,
321
+ });
322
+ } catch (error) {
323
+ console.error("Error reading file:", error);
324
+ sendJson(res, 500, {
325
+ ok: false,
326
+ error: "internal_error",
327
+ message: "Failed to read file",
328
+ });
329
+ }
330
+ }