@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,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE Enhancer
|
|
3
|
+
*
|
|
4
|
+
* Provides a factory createSSEEnhancer({ readJobFn, sseRegistry, debounceMs })
|
|
5
|
+
* and a singleton export sseEnhancer created with default dependencies.
|
|
6
|
+
*
|
|
7
|
+
* Behavior:
|
|
8
|
+
* - handleJobChange({ jobId, category, filePath })
|
|
9
|
+
* - debounce per jobId (default 200ms)
|
|
10
|
+
* - after debounce, call readJobFn(jobId) to obtain latest detail
|
|
11
|
+
* - if read succeeds (ok), broadcast { type: "job:updated", data: detail }
|
|
12
|
+
* - if read fails, do not broadcast
|
|
13
|
+
* - getPendingCount() returns number of pending timers
|
|
14
|
+
* - cleanup() clears timers
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { detectJobChange } from "./job-change-detector.js";
|
|
18
|
+
import { transformJobStatus } from "./transformers/status-transformer.js";
|
|
19
|
+
import { transformJobListForAPI } from "./transformers/list-transformer.js";
|
|
20
|
+
|
|
21
|
+
export function createSSEEnhancer({
|
|
22
|
+
readJobFn,
|
|
23
|
+
sseRegistry,
|
|
24
|
+
debounceMs = 200,
|
|
25
|
+
} = {}) {
|
|
26
|
+
if (!readJobFn) {
|
|
27
|
+
throw new Error("readJobFn is required");
|
|
28
|
+
}
|
|
29
|
+
if (!sseRegistry) {
|
|
30
|
+
throw new Error("sseRegistry is required");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pending = new Map(); // jobId -> timeoutId
|
|
34
|
+
// Track jobIds we've already emitted a creation event for so we can
|
|
35
|
+
// emit "job:created" on first successful read, then "job:updated" thereafter.
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
|
|
38
|
+
async function runJobUpdate(jobId) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await readJobFn(jobId);
|
|
41
|
+
if (!res || !res.ok) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// First transform to detailed job schema, then to list schema for SSE
|
|
46
|
+
const detailedJob = transformJobStatus(
|
|
47
|
+
res.data || {},
|
|
48
|
+
jobId,
|
|
49
|
+
res.location
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (!detailedJob) {
|
|
53
|
+
console.warn(
|
|
54
|
+
`[SSEEnhancer] Failed to transform job ${jobId} for broadcast`
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Transform to canonical list schema to match /api/jobs item shape
|
|
60
|
+
const listJob = transformJobListForAPI([detailedJob], {
|
|
61
|
+
includePipelineMetadata: true,
|
|
62
|
+
});
|
|
63
|
+
const canonicalJob = listJob.length > 0 ? listJob[0] : null;
|
|
64
|
+
|
|
65
|
+
if (!canonicalJob) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[SSEEnhancer] Failed to transform job ${jobId} to list schema for broadcast`
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If this is the first successful read for this jobId, emit a
|
|
73
|
+
// "job:created" event so clients can observe new jobs immediately.
|
|
74
|
+
// Subsequent successful reads will emit "job:updated".
|
|
75
|
+
try {
|
|
76
|
+
const alreadySeen = seen.has(jobId);
|
|
77
|
+
const type = alreadySeen ? "job:updated" : "job:created";
|
|
78
|
+
sseRegistry.broadcast({ type, data: canonicalJob });
|
|
79
|
+
if (!alreadySeen) {
|
|
80
|
+
seen.add(jobId);
|
|
81
|
+
}
|
|
82
|
+
} catch (broadcastErr) {
|
|
83
|
+
// If broadcasting fails for any reason, swallow to avoid crashing the enhancer
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// swallow errors - do not broadcast
|
|
87
|
+
return;
|
|
88
|
+
} finally {
|
|
89
|
+
pending.delete(jobId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function handleJobChange(change) {
|
|
94
|
+
if (!change || !change.jobId) return;
|
|
95
|
+
|
|
96
|
+
const jobId = change.jobId;
|
|
97
|
+
|
|
98
|
+
// debounce/coalesce per jobId
|
|
99
|
+
if (pending.has(jobId)) {
|
|
100
|
+
clearTimeout(pending.get(jobId));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const t = setTimeout(() => {
|
|
104
|
+
pending.delete(jobId);
|
|
105
|
+
// fire async update
|
|
106
|
+
void runJobUpdate(jobId);
|
|
107
|
+
}, debounceMs);
|
|
108
|
+
|
|
109
|
+
pending.set(jobId, t);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getPendingCount() {
|
|
113
|
+
return pending.size;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function cleanup() {
|
|
117
|
+
for (const [_, t] of pending) {
|
|
118
|
+
clearTimeout(t);
|
|
119
|
+
}
|
|
120
|
+
pending.clear();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
handleJobChange,
|
|
125
|
+
getPendingCount,
|
|
126
|
+
cleanup,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Singleton using default dependencies if available
|
|
131
|
+
// Try to import default sseRegistry and readJobFn lazily to avoid cycles
|
|
132
|
+
let sseEnhancer = null;
|
|
133
|
+
try {
|
|
134
|
+
// eslint-disable-next-line import/no-mutable-exports
|
|
135
|
+
const { sseRegistry } = await import("./sse.js");
|
|
136
|
+
const { readJob } = await import("./job-reader.js");
|
|
137
|
+
sseEnhancer = createSSEEnhancer({
|
|
138
|
+
readJobFn: readJob,
|
|
139
|
+
sseRegistry,
|
|
140
|
+
debounceMs: 200,
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// In test environments, consumers will create their own using the factory
|
|
144
|
+
// Leave sseEnhancer as null if dependencies not available
|
|
145
|
+
// eslint-disable-next-line no-console
|
|
146
|
+
console.warn("sseEnhancer singleton not initialized:", err?.message || err);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export { sseEnhancer };
|
package/src/ui/sse.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events (SSE) registry for broadcasting to connected clients.
|
|
3
|
+
* Compatibility-focused: tolerant of mock clients, supports typed & untyped
|
|
4
|
+
* broadcasts, dead-client cleanup, optional heartbeats, and optional initial ping.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
setInterval as nodeSetInterval,
|
|
9
|
+
clearInterval as nodeClearInterval,
|
|
10
|
+
} from "node:timers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create an SSE registry.
|
|
14
|
+
* @param {{ heartbeatMs?: number, sendInitialPing?: boolean }} [opts]
|
|
15
|
+
* - heartbeatMs: send periodic keep-alive comments (default 15000)
|
|
16
|
+
* - sendInitialPing: write ': connected\n\n' on addClient (default false)
|
|
17
|
+
*/
|
|
18
|
+
export function createSSERegistry({
|
|
19
|
+
heartbeatMs = 15000,
|
|
20
|
+
sendInitialPing = false,
|
|
21
|
+
} = {}) {
|
|
22
|
+
const clients = new Set(); // Set<{res: http.ServerResponse | {write:Function, end?:Function, on?:Function}, jobId?: string}>
|
|
23
|
+
let heartbeatTimer = null;
|
|
24
|
+
|
|
25
|
+
function _startHeartbeat() {
|
|
26
|
+
if (!heartbeatMs || heartbeatTimer) return;
|
|
27
|
+
heartbeatTimer = nodeSetInterval(() => {
|
|
28
|
+
for (const client of clients) {
|
|
29
|
+
const res = client.res || client;
|
|
30
|
+
try {
|
|
31
|
+
if (typeof res.write === "function") {
|
|
32
|
+
// Comment line per SSE spec; keeps proxies from buffering/closing.
|
|
33
|
+
res.write(`: keep-alive\n\n`);
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Will be cleaned on next broadcast or below
|
|
37
|
+
try {
|
|
38
|
+
typeof res.end === "function" && res.end();
|
|
39
|
+
} catch {}
|
|
40
|
+
clients.delete(client);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}, heartbeatMs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Add a client response to the registry and send headers if possible.
|
|
48
|
+
* Accepts real http.ServerResponse or a test mock {write(), [writeHead], [end], [on]}.
|
|
49
|
+
* @param {any} res
|
|
50
|
+
* @param {Object} [metadata] - Optional metadata for the client (e.g., { jobId })
|
|
51
|
+
*/
|
|
52
|
+
function addClient(res, metadata = {}) {
|
|
53
|
+
const client = { res, ...metadata };
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (typeof res.writeHead === "function") {
|
|
57
|
+
res.writeHead(200, {
|
|
58
|
+
"Content-Type": "text/event-stream",
|
|
59
|
+
"Cache-Control": "no-cache",
|
|
60
|
+
Connection: "keep-alive",
|
|
61
|
+
"X-Accel-Buffering": "no", // helps with nginx buffering
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (sendInitialPing && typeof res.write === "function") {
|
|
65
|
+
// Initial ping so EventSource 'open' resolves quickly (server mode)
|
|
66
|
+
res.write(`: connected\n\n`);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// If headers or initial write fail, avoid crashing tests—still register client
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
clients.add(client);
|
|
73
|
+
_startHeartbeat();
|
|
74
|
+
|
|
75
|
+
if (res && typeof res.on === "function") {
|
|
76
|
+
res.on("close", () => {
|
|
77
|
+
clients.delete(client);
|
|
78
|
+
if (clients.size === 0 && heartbeatTimer) {
|
|
79
|
+
nodeClearInterval(heartbeatTimer);
|
|
80
|
+
heartbeatTimer = null;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove a client (and end its response if possible).
|
|
88
|
+
* @param {any} res
|
|
89
|
+
*/
|
|
90
|
+
function removeClient(res) {
|
|
91
|
+
// Find client by response object (handle both old and new structure)
|
|
92
|
+
let clientToRemove = null;
|
|
93
|
+
for (const client of clients) {
|
|
94
|
+
const clientRes = client.res || client;
|
|
95
|
+
if (clientRes === res) {
|
|
96
|
+
clientToRemove = client;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!clientToRemove) return;
|
|
102
|
+
|
|
103
|
+
const clientRes = clientToRemove.res || clientToRemove;
|
|
104
|
+
try {
|
|
105
|
+
typeof clientRes.end === "function" && clientRes.end();
|
|
106
|
+
} catch {}
|
|
107
|
+
clients.delete(clientToRemove);
|
|
108
|
+
if (clients.size === 0 && heartbeatTimer) {
|
|
109
|
+
nodeClearInterval(heartbeatTimer);
|
|
110
|
+
heartbeatTimer = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Broadcast SSE. Supports:
|
|
116
|
+
* - broadcast({ type, data })
|
|
117
|
+
* - broadcast("eventName", data)
|
|
118
|
+
* - broadcast(data) // untyped 'message' event (data-only)
|
|
119
|
+
*/
|
|
120
|
+
function broadcast(arg1, arg2) {
|
|
121
|
+
/** @type {string | undefined} */
|
|
122
|
+
let type;
|
|
123
|
+
/** @type {any} */
|
|
124
|
+
let data;
|
|
125
|
+
|
|
126
|
+
if (typeof arg1 === "string") {
|
|
127
|
+
type = arg1;
|
|
128
|
+
data = arg2;
|
|
129
|
+
} else if (
|
|
130
|
+
arg1 &&
|
|
131
|
+
typeof arg1 === "object" &&
|
|
132
|
+
("type" in arg1 || "data" in arg1)
|
|
133
|
+
) {
|
|
134
|
+
type = arg1.type;
|
|
135
|
+
data = arg1.data;
|
|
136
|
+
} else {
|
|
137
|
+
type = undefined;
|
|
138
|
+
data = arg1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const payload =
|
|
142
|
+
typeof data === "string" ? data : JSON.stringify(data ?? {});
|
|
143
|
+
const dead = [];
|
|
144
|
+
|
|
145
|
+
for (const client of clients) {
|
|
146
|
+
const res = client.res || client;
|
|
147
|
+
|
|
148
|
+
// Apply jobId filtering: if data has a jobId and client has a jobId, only send if they match
|
|
149
|
+
if (data && data.jobId && client.jobId) {
|
|
150
|
+
if (data.jobId !== client.jobId) {
|
|
151
|
+
continue; // Skip this client - event is for a different job
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
if (typeof res.write !== "function") {
|
|
157
|
+
dead.push(client);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (type) {
|
|
161
|
+
res.write(`event: ${type}\n`);
|
|
162
|
+
}
|
|
163
|
+
res.write(`data: ${payload}\n\n`);
|
|
164
|
+
} catch {
|
|
165
|
+
dead.push(client);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Clean up dead clients
|
|
170
|
+
for (const client of dead) {
|
|
171
|
+
const clientRes = client.res || client;
|
|
172
|
+
try {
|
|
173
|
+
typeof clientRes.end === "function" && clientRes.end();
|
|
174
|
+
} catch {}
|
|
175
|
+
clients.delete(client);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getClientCount() {
|
|
180
|
+
return clients.size;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function closeAll() {
|
|
184
|
+
for (const client of clients) {
|
|
185
|
+
const res = client.res || client;
|
|
186
|
+
try {
|
|
187
|
+
typeof res.end === "function" && res.end();
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
clients.clear();
|
|
191
|
+
if (heartbeatTimer) {
|
|
192
|
+
nodeClearInterval(heartbeatTimer);
|
|
193
|
+
heartbeatTimer = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { addClient, removeClient, broadcast, getClientCount, closeAll };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Export a singleton used by the server: keep initial ping enabled for real EventSource clients
|
|
201
|
+
export const sseRegistry = createSSERegistry({
|
|
202
|
+
heartbeatMs: 15000,
|
|
203
|
+
sendInitialPing: true,
|
|
204
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* composeStateSnapshot
|
|
3
|
+
*
|
|
4
|
+
* Pure function that composes a minimal snapshot object for client bootstrap.
|
|
5
|
+
*
|
|
6
|
+
* Signature:
|
|
7
|
+
* composeStateSnapshot(options?)
|
|
8
|
+
*
|
|
9
|
+
* options:
|
|
10
|
+
* - jobs: Array of job-like objects (optional)
|
|
11
|
+
* - meta: Object with metadata (optional)
|
|
12
|
+
* - transformJob: optional function(job) -> normalizedJob to customize normalization
|
|
13
|
+
*
|
|
14
|
+
* Returns:
|
|
15
|
+
* {
|
|
16
|
+
* jobs: [{ id: string, status: string|null, summary: string|null, updatedAt: string|null }, ...],
|
|
17
|
+
* meta: { version: string|number, lastUpdated: string }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Notes:
|
|
21
|
+
* - This function is pure and does not perform I/O or mutate inputs.
|
|
22
|
+
* - It is defensive about input shapes and provides sensible defaults.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function composeStateSnapshot(options = {}) {
|
|
26
|
+
const { jobs = [], meta, transformJob } = options || {};
|
|
27
|
+
|
|
28
|
+
// Ensure we don't mutate input arrays/objects; work on copies.
|
|
29
|
+
const inputJobs = Array.isArray(jobs) ? jobs.slice() : [];
|
|
30
|
+
|
|
31
|
+
const normalizedJobs = inputJobs.map((j) => {
|
|
32
|
+
// If caller provided a transformJob, prefer its output but still normalize missing fields.
|
|
33
|
+
if (typeof transformJob === "function") {
|
|
34
|
+
const t = transformJob(j) || {};
|
|
35
|
+
return {
|
|
36
|
+
jobId: t.jobId != null ? String(t.jobId) : null,
|
|
37
|
+
status: t.status ?? null,
|
|
38
|
+
title: t.title ?? null,
|
|
39
|
+
updatedAt: t.updatedAt ?? t.lastUpdated ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Best-effort normalization for common fields seen in this repo.
|
|
44
|
+
// Prefer canonical fields, fallback to legacy fields.
|
|
45
|
+
const rawId = j?.jobId ?? j?.id ?? j?.uid ?? j?.job_id ?? j?.jobID ?? null;
|
|
46
|
+
const rawStatus = j?.status ?? j?.state ?? j?.s ?? null;
|
|
47
|
+
const rawTitle = j?.title ?? j?.name ?? j?.summary ?? null;
|
|
48
|
+
const rawUpdated = j?.updatedAt ?? j?.lastUpdated ?? j?.updated_at ?? null;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
jobId: rawId != null ? String(rawId) : null,
|
|
52
|
+
status: rawStatus ?? null,
|
|
53
|
+
title: rawTitle ?? null,
|
|
54
|
+
updatedAt: rawUpdated ?? null,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const resultMeta = {
|
|
59
|
+
version: meta?.version ?? meta ?? "1",
|
|
60
|
+
lastUpdated: meta?.lastUpdated ?? new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
jobs: normalizedJobs,
|
|
65
|
+
meta: resultMeta,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a minimal snapshot composed from files on disk.
|
|
71
|
+
*
|
|
72
|
+
* deps (optional) - injected dependencies for testability:
|
|
73
|
+
* - listAllJobs() -> { current: [], complete: [] }
|
|
74
|
+
* - readJob(jobId, location) -> { ok:true, data, location, path } or error envelope
|
|
75
|
+
* - transformMultipleJobs(readResults) -> Array<job>
|
|
76
|
+
* - now() -> () => new Date()
|
|
77
|
+
* - paths -> optional resolved PATHS object
|
|
78
|
+
*
|
|
79
|
+
* Behavior:
|
|
80
|
+
* - Reads job ids from current then complete
|
|
81
|
+
* - Reads each job (attach jobId & location to the read result)
|
|
82
|
+
* - Transforms reads, dedupes (prefer current), sorts, maps to minimal job fields
|
|
83
|
+
* - Returns { jobs: [...], meta: { version: "1", lastUpdated } }
|
|
84
|
+
*/
|
|
85
|
+
export async function buildSnapshotFromFilesystem(deps = {}) {
|
|
86
|
+
// Prefer injected deps; fall back to local modules for convenience.
|
|
87
|
+
const { listAllJobs, readJob, transformMultipleJobs, now, paths } = deps;
|
|
88
|
+
|
|
89
|
+
// Lazy-import fallbacks when deps not provided.
|
|
90
|
+
// These imports are intentionally dynamic to avoid circular/boot-time issues in tests.
|
|
91
|
+
const jobScanner = listAllJobs
|
|
92
|
+
? null
|
|
93
|
+
: await import("./job-scanner.js").then((m) => m).catch(() => null);
|
|
94
|
+
const jobReader = readJob
|
|
95
|
+
? null
|
|
96
|
+
: await import("./job-reader.js").then((m) => m).catch(() => null);
|
|
97
|
+
const statusTransformer = transformMultipleJobs
|
|
98
|
+
? null
|
|
99
|
+
: await import("./transformers/status-transformer.js")
|
|
100
|
+
.then((m) => m)
|
|
101
|
+
.catch(() => null);
|
|
102
|
+
const configBridge = await import("./config-bridge.js")
|
|
103
|
+
.then((m) => m)
|
|
104
|
+
.catch(() => null);
|
|
105
|
+
|
|
106
|
+
const _listAllJobs = listAllJobs || (jobScanner && jobScanner.listAllJobs);
|
|
107
|
+
const _readJob = readJob || (jobReader && jobReader.readJob);
|
|
108
|
+
const _transformMultipleJobs =
|
|
109
|
+
transformMultipleJobs ||
|
|
110
|
+
(statusTransformer && statusTransformer.transformMultipleJobs);
|
|
111
|
+
const _now = typeof now === "function" ? now : () => new Date();
|
|
112
|
+
const _paths = paths || (configBridge && configBridge.PATHS) || null;
|
|
113
|
+
|
|
114
|
+
if (typeof _listAllJobs !== "function") {
|
|
115
|
+
throw new Error("Missing dependency: listAllJobs");
|
|
116
|
+
}
|
|
117
|
+
if (typeof _readJob !== "function") {
|
|
118
|
+
throw new Error("Missing dependency: readJob");
|
|
119
|
+
}
|
|
120
|
+
if (typeof _transformMultipleJobs !== "function") {
|
|
121
|
+
throw new Error("Missing dependency: transformMultipleJobs");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 1) Enumerate jobs
|
|
125
|
+
const all = (await _listAllJobs()) || {};
|
|
126
|
+
const currentIds = Array.isArray(all.current) ? all.current : [];
|
|
127
|
+
const completeIds = Array.isArray(all.complete) ? all.complete : [];
|
|
128
|
+
|
|
129
|
+
// Build read order: current first, then complete
|
|
130
|
+
const toRead = [
|
|
131
|
+
...currentIds.map((id) => ({ id, location: "current" })),
|
|
132
|
+
...completeIds.map((id) => ({ id, location: "complete" })),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// If no jobs, return empty snapshot
|
|
136
|
+
if (toRead.length === 0) {
|
|
137
|
+
const meta = { version: "1", lastUpdated: _now().toISOString() };
|
|
138
|
+
return { jobs: [], meta };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2) Read jobs concurrently (Promise.all is acceptable for demo-scale)
|
|
142
|
+
const readPromises = toRead.map(async ({ id, location }) => {
|
|
143
|
+
try {
|
|
144
|
+
// Call readJob with (id, location) - extra arg is ignored by implementations that don't accept it.
|
|
145
|
+
const res = await _readJob(id, location);
|
|
146
|
+
// Ensure we attach jobId and location for downstream transformers
|
|
147
|
+
if (res && typeof res === "object") {
|
|
148
|
+
return { ...res, jobId: id, location };
|
|
149
|
+
}
|
|
150
|
+
// If readJob returns non-object, wrap as error
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
code: "read_error",
|
|
154
|
+
message: "Invalid read result",
|
|
155
|
+
jobId: id,
|
|
156
|
+
location,
|
|
157
|
+
};
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.warn(
|
|
160
|
+
`Error reading job ${id} in ${location}: ${err?.message || String(err)}`
|
|
161
|
+
);
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
code: "read_exception",
|
|
165
|
+
message: err?.message || String(err),
|
|
166
|
+
jobId: id,
|
|
167
|
+
location,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const readResults = await Promise.all(readPromises);
|
|
173
|
+
|
|
174
|
+
// 3) Transform reads into canonical job objects
|
|
175
|
+
const transformed = _transformMultipleJobs(readResults || []);
|
|
176
|
+
|
|
177
|
+
// 4) Dedupe by id, preferring earlier entries (current before complete)
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
const deduped = [];
|
|
180
|
+
for (const j of transformed || []) {
|
|
181
|
+
if (!j || !j.id) continue;
|
|
182
|
+
if (seen.has(j.id)) continue;
|
|
183
|
+
seen.add(j.id);
|
|
184
|
+
deduped.push(j);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 5) Sorting
|
|
188
|
+
// Location weight: current=0, complete=1
|
|
189
|
+
const locWeight = (loc) => (loc === "current" ? 0 : 1);
|
|
190
|
+
|
|
191
|
+
// Status priority from Constants if available
|
|
192
|
+
const statusOrder = (configBridge &&
|
|
193
|
+
configBridge.Constants &&
|
|
194
|
+
configBridge.Constants.STATUS_ORDER) || [
|
|
195
|
+
"error",
|
|
196
|
+
"running",
|
|
197
|
+
"complete",
|
|
198
|
+
"pending",
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
const statusPriority = (s) => {
|
|
202
|
+
const idx = statusOrder.indexOf(s);
|
|
203
|
+
return idx === -1 ? statusOrder.length : idx;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const getTime = (j) => {
|
|
207
|
+
const ua =
|
|
208
|
+
j && j.updatedAt ? j.updatedAt : j && j.createdAt ? j.createdAt : null;
|
|
209
|
+
if (!ua) return null;
|
|
210
|
+
// Ensure we compare ISO strings or timestamps consistently
|
|
211
|
+
const t = Date.parse(ua);
|
|
212
|
+
return Number.isNaN(t) ? null : t;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
deduped.sort((a, b) => {
|
|
216
|
+
// 1) location
|
|
217
|
+
const lw = locWeight(a.location) - locWeight(b.location);
|
|
218
|
+
if (lw !== 0) return lw;
|
|
219
|
+
|
|
220
|
+
// 2) status priority (lower is higher priority)
|
|
221
|
+
const sp = statusPriority(a.status) - statusPriority(b.status);
|
|
222
|
+
if (sp !== 0) return sp;
|
|
223
|
+
|
|
224
|
+
// 3) updatedAt descending (newer first)
|
|
225
|
+
const ta = getTime(a);
|
|
226
|
+
const tb = getTime(b);
|
|
227
|
+
if (ta !== null && tb !== null && ta !== tb) return tb - ta;
|
|
228
|
+
if (ta !== null && tb === null) return -1;
|
|
229
|
+
if (ta === null && tb !== null) return 1;
|
|
230
|
+
|
|
231
|
+
// 4) id ascending
|
|
232
|
+
return String(a.id).localeCompare(String(b.id));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// 6) Map to minimal snapshot fields (canonical schema)
|
|
236
|
+
const snapshotJobs = deduped.map((j) => ({
|
|
237
|
+
jobId: j.jobId || j.id,
|
|
238
|
+
title: j.title || j.name || "Unnamed Job",
|
|
239
|
+
status: j.status || "pending",
|
|
240
|
+
progress:
|
|
241
|
+
typeof j.progress === "number" && Number.isFinite(j.progress)
|
|
242
|
+
? j.progress
|
|
243
|
+
: 0,
|
|
244
|
+
createdAt: j.createdAt || null,
|
|
245
|
+
updatedAt: j.updatedAt || j.createdAt || null,
|
|
246
|
+
location: j.location || "current",
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
const meta = { version: "1", lastUpdated: _now().toISOString() };
|
|
250
|
+
|
|
251
|
+
return { jobs: snapshotJobs, meta };
|
|
252
|
+
}
|