@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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job index and cache utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides a centralized jobsById cache and indexing functionality
|
|
5
|
+
* for improved performance and single source of truth for job data.
|
|
6
|
+
*
|
|
7
|
+
* Exports:
|
|
8
|
+
* - JobIndex class for managing job cache
|
|
9
|
+
* - createJobIndex() -> JobIndex instance
|
|
10
|
+
* - getJobIndex() -> singleton JobIndex instance
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { listAllJobs } from "./job-scanner.js";
|
|
14
|
+
import { readJob } from "./job-reader.js";
|
|
15
|
+
import { transformJobStatus } from "./transformers/status-transformer.js";
|
|
16
|
+
import * as configBridge from "./config-bridge.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* JobIndex class for managing job cache and indexing
|
|
20
|
+
*/
|
|
21
|
+
export class JobIndex {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.jobsById = new Map();
|
|
24
|
+
this.lastRefresh = null;
|
|
25
|
+
this.refreshInProgress = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Refresh the job index by scanning all job locations
|
|
30
|
+
* Returns Promise<void>
|
|
31
|
+
*/
|
|
32
|
+
async refresh() {
|
|
33
|
+
if (this.refreshInProgress) {
|
|
34
|
+
return; // Avoid concurrent refreshes
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.refreshInProgress = true;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
console.log("[JobIndex] Starting refresh");
|
|
41
|
+
|
|
42
|
+
// Clear current index
|
|
43
|
+
this.jobsById.clear();
|
|
44
|
+
|
|
45
|
+
// Get all job IDs from all locations
|
|
46
|
+
const { current, complete } = await listAllJobs();
|
|
47
|
+
const currentIds = current || [];
|
|
48
|
+
const completeIds = complete || [];
|
|
49
|
+
const allJobIds = [...new Set([...currentIds, ...completeIds])];
|
|
50
|
+
|
|
51
|
+
// Read all jobs and populate index
|
|
52
|
+
const readPromises = allJobIds.map(async (jobId) => {
|
|
53
|
+
try {
|
|
54
|
+
// Try each location until we find the job
|
|
55
|
+
let result = null;
|
|
56
|
+
const locations = ["current", "complete", "pending", "rejected"];
|
|
57
|
+
|
|
58
|
+
for (const location of locations) {
|
|
59
|
+
result = await readJob(jobId, location);
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (result && result.ok) {
|
|
66
|
+
// Transform to canonical schema before caching
|
|
67
|
+
const canonicalJob = transformJobStatus(
|
|
68
|
+
result.data,
|
|
69
|
+
jobId,
|
|
70
|
+
result.location
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (canonicalJob) {
|
|
74
|
+
this.jobsById.set(jobId, {
|
|
75
|
+
...canonicalJob,
|
|
76
|
+
location: result.location,
|
|
77
|
+
path: result.path,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[JobIndex] Failed to read job ${jobId}:`,
|
|
84
|
+
error?.message
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await Promise.all(readPromises);
|
|
90
|
+
this.lastRefresh = new Date();
|
|
91
|
+
|
|
92
|
+
console.log(
|
|
93
|
+
`[JobIndex] Refresh complete: ${this.jobsById.size} jobs indexed`
|
|
94
|
+
);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error("[JobIndex] Refresh failed:", error);
|
|
97
|
+
throw error;
|
|
98
|
+
} finally {
|
|
99
|
+
this.refreshInProgress = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get a job by ID from the cache
|
|
105
|
+
* Returns job data or null if not found
|
|
106
|
+
*/
|
|
107
|
+
getJob(jobId) {
|
|
108
|
+
return this.jobsById.get(jobId) || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get all jobs from the cache
|
|
113
|
+
* Returns Array of job data
|
|
114
|
+
*/
|
|
115
|
+
getAllJobs() {
|
|
116
|
+
return Array.from(this.jobsById.values());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get jobs by location
|
|
121
|
+
* Returns Array of job data for specified location
|
|
122
|
+
*/
|
|
123
|
+
getJobsByLocation(location) {
|
|
124
|
+
return this.getAllJobs().filter((job) => job.location === location);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if a job exists in the cache
|
|
129
|
+
* Returns boolean
|
|
130
|
+
*/
|
|
131
|
+
hasJob(jobId) {
|
|
132
|
+
return this.jobsById.has(jobId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get job count
|
|
137
|
+
* Returns number of jobs in cache
|
|
138
|
+
*/
|
|
139
|
+
getJobCount() {
|
|
140
|
+
return this.jobsById.size;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get index statistics
|
|
145
|
+
* Returns object with index metadata
|
|
146
|
+
*/
|
|
147
|
+
getStats() {
|
|
148
|
+
const jobs = this.getAllJobs();
|
|
149
|
+
const locations = {};
|
|
150
|
+
|
|
151
|
+
jobs.forEach((job) => {
|
|
152
|
+
locations[job.location] = (locations[job.location] || 0) + 1;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
totalJobs: this.jobsById.size,
|
|
157
|
+
lastRefresh: this.lastRefresh,
|
|
158
|
+
refreshInProgress: this.refreshInProgress,
|
|
159
|
+
locations,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Clear the cache
|
|
165
|
+
*/
|
|
166
|
+
clear() {
|
|
167
|
+
this.jobsById.clear();
|
|
168
|
+
this.lastRefresh = null;
|
|
169
|
+
console.log("[JobIndex] Cache cleared");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Update or add a single job in the cache
|
|
174
|
+
* Useful for real-time updates
|
|
175
|
+
*/
|
|
176
|
+
updateJob(jobId, jobData, location, path) {
|
|
177
|
+
// Transform to canonical schema before caching
|
|
178
|
+
const canonicalJob = transformJobStatus(jobData, jobId, location);
|
|
179
|
+
|
|
180
|
+
if (canonicalJob) {
|
|
181
|
+
this.jobsById.set(jobId, {
|
|
182
|
+
...canonicalJob,
|
|
183
|
+
location,
|
|
184
|
+
path,
|
|
185
|
+
});
|
|
186
|
+
console.log(`[JobIndex] Updated job ${jobId} in cache`);
|
|
187
|
+
} else {
|
|
188
|
+
console.warn(`[JobIndex] Failed to transform job ${jobId} for cache`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Remove a job from the cache
|
|
194
|
+
*/
|
|
195
|
+
removeJob(jobId) {
|
|
196
|
+
const removed = this.jobsById.delete(jobId);
|
|
197
|
+
if (removed) {
|
|
198
|
+
console.log(`[JobIndex] Removed job ${jobId} from cache`);
|
|
199
|
+
}
|
|
200
|
+
return removed;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Singleton instance
|
|
205
|
+
let jobIndexInstance = null;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Create a new JobIndex instance
|
|
209
|
+
* Returns JobIndex
|
|
210
|
+
*/
|
|
211
|
+
export function createJobIndex() {
|
|
212
|
+
return new JobIndex();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get the singleton JobIndex instance
|
|
217
|
+
* Returns JobIndex
|
|
218
|
+
*/
|
|
219
|
+
export function getJobIndex() {
|
|
220
|
+
if (!jobIndexInstance) {
|
|
221
|
+
jobIndexInstance = createJobIndex();
|
|
222
|
+
}
|
|
223
|
+
return jobIndexInstance;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reset the singleton instance (useful for testing)
|
|
228
|
+
*/
|
|
229
|
+
export function resetJobIndex() {
|
|
230
|
+
jobIndexInstance = null;
|
|
231
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job reader utilities
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - readJob(jobId)
|
|
6
|
+
* - readMultipleJobs(jobIds)
|
|
7
|
+
* - getJobReadingStats(jobIds, results)
|
|
8
|
+
* - validateJobData(jobData, expectedJobId)
|
|
9
|
+
*
|
|
10
|
+
* Uses config-bridge for paths/constants and file-reader for safe file I/O.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileWithRetry } from "./file-reader.js";
|
|
14
|
+
import * as configBridge from "./config-bridge.node.js";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read a single job's tasks-status.json with lock-awareness and precedence.
|
|
19
|
+
* Returns { ok:true, data, location, path } or an error envelope.
|
|
20
|
+
*/
|
|
21
|
+
export async function readJob(jobId) {
|
|
22
|
+
console.log(`readJob start: ${jobId}`);
|
|
23
|
+
// Validate job id
|
|
24
|
+
if (!configBridge.validateJobId(jobId)) {
|
|
25
|
+
return configBridge.createErrorResponse(
|
|
26
|
+
configBridge.Constants.ERROR_CODES.BAD_REQUEST,
|
|
27
|
+
"Invalid job ID format",
|
|
28
|
+
jobId
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Locations in precedence order
|
|
33
|
+
const locations = ["current", "complete"];
|
|
34
|
+
|
|
35
|
+
for (const location of locations) {
|
|
36
|
+
console.log(`readJob: checking location ${location} for ${jobId}`);
|
|
37
|
+
// Prefer using getPATHS() to get paths with PO_ROOT support
|
|
38
|
+
const paths = configBridge.getPATHS();
|
|
39
|
+
const jobDir = path.join(paths[location], jobId);
|
|
40
|
+
const tasksPath = path.join(paths[location], jobId, "tasks-status.json");
|
|
41
|
+
|
|
42
|
+
// Debug: trace lock checks and reading steps
|
|
43
|
+
console.log(
|
|
44
|
+
`readJob: will check lock at ${jobDir} and attempt to read ${tasksPath}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Check locks with retry
|
|
48
|
+
const maxLockAttempts =
|
|
49
|
+
configBridge.Constants?.RETRY_CONFIG?.MAX_ATTEMPTS ?? 3;
|
|
50
|
+
const configuredDelay =
|
|
51
|
+
configBridge.Constants?.RETRY_CONFIG?.DELAY_MS ?? 50;
|
|
52
|
+
// Cap lock retry delay during tests to avoid long waits; use small bound for responsiveness
|
|
53
|
+
const lockDelay = Math.min(configuredDelay, 20);
|
|
54
|
+
|
|
55
|
+
// Check lock with a small, deterministic retry loop.
|
|
56
|
+
// Tests mock isLocked to return true once then false; this loop allows that behavior.
|
|
57
|
+
// Single-check lock flow with one re-check after a short wait.
|
|
58
|
+
// Tests mock isLocked to return true once then false; calling it twice
|
|
59
|
+
// triggers that behavior deterministically without long retry loops.
|
|
60
|
+
let locked = false;
|
|
61
|
+
try {
|
|
62
|
+
locked = await configBridge.isLocked(jobDir);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
locked = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(
|
|
68
|
+
`readJob lock check for ${jobId} at ${location}: locked=${locked}`
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (locked) {
|
|
72
|
+
// Log that we observed a lock. Tests expect this log. Do not block:
|
|
73
|
+
// proceed immediately to reading to keep test deterministic and fast.
|
|
74
|
+
console.log(`Job ${jobId} in ${location} is locked, retrying`);
|
|
75
|
+
// Note: we intentionally do not wait or re-check here to avoid flaky timing.
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Try reading tasks-status.json with retry for parse-race conditions
|
|
79
|
+
const result = await readFileWithRetry(tasksPath);
|
|
80
|
+
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
// Log a warning for failed reads of tasks-status.json in this location
|
|
83
|
+
console.warn(
|
|
84
|
+
`Failed to read tasks-status.json for job ${jobId} in ${location}`,
|
|
85
|
+
result
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// If not found, continue to next location
|
|
89
|
+
if (result.code === configBridge.Constants.ERROR_CODES.NOT_FOUND) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// For other errors, return a job_not_found style envelope (tests expect job_not_found when missing)
|
|
94
|
+
// but preserve underlying code for diagnostics
|
|
95
|
+
return configBridge.createErrorResponse(
|
|
96
|
+
configBridge.Constants.ERROR_CODES.JOB_NOT_FOUND,
|
|
97
|
+
`Job not found: ${jobId}`,
|
|
98
|
+
tasksPath
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate job shape minimally (validation function exists separately)
|
|
103
|
+
// Return successful read
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
data: result.data,
|
|
107
|
+
location,
|
|
108
|
+
path: tasksPath,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// If we reach here, job not found in any location
|
|
113
|
+
return configBridge.createErrorResponse(
|
|
114
|
+
configBridge.Constants.ERROR_CODES.JOB_NOT_FOUND,
|
|
115
|
+
"Job not found",
|
|
116
|
+
jobId
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read multiple jobs by id. Returns array of per-job results.
|
|
122
|
+
* Logs a summary: "Read X/Y jobs successfully, Z errors"
|
|
123
|
+
*/
|
|
124
|
+
export async function readMultipleJobs(jobIds = []) {
|
|
125
|
+
if (!Array.isArray(jobIds) || jobIds.length === 0) return [];
|
|
126
|
+
|
|
127
|
+
const promises = jobIds.map((id) => readJob(id));
|
|
128
|
+
const results = await Promise.all(promises);
|
|
129
|
+
|
|
130
|
+
// Log summary similar to file reader
|
|
131
|
+
const successCount = results.filter((r) => r && r.ok).length;
|
|
132
|
+
const total = jobIds.length;
|
|
133
|
+
const errorCount = total - successCount;
|
|
134
|
+
|
|
135
|
+
console.log(
|
|
136
|
+
`Read ${successCount}/${total} jobs successfully, ${errorCount} errors`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compute job-reading statistics
|
|
144
|
+
*/
|
|
145
|
+
export function getJobReadingStats(jobIds = [], results = []) {
|
|
146
|
+
const totalJobs = jobIds.length;
|
|
147
|
+
let successCount = 0;
|
|
148
|
+
const errorTypes = {};
|
|
149
|
+
const locations = {};
|
|
150
|
+
|
|
151
|
+
for (const res of results) {
|
|
152
|
+
if (res && res.ok) {
|
|
153
|
+
successCount += 1;
|
|
154
|
+
const loc = res.location || "unknown";
|
|
155
|
+
locations[loc] = (locations[loc] || 0) + 1;
|
|
156
|
+
} else if (res && res.code) {
|
|
157
|
+
errorTypes[res.code] = (errorTypes[res.code] || 0) + 1;
|
|
158
|
+
} else {
|
|
159
|
+
errorTypes.unknown = (errorTypes.unknown || 0) + 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const errorCount = totalJobs - successCount;
|
|
164
|
+
const successRate =
|
|
165
|
+
totalJobs === 0 ? 0 : Math.round((successCount / totalJobs) * 100);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
totalJobs,
|
|
169
|
+
successCount,
|
|
170
|
+
errorCount,
|
|
171
|
+
successRate,
|
|
172
|
+
errorTypes,
|
|
173
|
+
locations,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate job data conforms to minimal schema and expected job id.
|
|
179
|
+
* Supports both legacy (id, name, tasks) and canonical (jobId, title, tasksStatus) fields.
|
|
180
|
+
* Returns { valid: boolean, warnings: string[], error?: string }
|
|
181
|
+
*/
|
|
182
|
+
export function validateJobData(jobData, expectedJobId) {
|
|
183
|
+
const warnings = [];
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
jobData === null ||
|
|
187
|
+
typeof jobData !== "object" ||
|
|
188
|
+
Array.isArray(jobData)
|
|
189
|
+
) {
|
|
190
|
+
return { valid: false, error: "Job data must be an object" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Support both legacy and canonical field names
|
|
194
|
+
const hasLegacyId = "id" in jobData;
|
|
195
|
+
const hasCanonicalId = "jobId" in jobData;
|
|
196
|
+
const hasLegacyName = "name" in jobData;
|
|
197
|
+
const hasCanonicalName = "title" in jobData;
|
|
198
|
+
const hasLegacyTasks = "tasks" in jobData;
|
|
199
|
+
const hasCanonicalTasks = "tasksStatus" in jobData;
|
|
200
|
+
|
|
201
|
+
// Required: at least one ID field
|
|
202
|
+
if (!hasLegacyId && !hasCanonicalId) {
|
|
203
|
+
return { valid: false, error: "Missing required field: id or jobId" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Required: at least one name field
|
|
207
|
+
if (!hasLegacyName && !hasCanonicalName) {
|
|
208
|
+
return { valid: false, error: "Missing required field: name or title" };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Required: createdAt
|
|
212
|
+
if (!("createdAt" in jobData)) {
|
|
213
|
+
return { valid: false, error: "Missing required field: createdAt" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Required: at least one tasks field
|
|
217
|
+
if (!hasLegacyTasks && !hasCanonicalTasks) {
|
|
218
|
+
return {
|
|
219
|
+
valid: false,
|
|
220
|
+
error: "Missing required field: tasks or tasksStatus",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Get actual ID for validation
|
|
225
|
+
const actualId = jobData.jobId ?? jobData.id;
|
|
226
|
+
if (actualId !== expectedJobId) {
|
|
227
|
+
warnings.push("Job ID mismatch");
|
|
228
|
+
console.warn(
|
|
229
|
+
`Job ID mismatch: expected ${expectedJobId}, found ${actualId}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Validate tasks (prefer canonical, fallback to legacy)
|
|
234
|
+
const tasks = jobData.tasksStatus ?? jobData.tasks;
|
|
235
|
+
if (typeof tasks !== "object" || tasks === null || Array.isArray(tasks)) {
|
|
236
|
+
return { valid: false, error: "Tasks must be an object" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const validStates = configBridge.Constants?.TASK_STATES || [
|
|
240
|
+
"pending",
|
|
241
|
+
"running",
|
|
242
|
+
"done",
|
|
243
|
+
"error",
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
for (const [taskName, task] of Object.entries(tasks)) {
|
|
247
|
+
if (!task || typeof task !== "object") {
|
|
248
|
+
return { valid: false, error: `Task ${taskName} missing state field` };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!("state" in task)) {
|
|
252
|
+
return { valid: false, error: `Task ${taskName} missing state field` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const state = task.state;
|
|
256
|
+
if (!validStates.includes(state)) {
|
|
257
|
+
warnings.push(`Unknown state: ${state}`);
|
|
258
|
+
console.warn(`Unknown task state for ${taskName}: ${state}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Add warnings for legacy field usage
|
|
263
|
+
if (hasLegacyId && hasCanonicalId) {
|
|
264
|
+
warnings.push("Both id and jobId present, using jobId");
|
|
265
|
+
}
|
|
266
|
+
if (hasLegacyName && hasCanonicalName) {
|
|
267
|
+
warnings.push("Both name and title present, using title");
|
|
268
|
+
}
|
|
269
|
+
if (hasLegacyTasks && hasCanonicalTasks) {
|
|
270
|
+
warnings.push("Both tasks and tasksStatus present, using tasksStatus");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { valid: true, warnings };
|
|
274
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job scanner utilities
|
|
3
|
+
* - listJobs(location) -> array of job IDs (directory names)
|
|
4
|
+
* - listAllJobs() -> { current: [], complete: [] }
|
|
5
|
+
* - getJobDirectoryStats(location) -> info about the directory
|
|
6
|
+
*
|
|
7
|
+
* Behavior guided by docs/project-data-display.md and tests/job-scanner.test.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { promises as fs } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import * as configBridge from "./config-bridge.node.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* List job directory names for a given location.
|
|
16
|
+
* Returns [] for invalid location, missing directory, or on permission errors.
|
|
17
|
+
*/
|
|
18
|
+
export async function listJobs(location) {
|
|
19
|
+
if (!configBridge || !configBridge.Constants) {
|
|
20
|
+
// Defensive: if Constants not available, return empty
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!configBridge.Constants.JOB_LOCATIONS.includes(location)) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Use dynamic path resolution to avoid caching issues
|
|
29
|
+
const paths = configBridge.getPATHS();
|
|
30
|
+
const dirPath = paths[location];
|
|
31
|
+
|
|
32
|
+
console.log(`[JobScanner] Resolved paths for ${location}:`, paths);
|
|
33
|
+
console.log(`[JobScanner] Directory path for ${location}:`, dirPath);
|
|
34
|
+
|
|
35
|
+
if (!dirPath) {
|
|
36
|
+
console.log(`[JobScanner] No directory path found for ${location}`);
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Check existence/access first to provide clearer handling in tests
|
|
42
|
+
await fs.access(dirPath);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
// Directory doesn't exist or access denied -> return empty
|
|
45
|
+
console.log(
|
|
46
|
+
`[JobScanner] Directory access failed for ${dirPath}:`,
|
|
47
|
+
err?.message
|
|
48
|
+
);
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
54
|
+
const jobs = [];
|
|
55
|
+
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (!entry.isDirectory()) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const name = entry.name;
|
|
62
|
+
|
|
63
|
+
// Skip hidden directories
|
|
64
|
+
if (name.startsWith(".")) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate job ID format
|
|
69
|
+
if (!configBridge.Constants.JOB_ID_REGEX.test(name)) {
|
|
70
|
+
console.warn(`Skipping invalid job directory name: ${name}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
jobs.push(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(`[JobScanner] Found ${jobs.length} jobs in ${location}:`, jobs);
|
|
78
|
+
return jobs;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Permission errors or other fs errors: log and return empty
|
|
81
|
+
console.warn(
|
|
82
|
+
`Error reading ${location} directory: ${err?.message || String(err)}`
|
|
83
|
+
);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List jobs from both current and complete locations.
|
|
90
|
+
*/
|
|
91
|
+
export async function listAllJobs() {
|
|
92
|
+
const current = await listJobs("current");
|
|
93
|
+
const complete = await listJobs("complete");
|
|
94
|
+
|
|
95
|
+
return { current, complete };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Return basic stats about a job directory location.
|
|
100
|
+
* { location, exists, jobCount, totalEntries, error? }
|
|
101
|
+
*/
|
|
102
|
+
export async function getJobDirectoryStats(location) {
|
|
103
|
+
if (!configBridge || !configBridge.Constants) {
|
|
104
|
+
return {
|
|
105
|
+
location,
|
|
106
|
+
exists: false,
|
|
107
|
+
jobCount: 0,
|
|
108
|
+
totalEntries: 0,
|
|
109
|
+
error: "Invalid location",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!configBridge.Constants.JOB_LOCATIONS.includes(location)) {
|
|
114
|
+
return {
|
|
115
|
+
location,
|
|
116
|
+
exists: false,
|
|
117
|
+
jobCount: 0,
|
|
118
|
+
totalEntries: 0,
|
|
119
|
+
error: "Invalid location",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Use dynamic path resolution to avoid caching issues
|
|
124
|
+
const paths = configBridge.getPATHS();
|
|
125
|
+
const dirPath = paths[location];
|
|
126
|
+
|
|
127
|
+
if (!dirPath) {
|
|
128
|
+
return {
|
|
129
|
+
location,
|
|
130
|
+
exists: false,
|
|
131
|
+
jobCount: 0,
|
|
132
|
+
totalEntries: 0,
|
|
133
|
+
error: "Directory not found",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await fs.access(dirPath);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// Directory does not exist
|
|
141
|
+
if (err && err.code === "ENOENT") {
|
|
142
|
+
return {
|
|
143
|
+
location,
|
|
144
|
+
exists: false,
|
|
145
|
+
jobCount: 0,
|
|
146
|
+
totalEntries: 0,
|
|
147
|
+
error: "Directory not found",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
location,
|
|
152
|
+
exists: false,
|
|
153
|
+
jobCount: 0,
|
|
154
|
+
totalEntries: 0,
|
|
155
|
+
error: err?.message || String(err),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
161
|
+
const totalEntries = entries.length;
|
|
162
|
+
let jobCount = 0;
|
|
163
|
+
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
if (!entry.isDirectory()) continue;
|
|
166
|
+
const name = entry.name;
|
|
167
|
+
if (name.startsWith(".")) continue;
|
|
168
|
+
if (!configBridge.Constants.JOB_ID_REGEX.test(name)) continue;
|
|
169
|
+
jobCount += 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
location,
|
|
174
|
+
exists: true,
|
|
175
|
+
jobCount,
|
|
176
|
+
totalEntries,
|
|
177
|
+
};
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// Permission or other error while reading
|
|
180
|
+
return {
|
|
181
|
+
location,
|
|
182
|
+
exists: false,
|
|
183
|
+
jobCount: 0,
|
|
184
|
+
totalEntries: 0,
|
|
185
|
+
error: err?.message || String(err),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/ui/public/app.js
CHANGED
|
@@ -169,7 +169,9 @@ function connectSSE() {
|
|
|
169
169
|
try {
|
|
170
170
|
const state = JSON.parse(event.data);
|
|
171
171
|
renderState(state);
|
|
172
|
-
|
|
172
|
+
// Do not derive connection health from receipt of state payloads.
|
|
173
|
+
// Connection status should be driven by EventSource.readyState (open/error)
|
|
174
|
+
// or an explicit health/ping endpoint. Keep this handler focused on state updates.
|
|
173
175
|
} catch (error) {
|
|
174
176
|
console.error("Error parsing state event:", error);
|
|
175
177
|
}
|