@ryanfw/prompt-orchestration-pipeline 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -1
- package/src/cli/analyze-task.js +51 -0
- package/src/cli/index.js +8 -0
- package/src/components/AddPipelineSidebar.jsx +144 -0
- package/src/components/AnalysisProgressTray.jsx +87 -0
- package/src/components/DAGGrid.jsx +157 -47
- package/src/components/JobTable.jsx +4 -3
- package/src/components/Layout.jsx +142 -139
- package/src/components/MarkdownRenderer.jsx +149 -0
- package/src/components/PipelineDAGGrid.jsx +404 -0
- package/src/components/PipelineTypeTaskSidebar.jsx +96 -0
- package/src/components/SchemaPreviewPanel.jsx +97 -0
- package/src/components/StageTimeline.jsx +36 -0
- package/src/components/TaskAnalysisDisplay.jsx +227 -0
- package/src/components/TaskCreationSidebar.jsx +447 -0
- package/src/components/TaskDetailSidebar.jsx +119 -117
- package/src/components/TaskFilePane.jsx +94 -39
- package/src/components/ui/RestartJobModal.jsx +26 -6
- package/src/components/ui/StopJobModal.jsx +183 -0
- package/src/components/ui/button.jsx +59 -27
- package/src/components/ui/sidebar.jsx +118 -0
- package/src/config/models.js +99 -67
- package/src/core/config.js +11 -4
- package/src/core/lifecycle-policy.js +62 -0
- package/src/core/pipeline-runner.js +312 -217
- package/src/core/status-writer.js +84 -0
- package/src/llm/index.js +129 -9
- package/src/pages/Code.jsx +8 -1
- package/src/pages/PipelineDetail.jsx +84 -2
- package/src/pages/PipelineList.jsx +214 -0
- package/src/pages/PipelineTypeDetail.jsx +234 -0
- package/src/pages/PromptPipelineDashboard.jsx +10 -11
- package/src/providers/deepseek.js +76 -16
- package/src/providers/openai.js +61 -34
- package/src/task-analysis/enrichers/analysis-writer.js +62 -0
- package/src/task-analysis/enrichers/schema-deducer.js +145 -0
- package/src/task-analysis/enrichers/schema-writer.js +74 -0
- package/src/task-analysis/extractors/artifacts.js +137 -0
- package/src/task-analysis/extractors/llm-calls.js +176 -0
- package/src/task-analysis/extractors/stages.js +51 -0
- package/src/task-analysis/index.js +103 -0
- package/src/task-analysis/parser.js +28 -0
- package/src/task-analysis/utils/ast.js +43 -0
- package/src/ui/client/adapters/job-adapter.js +60 -0
- package/src/ui/client/api.js +233 -8
- package/src/ui/client/hooks/useAnalysisProgress.js +145 -0
- package/src/ui/client/hooks/useJobList.js +14 -1
- package/src/ui/client/index.css +64 -0
- package/src/ui/client/main.jsx +4 -0
- package/src/ui/client/sse-fetch.js +120 -0
- package/src/ui/dist/app.js +262 -0
- package/src/ui/dist/assets/index-cjHV9mYW.js +82578 -0
- package/src/ui/dist/assets/index-cjHV9mYW.js.map +1 -0
- package/src/ui/dist/assets/style-CoM9SoQF.css +180 -0
- package/src/ui/dist/favicon.svg +12 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/endpoints/create-pipeline-endpoint.js +194 -0
- package/src/ui/endpoints/file-endpoints.js +330 -0
- package/src/ui/endpoints/job-control-endpoints.js +1001 -0
- package/src/ui/endpoints/job-endpoints.js +62 -0
- package/src/ui/endpoints/pipeline-analysis-endpoint.js +246 -0
- package/src/ui/endpoints/pipeline-type-detail-endpoint.js +181 -0
- package/src/ui/endpoints/pipelines-endpoint.js +133 -0
- package/src/ui/endpoints/schema-file-endpoint.js +105 -0
- package/src/ui/endpoints/sse-endpoints.js +223 -0
- package/src/ui/endpoints/state-endpoint.js +85 -0
- package/src/ui/endpoints/task-analysis-endpoint.js +104 -0
- package/src/ui/endpoints/task-creation-endpoint.js +114 -0
- package/src/ui/endpoints/task-save-endpoint.js +101 -0
- package/src/ui/endpoints/upload-endpoints.js +406 -0
- package/src/ui/express-app.js +227 -0
- package/src/ui/lib/analysis-lock.js +67 -0
- package/src/ui/lib/sse.js +30 -0
- package/src/ui/server.js +42 -1880
- package/src/ui/sse-broadcast.js +93 -0
- package/src/ui/utils/http-utils.js +139 -0
- package/src/ui/utils/mime-types.js +196 -0
- package/src/ui/utils/slug.js +31 -0
- package/src/ui/vite.config.js +22 -0
- package/src/ui/watcher.js +28 -2
- package/src/utils/jobs.js +39 -0
- package/src/ui/dist/assets/index-DeDzq-Kk.js +0 -23863
- package/src/ui/dist/assets/style-aBtD_Yrs.css +0 -62
|
@@ -0,0 +1,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>
|
package/src/ui/dist/index.html
CHANGED
|
@@ -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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/style-
|
|
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
|
+
}
|