@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
@@ -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
+ }