@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.
- package/README.md +415 -24
- package/package.json +45 -8
- package/src/api/files.js +48 -0
- package/src/api/index.js +149 -53
- package/src/api/validators/seed.js +141 -0
- package/src/cli/index.js +456 -29
- package/src/cli/run-orchestrator.js +39 -0
- package/src/cli/update-pipeline-json.js +47 -0
- package/src/components/DAGGrid.jsx +649 -0
- package/src/components/JobCard.jsx +96 -0
- package/src/components/JobDetail.jsx +159 -0
- package/src/components/JobTable.jsx +202 -0
- package/src/components/Layout.jsx +134 -0
- package/src/components/TaskFilePane.jsx +570 -0
- package/src/components/UploadSeed.jsx +239 -0
- package/src/components/ui/badge.jsx +20 -0
- package/src/components/ui/button.jsx +43 -0
- package/src/components/ui/card.jsx +20 -0
- package/src/components/ui/focus-styles.css +60 -0
- package/src/components/ui/progress.jsx +26 -0
- package/src/components/ui/select.jsx +27 -0
- package/src/components/ui/separator.jsx +6 -0
- package/src/config/paths.js +99 -0
- package/src/core/config.js +270 -9
- package/src/core/file-io.js +202 -0
- package/src/core/module-loader.js +157 -0
- package/src/core/orchestrator.js +275 -294
- package/src/core/pipeline-runner.js +95 -41
- package/src/core/progress.js +66 -0
- package/src/core/status-writer.js +331 -0
- package/src/core/task-runner.js +719 -73
- package/src/core/validation.js +120 -1
- package/src/lib/utils.js +6 -0
- package/src/llm/README.md +139 -30
- package/src/llm/index.js +222 -72
- package/src/pages/PipelineDetail.jsx +111 -0
- package/src/pages/PromptPipelineDashboard.jsx +223 -0
- package/src/providers/deepseek.js +3 -15
- package/src/ui/client/adapters/job-adapter.js +258 -0
- package/src/ui/client/bootstrap.js +120 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
- package/src/ui/client/hooks/useJobList.js +50 -0
- package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
- package/src/ui/client/hooks/useTicker.js +26 -0
- package/src/ui/client/index.css +31 -0
- package/src/ui/client/index.html +18 -0
- package/src/ui/client/main.jsx +38 -0
- package/src/ui/config-bridge.browser.js +149 -0
- package/src/ui/config-bridge.js +149 -0
- package/src/ui/config-bridge.node.js +310 -0
- package/src/ui/dist/assets/index-BDABnI-4.js +33399 -0
- package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -0
- package/src/ui/dist/index.html +19 -0
- package/src/ui/endpoints/job-endpoints.js +300 -0
- package/src/ui/file-reader.js +216 -0
- package/src/ui/job-change-detector.js +83 -0
- package/src/ui/job-index.js +231 -0
- package/src/ui/job-reader.js +274 -0
- package/src/ui/job-scanner.js +188 -0
- package/src/ui/public/app.js +3 -1
- package/src/ui/server.js +1636 -59
- package/src/ui/sse-enhancer.js +149 -0
- package/src/ui/sse.js +204 -0
- package/src/ui/state-snapshot.js +252 -0
- package/src/ui/transformers/list-transformer.js +347 -0
- package/src/ui/transformers/status-transformer.js +307 -0
- package/src/ui/watcher.js +61 -7
- package/src/utils/dag.js +101 -0
- package/src/utils/duration.js +126 -0
- package/src/utils/id-generator.js +30 -0
- package/src/utils/jobs.js +7 -0
- package/src/utils/pipelines.js +44 -0
- package/src/utils/task-files.js +271 -0
- package/src/utils/ui.jsx +76 -0
- package/src/ui/public/index.html +0 -53
- package/src/ui/public/style.css +0 -341
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
7
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
8
|
+
<link
|
|
9
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
|
|
10
|
+
rel="stylesheet"
|
|
11
|
+
/>
|
|
12
|
+
<title>Prompt Pipeline Dashboard</title>
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BDABnI-4.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/style-Ks8LY8gB.css">
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="root"></div>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job endpoints (logic-only)
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - handleJobList() -> { ok: true, data: [...] } | error envelope
|
|
6
|
+
* - handleJobDetail(jobId) -> { ok: true, data: {...} } | error envelope
|
|
7
|
+
* - getEndpointStats(jobListResponses, jobDetailResponses) -> stats object
|
|
8
|
+
*
|
|
9
|
+
* These functions return structured results (not HTTP responses) so the server
|
|
10
|
+
* can map them to HTTP status codes. Tests mock underlying modules and expect
|
|
11
|
+
* these functions to call the mocked methods in particular ways.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { listJobs } from "../job-scanner.js";
|
|
15
|
+
import { readJob } from "../job-reader.js";
|
|
16
|
+
import { transformMultipleJobs } from "../transformers/status-transformer.js";
|
|
17
|
+
import {
|
|
18
|
+
aggregateAndSortJobs,
|
|
19
|
+
transformJobListForAPI,
|
|
20
|
+
} from "../transformers/list-transformer.js";
|
|
21
|
+
import * as configBridge from "../config-bridge.js";
|
|
22
|
+
import fs from "node:fs/promises";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { getJobPipelinePath } from "../../config/paths.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Return a list of job summaries suitable for the API.
|
|
28
|
+
*
|
|
29
|
+
* Behavior (matching tests):
|
|
30
|
+
* - call listJobs("current") then listJobs("complete")
|
|
31
|
+
* - for each id (current then complete), call readJob(id, location)
|
|
32
|
+
* - collect read results into an array and pass to transformMultipleJobs()
|
|
33
|
+
* - aggregate current/complete via aggregateAndSortJobs and finally transformJobListForAPI
|
|
34
|
+
*/
|
|
35
|
+
export async function handleJobList() {
|
|
36
|
+
console.log("[JobEndpoints] GET /api/jobs called");
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const currentIds = await listJobs("current");
|
|
40
|
+
const completeIds = await listJobs("complete");
|
|
41
|
+
|
|
42
|
+
// Instrumentation: log resolved paths and check for pipeline.json presence
|
|
43
|
+
// Only run in non-test environments when explicitly enabled
|
|
44
|
+
const shouldInstrument =
|
|
45
|
+
process.env.NODE_ENV !== "test" &&
|
|
46
|
+
(process.env.JOB_ENDPOINTS_INSTRUMENT === "1" ||
|
|
47
|
+
process.env.UI_LOG_LEVEL === "debug");
|
|
48
|
+
|
|
49
|
+
if (shouldInstrument) {
|
|
50
|
+
try {
|
|
51
|
+
const paths =
|
|
52
|
+
(typeof configBridge.getPATHS === "function" &&
|
|
53
|
+
configBridge.getPATHS()) ||
|
|
54
|
+
configBridge.PATHS ||
|
|
55
|
+
(typeof configBridge.resolvePipelinePaths === "function" &&
|
|
56
|
+
(function () {
|
|
57
|
+
try {
|
|
58
|
+
return configBridge.resolvePipelinePaths();
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
})()) ||
|
|
63
|
+
null;
|
|
64
|
+
|
|
65
|
+
console.log("[JobEndpoints] resolved PATHS:", paths);
|
|
66
|
+
|
|
67
|
+
// Log per-job pipeline snapshots by reading job-scoped pipeline.json files
|
|
68
|
+
const allJobIds = [...(currentIds || []), ...(completeIds || [])];
|
|
69
|
+
for (const jobId of allJobIds) {
|
|
70
|
+
try {
|
|
71
|
+
const jobPipelinePath = getJobPipelinePath(
|
|
72
|
+
process.env.PO_ROOT || process.cwd(),
|
|
73
|
+
jobId,
|
|
74
|
+
currentIds.includes(jobId) ? "current" : "complete"
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await fs.access(jobPipelinePath);
|
|
78
|
+
console.log(
|
|
79
|
+
`[JobEndpoints] pipeline.json exists for job ${jobId} at ${jobPipelinePath}`
|
|
80
|
+
);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.log(
|
|
83
|
+
`[JobEndpoints] pipeline.json NOT found for job ${jobId}: ${err?.message}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (instrErr) {
|
|
88
|
+
console.error("JobEndpoints instrumentation error:", instrErr);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Read jobs in two phases to respect precedence and match test expectations:
|
|
93
|
+
// 1) read all currentIds with location "current"
|
|
94
|
+
// 2) read completeIds with location "complete" only for ids not present in currentIds
|
|
95
|
+
const currentSet = new Set(currentIds || []);
|
|
96
|
+
const readResults = [];
|
|
97
|
+
|
|
98
|
+
// Read current jobs (preserve order)
|
|
99
|
+
const currentPromises = (currentIds || []).map(async (id) => {
|
|
100
|
+
const res = await readJob(id, "current");
|
|
101
|
+
// attach metadata expected by tests
|
|
102
|
+
return res
|
|
103
|
+
? { ...res, jobId: id, location: "current" }
|
|
104
|
+
: { ok: false, jobId: id, location: "current" };
|
|
105
|
+
});
|
|
106
|
+
const currentResults = await Promise.all(currentPromises);
|
|
107
|
+
readResults.push(...currentResults);
|
|
108
|
+
|
|
109
|
+
// Read complete jobs that were not present in current
|
|
110
|
+
const completeToRead = (completeIds || []).filter(
|
|
111
|
+
(id) => !currentSet.has(id)
|
|
112
|
+
);
|
|
113
|
+
const completePromises = completeToRead.map(async (id) => {
|
|
114
|
+
console.log("handleJobList: readJob(complete) ->", id);
|
|
115
|
+
const res = await readJob(id, "complete");
|
|
116
|
+
return res
|
|
117
|
+
? { ...res, jobId: id, location: "complete" }
|
|
118
|
+
: { ok: false, jobId: id, location: "complete" };
|
|
119
|
+
});
|
|
120
|
+
const completeResults = await Promise.all(completePromises);
|
|
121
|
+
readResults.push(...completeResults);
|
|
122
|
+
|
|
123
|
+
// Invoke status transformer over all read results (tests expect this)
|
|
124
|
+
const transformed = transformMultipleJobs(readResults);
|
|
125
|
+
|
|
126
|
+
// Split transformed into current/complete buckets
|
|
127
|
+
const currentJobs = (transformed || []).filter(
|
|
128
|
+
(j) => j.location === "current"
|
|
129
|
+
);
|
|
130
|
+
const completeJobs = (transformed || []).filter(
|
|
131
|
+
(j) => j.location === "complete"
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const aggregated = aggregateAndSortJobs(currentJobs, completeJobs);
|
|
135
|
+
|
|
136
|
+
const payload = transformJobListForAPI(aggregated, {
|
|
137
|
+
includePipelineMetadata: true,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return { ok: true, data: payload };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error("handleJobList error:", err);
|
|
143
|
+
return configBridge.createErrorResponse(
|
|
144
|
+
configBridge.Constants.ERROR_CODES.FS_ERROR,
|
|
145
|
+
"Failed to read job data"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Return detailed job info for a single jobId.
|
|
152
|
+
* Behavior:
|
|
153
|
+
* 1. Validate job ID format
|
|
154
|
+
* 2. Read directly from filesystem (no caching)
|
|
155
|
+
* 3. Return appropriate error responses for different failure scenarios
|
|
156
|
+
* 4. Include pipeline config when available
|
|
157
|
+
*/
|
|
158
|
+
export async function handleJobDetail(jobId) {
|
|
159
|
+
console.log(`[JobEndpoints] GET /api/jobs/${jobId} called`);
|
|
160
|
+
|
|
161
|
+
// Step 1: Validate job ID format
|
|
162
|
+
if (!configBridge.validateJobId(jobId)) {
|
|
163
|
+
console.warn("[JobEndpoints] Invalid job ID format");
|
|
164
|
+
return configBridge.createErrorResponse(
|
|
165
|
+
configBridge.Constants.ERROR_CODES.BAD_REQUEST,
|
|
166
|
+
"Invalid job ID format",
|
|
167
|
+
jobId
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(`[JobEndpoints] Treating as job ID: ${jobId}`);
|
|
172
|
+
|
|
173
|
+
// Step 2: Read directly from filesystem
|
|
174
|
+
const result = await handleJobDetailById(jobId);
|
|
175
|
+
|
|
176
|
+
if (!result.ok) {
|
|
177
|
+
// Return appropriate error
|
|
178
|
+
if (result.code === "job_not_found") {
|
|
179
|
+
return configBridge.createErrorResponse(
|
|
180
|
+
configBridge.Constants.ERROR_CODES.JOB_NOT_FOUND,
|
|
181
|
+
"Job not found",
|
|
182
|
+
jobId
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return result; // Return other errors as-is
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Helper function to handle job detail lookup by exact ID.
|
|
193
|
+
* This contains the original logic for reading a job by ID.
|
|
194
|
+
*/
|
|
195
|
+
async function handleJobDetailById(jobId) {
|
|
196
|
+
try {
|
|
197
|
+
const readRes = await readJob(jobId);
|
|
198
|
+
|
|
199
|
+
if (!readRes || !readRes.ok) {
|
|
200
|
+
// Propagate or return job_not_found style envelope
|
|
201
|
+
if (readRes && readRes.code) return readRes;
|
|
202
|
+
return configBridge.createErrorResponse(
|
|
203
|
+
configBridge.Constants.ERROR_CODES.JOB_NOT_FOUND,
|
|
204
|
+
"Job not found",
|
|
205
|
+
jobId
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const transformed = transformMultipleJobs([readRes]);
|
|
210
|
+
const job = (transformed && transformed[0]) || null;
|
|
211
|
+
if (!job) {
|
|
212
|
+
return configBridge.createErrorResponse(
|
|
213
|
+
configBridge.Constants.ERROR_CODES.FS_ERROR,
|
|
214
|
+
"Invalid job data",
|
|
215
|
+
jobId
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Read pipeline snapshot from job directory to include canonical task order
|
|
220
|
+
let pipelineConfig = null;
|
|
221
|
+
try {
|
|
222
|
+
const jobPipelinePath = getJobPipelinePath(
|
|
223
|
+
process.env.PO_ROOT || process.cwd(),
|
|
224
|
+
jobId,
|
|
225
|
+
readRes.location
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const pipelineData = await fs.readFile(jobPipelinePath, "utf8");
|
|
229
|
+
pipelineConfig = JSON.parse(pipelineData);
|
|
230
|
+
console.log(`[JobEndpoints] Read pipeline snapshot from job ${jobId}`);
|
|
231
|
+
} catch (jobPipelineErr) {
|
|
232
|
+
// Log warning but don't fail the request - pipeline config is optional
|
|
233
|
+
console.warn(
|
|
234
|
+
`[JobEndpoints] Failed to read pipeline config from job directory ${jobId}:`,
|
|
235
|
+
jobPipelineErr?.message
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Add pipeline to job data if available
|
|
240
|
+
const jobWithPipeline = pipelineConfig
|
|
241
|
+
? {
|
|
242
|
+
...job,
|
|
243
|
+
pipeline: {
|
|
244
|
+
tasks: pipelineConfig.tasks || [],
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
: job;
|
|
248
|
+
|
|
249
|
+
return { ok: true, data: jobWithPipeline };
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error("handleJobDetailById error:", err);
|
|
252
|
+
return configBridge.createErrorResponse(
|
|
253
|
+
configBridge.Constants.ERROR_CODES.FS_ERROR,
|
|
254
|
+
"Failed to read job detail",
|
|
255
|
+
jobId
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Compute endpoint statistics for test assertions.
|
|
262
|
+
* jobListResponses/jobDetailResponses are arrays of response envelopes.
|
|
263
|
+
*/
|
|
264
|
+
export function getEndpointStats(
|
|
265
|
+
jobListResponses = [],
|
|
266
|
+
jobDetailResponses = []
|
|
267
|
+
) {
|
|
268
|
+
const summarize = (arr = []) => {
|
|
269
|
+
const totalCalls = arr.length;
|
|
270
|
+
let successfulCalls = 0;
|
|
271
|
+
let failedCalls = 0;
|
|
272
|
+
const errorCodes = {};
|
|
273
|
+
for (const r of arr) {
|
|
274
|
+
if (r && r.ok) successfulCalls += 1;
|
|
275
|
+
else {
|
|
276
|
+
failedCalls += 1;
|
|
277
|
+
const code = r && r.code ? r.code : "unknown";
|
|
278
|
+
errorCodes[code] = (errorCodes[code] || 0) + 1;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return { totalCalls, successfulCalls, failedCalls, errorCodes };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const jl = summarize(jobListResponses);
|
|
285
|
+
const jd = summarize(jobDetailResponses);
|
|
286
|
+
|
|
287
|
+
const overallTotal = jl.totalCalls + jd.totalCalls;
|
|
288
|
+
const overallSuccess = jl.successfulCalls + jd.successfulCalls;
|
|
289
|
+
const successRate =
|
|
290
|
+
overallTotal === 0 ? 0 : Math.round((overallSuccess / overallTotal) * 100);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
jobList: jl,
|
|
294
|
+
jobDetail: jd,
|
|
295
|
+
overall: {
|
|
296
|
+
totalCalls: overallTotal,
|
|
297
|
+
successRate,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe file reader utilities for pipeline-data JSON files
|
|
3
|
+
* Exports:
|
|
4
|
+
* - readJSONFile(path)
|
|
5
|
+
* - readFileWithRetry(path, options)
|
|
6
|
+
* - readMultipleJSONFiles(paths)
|
|
7
|
+
* - validateFilePath(path)
|
|
8
|
+
* - getFileReadingStats(filePaths, results)
|
|
9
|
+
*
|
|
10
|
+
* Conforms to error envelope used across the project.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { promises as fs } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { Constants, createErrorResponse } from "./config-bridge.node.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate that a path points to a readable file within size limits.
|
|
19
|
+
* Returns an object with ok:true and metadata or ok:false and an error envelope.
|
|
20
|
+
*/
|
|
21
|
+
export async function validateFilePath(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
const stats = await fs.stat(filePath);
|
|
24
|
+
|
|
25
|
+
if (!stats.isFile()) {
|
|
26
|
+
return createErrorResponse(
|
|
27
|
+
Constants.ERROR_CODES.FS_ERROR,
|
|
28
|
+
"Path is not a file"
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const size = stats.size;
|
|
33
|
+
if (size > Constants.FILE_LIMITS.MAX_FILE_size && false) {
|
|
34
|
+
// defensive: in case project had different naming, but we use the canonical constant below
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (size > Constants.FILE_LIMITS.MAX_FILE_SIZE) {
|
|
38
|
+
return createErrorResponse(
|
|
39
|
+
Constants.ERROR_CODES.FS_ERROR,
|
|
40
|
+
`File too large (${Math.round(size / 1024)} KB) - limit is ${Math.round(
|
|
41
|
+
Constants.FILE_LIMITS.MAX_FILE_SIZE / 1024
|
|
42
|
+
)} KB`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
path: filePath,
|
|
49
|
+
size,
|
|
50
|
+
modified: new Date(stats.mtime),
|
|
51
|
+
};
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// ENOENT -> not found
|
|
54
|
+
if (err && err.code === "ENOENT") {
|
|
55
|
+
return createErrorResponse(
|
|
56
|
+
Constants.ERROR_CODES.NOT_FOUND,
|
|
57
|
+
"File not found",
|
|
58
|
+
filePath
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return createErrorResponse(
|
|
63
|
+
Constants.ERROR_CODES.FS_ERROR,
|
|
64
|
+
`Validation error: File system error: ${err?.message || String(err)}`,
|
|
65
|
+
filePath
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read and parse a JSON file safely.
|
|
72
|
+
* Returns { ok:true, data, path } on success or an error envelope on failure.
|
|
73
|
+
*/
|
|
74
|
+
export async function readJSONFile(filePath) {
|
|
75
|
+
// Validate file existence, size, etc.
|
|
76
|
+
const validation = await validateFilePath(filePath);
|
|
77
|
+
if (!validation.ok) {
|
|
78
|
+
return validation;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const raw = await fs.readFile(filePath, { encoding: "utf8" });
|
|
83
|
+
|
|
84
|
+
// Handle UTF-8 BOM
|
|
85
|
+
const content = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const data = JSON.parse(content);
|
|
89
|
+
return { ok: true, data, path: filePath };
|
|
90
|
+
} catch (parseErr) {
|
|
91
|
+
return createErrorResponse(
|
|
92
|
+
Constants.ERROR_CODES.INVALID_JSON,
|
|
93
|
+
`Invalid JSON: ${parseErr.message}`,
|
|
94
|
+
filePath
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Map common fs errors to fs_error
|
|
99
|
+
return createErrorResponse(
|
|
100
|
+
Constants.ERROR_CODES.FS_ERROR,
|
|
101
|
+
err?.message ? `File system error: ${err.message}` : "File system error",
|
|
102
|
+
filePath
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read JSON file with retries for transient conditions (e.g., writer in progress).
|
|
109
|
+
* Options:
|
|
110
|
+
* - maxAttempts (default: Constants.RETRY_CONFIG.MAX_ATTEMPTS)
|
|
111
|
+
* - delayMs (default: Constants.RETRY_CONFIG.DELAY_MS)
|
|
112
|
+
*/
|
|
113
|
+
export async function readFileWithRetry(filePath, options = {}) {
|
|
114
|
+
const maxAttempts =
|
|
115
|
+
options.maxAttempts ?? Constants.RETRY_CONFIG.MAX_ATTEMPTS ?? 3;
|
|
116
|
+
const delayMs = options.delayMs ?? Constants.RETRY_CONFIG.DELAY_MS ?? 100;
|
|
117
|
+
|
|
118
|
+
// Cap attempts and delay to reasonable bounds to avoid long waits in non-test environments
|
|
119
|
+
const effectiveMaxAttempts = Math.max(1, Math.min(maxAttempts, 5));
|
|
120
|
+
const effectiveDelayMs = Math.max(0, Math.min(delayMs, 50));
|
|
121
|
+
|
|
122
|
+
let attempt = 0;
|
|
123
|
+
let lastErr = null;
|
|
124
|
+
|
|
125
|
+
while (attempt < effectiveMaxAttempts) {
|
|
126
|
+
attempt += 1;
|
|
127
|
+
const result = await readJSONFile(filePath);
|
|
128
|
+
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// If file is missing, return immediately (no retries)
|
|
134
|
+
if (result.code === Constants.ERROR_CODES.NOT_FOUND) {
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// If invalid_json, it's plausible the writer is mid-write — retry
|
|
139
|
+
if (
|
|
140
|
+
result.code === Constants.ERROR_CODES.INVALID_JSON &&
|
|
141
|
+
attempt < effectiveMaxAttempts
|
|
142
|
+
) {
|
|
143
|
+
lastErr = result;
|
|
144
|
+
await new Promise((res) => setTimeout(res, effectiveDelayMs));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// For persistent fs_error, allow retries up to maxAttempts.
|
|
149
|
+
lastErr = result;
|
|
150
|
+
if (attempt < effectiveMaxAttempts) {
|
|
151
|
+
await new Promise((res) => setTimeout(res, effectiveDelayMs));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Exhausted attempts
|
|
156
|
+
return lastErr;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return createErrorResponse(
|
|
160
|
+
Constants.ERROR_CODES.FS_ERROR,
|
|
161
|
+
"Exceeded retry attempts",
|
|
162
|
+
filePath
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read multiple JSON files in parallel and report per-file results.
|
|
168
|
+
* Logs a summary using console.log about success/error counts.
|
|
169
|
+
*/
|
|
170
|
+
export async function readMultipleJSONFiles(filePaths = []) {
|
|
171
|
+
const promises = filePaths.map((p) => readJSONFile(p));
|
|
172
|
+
const results = await Promise.all(promises);
|
|
173
|
+
|
|
174
|
+
const stats = getFileReadingStats(filePaths, results);
|
|
175
|
+
|
|
176
|
+
// Log summary for visibility in tests (tests expect a specific log fragment)
|
|
177
|
+
console.log(
|
|
178
|
+
`Read ${stats.successCount}/${stats.totalFiles} files successfully, ${stats.errorCount} errors`
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return results;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Compute reading statistics used for logging and assertions
|
|
186
|
+
*/
|
|
187
|
+
export function getFileReadingStats(filePaths = [], results = []) {
|
|
188
|
+
const totalFiles = filePaths.length;
|
|
189
|
+
let successCount = 0;
|
|
190
|
+
const errorTypes = {};
|
|
191
|
+
|
|
192
|
+
for (const res of results) {
|
|
193
|
+
if (res && res.ok) {
|
|
194
|
+
successCount += 1;
|
|
195
|
+
} else if (res && res.code) {
|
|
196
|
+
// count error type
|
|
197
|
+
errorTypes[res.code] = (errorTypes[res.code] || 0) + 1;
|
|
198
|
+
} else {
|
|
199
|
+
errorTypes.unknown = (errorTypes.unknown || 0) + 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const errorCount = totalFiles - successCount;
|
|
204
|
+
const successRate =
|
|
205
|
+
totalFiles === 0
|
|
206
|
+
? 0
|
|
207
|
+
: Number(((successCount / totalFiles) * 100).toFixed(2));
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
totalFiles,
|
|
211
|
+
successCount,
|
|
212
|
+
errorCount,
|
|
213
|
+
successRate,
|
|
214
|
+
errorTypes,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job change detector
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - detectJobChange(filePath) -> { jobId, category, filePath } | null
|
|
6
|
+
* - getJobLocation(filePath) -> 'current' | 'complete' | 'pending' | 'rejected' | null
|
|
7
|
+
*
|
|
8
|
+
* Normalizes Windows backslashes to forward slashes for detection.
|
|
9
|
+
* Supports absolute paths and all lifecycle directories.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const JOB_ID_RE = /^[A-Za-z0-9-_]+$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize path separators to forward slash and trim
|
|
16
|
+
*/
|
|
17
|
+
function normalizePath(p) {
|
|
18
|
+
if (!p || typeof p !== "string") return "";
|
|
19
|
+
return p.replace(/\\/g, "/").replace(/\/\/+/g, "/");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determine the job location ('current'|'complete'|'pending'|'rejected') from a path, or null.
|
|
24
|
+
* Accepts both relative and absolute paths, always returns the lifecycle name.
|
|
25
|
+
*/
|
|
26
|
+
export function getJobLocation(filePath) {
|
|
27
|
+
const p = normalizePath(filePath);
|
|
28
|
+
const m = p.match(
|
|
29
|
+
/^.*?pipeline-data\/(current|complete|pending|rejected)\/([^/]+)\/?/
|
|
30
|
+
);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
return m[1] || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Given a file path, determine whether it belongs to a job and what category the change is.
|
|
37
|
+
* Categories: 'status' (tasks-status.json), 'task' (anything under tasks/**), 'seed' (seed.json)
|
|
38
|
+
* Accepts absolute paths and returns normalized filePath (always starting with "pipeline-data/...").
|
|
39
|
+
*/
|
|
40
|
+
export function detectJobChange(filePath) {
|
|
41
|
+
const p = normalizePath(filePath);
|
|
42
|
+
|
|
43
|
+
// Must start with optional prefix + pipeline-data/{current|complete|pending|rejected}/{jobId}/...
|
|
44
|
+
const m = p.match(
|
|
45
|
+
/^.*?pipeline-data\/(current|complete|pending|rejected)\/([^/]+)\/(.*)$/
|
|
46
|
+
);
|
|
47
|
+
if (!m) return null;
|
|
48
|
+
|
|
49
|
+
const [, location, jobId, rest] = m;
|
|
50
|
+
if (!JOB_ID_RE.test(jobId)) return null;
|
|
51
|
+
|
|
52
|
+
const normalized = `pipeline-data/${location}/${jobId}/${rest}`;
|
|
53
|
+
|
|
54
|
+
// status
|
|
55
|
+
if (rest === "tasks-status.json") {
|
|
56
|
+
return {
|
|
57
|
+
jobId,
|
|
58
|
+
category: "status",
|
|
59
|
+
filePath: normalized,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// seed
|
|
64
|
+
if (rest === "seed.json") {
|
|
65
|
+
return {
|
|
66
|
+
jobId,
|
|
67
|
+
category: "seed",
|
|
68
|
+
filePath: normalized,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// tasks/** (task artifacts)
|
|
73
|
+
if (rest.startsWith("tasks/")) {
|
|
74
|
+
return {
|
|
75
|
+
jobId,
|
|
76
|
+
category: "task",
|
|
77
|
+
filePath: `pipeline-data/${location}/${jobId}/${rest}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// anything else is not relevant
|
|
82
|
+
return null;
|
|
83
|
+
}
|