@ryanfw/prompt-orchestration-pipeline 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/components/DAGGrid.jsx +157 -47
- package/src/components/ui/RestartJobModal.jsx +26 -6
- package/src/components/ui/StopJobModal.jsx +183 -0
- package/src/core/config.js +7 -3
- package/src/core/lifecycle-policy.js +62 -0
- package/src/core/pipeline-runner.js +312 -217
- package/src/core/status-writer.js +84 -0
- package/src/pages/Code.jsx +8 -1
- package/src/pages/PipelineDetail.jsx +85 -3
- package/src/pages/PromptPipelineDashboard.jsx +10 -11
- package/src/ui/client/adapters/job-adapter.js +60 -0
- package/src/ui/client/api.js +233 -8
- package/src/ui/client/hooks/useJobList.js +14 -1
- package/src/ui/dist/app.js +262 -0
- package/src/ui/dist/assets/{index-DeDzq-Kk.js → index-B320avRx.js} +4854 -2104
- package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
- package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
- package/src/ui/dist/favicon.svg +12 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/endpoints/file-endpoints.js +330 -0
- package/src/ui/endpoints/job-control-endpoints.js +1001 -0
- package/src/ui/endpoints/job-endpoints.js +62 -0
- package/src/ui/endpoints/sse-endpoints.js +223 -0
- package/src/ui/endpoints/state-endpoint.js +85 -0
- package/src/ui/endpoints/upload-endpoints.js +406 -0
- package/src/ui/express-app.js +182 -0
- package/src/ui/server.js +38 -1880
- package/src/ui/sse-broadcast.js +93 -0
- package/src/ui/utils/http-utils.js +139 -0
- package/src/ui/utils/mime-types.js +196 -0
- package/src/ui/vite.config.js +22 -0
- package/src/utils/jobs.js +39 -0
- package/src/ui/dist/assets/style-aBtD_Yrs.css +0 -62
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Exports:
|
|
5
5
|
* - handleJobList() -> { ok: true, data: [...] } | error envelope
|
|
6
6
|
* - handleJobDetail(jobId) -> { ok: true, data: {...} } | error envelope
|
|
7
|
+
* - handleJobListRequest(req, res) -> HTTP response wrapper
|
|
8
|
+
* - handleJobDetailRequest(req, res, jobId) -> HTTP response wrapper
|
|
7
9
|
* - getEndpointStats(jobListResponses, jobDetailResponses) -> stats object
|
|
8
10
|
*
|
|
9
11
|
* These functions return structured results (not HTTP responses) so the server
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
transformJobListForAPI,
|
|
20
22
|
} from "../transformers/list-transformer.js";
|
|
21
23
|
import * as configBridge from "../config-bridge.js";
|
|
24
|
+
import { sendJson } from "../utils/http-utils.js";
|
|
22
25
|
import fs from "node:fs/promises";
|
|
23
26
|
import path from "node:path";
|
|
24
27
|
import { getJobPipelinePath } from "../../config/paths.js";
|
|
@@ -257,6 +260,65 @@ async function handleJobDetailById(jobId) {
|
|
|
257
260
|
}
|
|
258
261
|
}
|
|
259
262
|
|
|
263
|
+
/**
|
|
264
|
+
* HTTP wrapper function for job list requests.
|
|
265
|
+
* Calls handleJobList() and sends the response using sendJson().
|
|
266
|
+
*/
|
|
267
|
+
export async function handleJobListRequest(req, res) {
|
|
268
|
+
console.info("[JobEndpoints] handleJobListRequest called");
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const result = await handleJobList();
|
|
272
|
+
|
|
273
|
+
if (result.ok) {
|
|
274
|
+
sendJson(res, 200, result);
|
|
275
|
+
} else {
|
|
276
|
+
// Map error codes to appropriate HTTP status codes
|
|
277
|
+
const statusCode = result.code === "fs_error" ? 500 : 400;
|
|
278
|
+
sendJson(res, statusCode, result);
|
|
279
|
+
}
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.error("handleJobListRequest unexpected error:", err);
|
|
282
|
+
sendJson(res, 500, {
|
|
283
|
+
ok: false,
|
|
284
|
+
code: "internal_error",
|
|
285
|
+
message: "Internal server error",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* HTTP wrapper function for job detail requests.
|
|
292
|
+
* Calls handleJobDetail(jobId) and sends the response using sendJson().
|
|
293
|
+
*/
|
|
294
|
+
export async function handleJobDetailRequest(req, res, jobId) {
|
|
295
|
+
console.log(`[JobEndpoints] handleJobDetailRequest called for job: ${jobId}`);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const result = await handleJobDetail(jobId);
|
|
299
|
+
|
|
300
|
+
if (result.ok) {
|
|
301
|
+
sendJson(res, 200, result);
|
|
302
|
+
} else {
|
|
303
|
+
// Map error codes to appropriate HTTP status codes
|
|
304
|
+
let statusCode = 400;
|
|
305
|
+
if (result.code === "job_not_found") {
|
|
306
|
+
statusCode = 404;
|
|
307
|
+
} else if (result.code === "fs_error") {
|
|
308
|
+
statusCode = 500;
|
|
309
|
+
}
|
|
310
|
+
sendJson(res, statusCode, result);
|
|
311
|
+
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.error("handleJobDetailRequest unexpected error:", err);
|
|
314
|
+
sendJson(res, 500, {
|
|
315
|
+
ok: false,
|
|
316
|
+
code: "internal_error",
|
|
317
|
+
message: "Internal server error",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
260
322
|
/**
|
|
261
323
|
* Compute endpoint statistics for test assertions.
|
|
262
324
|
* jobListResponses/jobDetailResponses are arrays of response envelopes.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) and state management endpoints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { sseRegistry } from "../sse.js";
|
|
6
|
+
import { sendJson } from "../utils/http-utils.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decorate a change object with jobId and lifecycle information
|
|
10
|
+
*/
|
|
11
|
+
function decorateChangeWithJobId(change) {
|
|
12
|
+
if (!change || typeof change !== "object") return change;
|
|
13
|
+
const normalizedPath = String(change.path || "").replace(/\\/g, "/");
|
|
14
|
+
const match = normalizedPath.match(
|
|
15
|
+
/pipeline-data\/(current|complete|pending|rejected)\/([^/]+)/
|
|
16
|
+
);
|
|
17
|
+
if (!match) {
|
|
18
|
+
return change;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
...change,
|
|
22
|
+
lifecycle: match[1],
|
|
23
|
+
jobId: match[2],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Prioritize job status changes from a list of changes
|
|
29
|
+
*/
|
|
30
|
+
function prioritizeJobStatusChange(changes = []) {
|
|
31
|
+
const normalized = changes.map((change) => decorateChangeWithJobId(change));
|
|
32
|
+
const statusChange = normalized.find(
|
|
33
|
+
(change) =>
|
|
34
|
+
typeof change?.path === "string" &&
|
|
35
|
+
/tasks-status\.json$/.test(change.path)
|
|
36
|
+
);
|
|
37
|
+
return statusChange || normalized[0] || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Broadcast state update to all SSE clients
|
|
42
|
+
*
|
|
43
|
+
* NOTE: Per plan, SSE should emit compact, incremental events rather than
|
|
44
|
+
* streaming full application state. Use /api/state for full snapshot
|
|
45
|
+
* retrieval on client bootstrap. This function will emit only the most
|
|
46
|
+
* recent change when available (type: "state:change") and fall back to a
|
|
47
|
+
* lightweight summary event if no recent change is present.
|
|
48
|
+
*/
|
|
49
|
+
function broadcastStateUpdate(currentState) {
|
|
50
|
+
try {
|
|
51
|
+
const recentChanges = (currentState && currentState.recentChanges) || [];
|
|
52
|
+
const latest = prioritizeJobStatusChange(recentChanges);
|
|
53
|
+
console.debug("[Server] Broadcasting state update:", {
|
|
54
|
+
latest,
|
|
55
|
+
currentState,
|
|
56
|
+
});
|
|
57
|
+
if (latest) {
|
|
58
|
+
// Emit only the most recent change as a compact, typed event
|
|
59
|
+
const eventData = { type: "state:change", data: latest };
|
|
60
|
+
console.debug("[Server] Broadcasting event:", eventData);
|
|
61
|
+
sseRegistry.broadcast(eventData);
|
|
62
|
+
} else {
|
|
63
|
+
// Fallback: emit a minimal summary so clients can observe a state "tick"
|
|
64
|
+
const eventData = {
|
|
65
|
+
type: "state:summary",
|
|
66
|
+
data: {
|
|
67
|
+
changeCount:
|
|
68
|
+
currentState && currentState.changeCount
|
|
69
|
+
? currentState.changeCount
|
|
70
|
+
: 0,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
console.debug("[Server] Broadcasting summary event:", eventData);
|
|
74
|
+
sseRegistry.broadcast(eventData);
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// Defensive: if something unexpected happens, fall back to a lightweight notification
|
|
78
|
+
try {
|
|
79
|
+
console.error("[Server] Error in broadcastStateUpdate:", err);
|
|
80
|
+
sseRegistry.broadcast({
|
|
81
|
+
type: "state:summary",
|
|
82
|
+
data: {
|
|
83
|
+
changeCount:
|
|
84
|
+
currentState && currentState.changeCount
|
|
85
|
+
? currentState.changeCount
|
|
86
|
+
: 0,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
} catch (fallbackErr) {
|
|
90
|
+
// Log error to aid debugging; this should never happen unless sseRegistry.broadcast is broken
|
|
91
|
+
console.error(
|
|
92
|
+
"Failed to broadcast fallback state summary in broadcastStateUpdate:",
|
|
93
|
+
fallbackErr
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Handle SSE events endpoint (/api/events)
|
|
101
|
+
*/
|
|
102
|
+
function handleSseEvents(req, res, searchParams) {
|
|
103
|
+
// Parse jobId from query parameters for filtering
|
|
104
|
+
const jobId = searchParams.get("jobId");
|
|
105
|
+
|
|
106
|
+
// Set SSE headers
|
|
107
|
+
res.writeHead(200, {
|
|
108
|
+
"Content-Type": "text/event-stream",
|
|
109
|
+
"Cache-Control": "no-cache",
|
|
110
|
+
Connection: "keep-alive",
|
|
111
|
+
"Access-Control-Allow-Origin": "*",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Flush headers immediately
|
|
115
|
+
res.flushHeaders();
|
|
116
|
+
|
|
117
|
+
// Initial full-state is no longer sent over the SSE stream.
|
|
118
|
+
// Clients should fetch the snapshot from GET /api/state during bootstrap
|
|
119
|
+
// and then rely on SSE incremental events (state:change/state:summary).
|
|
120
|
+
// Keep headers flushed; sseRegistry.addClient will optionally send an initial ping.
|
|
121
|
+
// (Previously sent full state here; removed to reduce SSE payloads.)
|
|
122
|
+
|
|
123
|
+
// Add to SSE registry with jobId metadata for filtering
|
|
124
|
+
sseRegistry.addClient(res, { jobId });
|
|
125
|
+
|
|
126
|
+
// Start heartbeat for this connection
|
|
127
|
+
const heartbeatInterval = setInterval(() => {
|
|
128
|
+
try {
|
|
129
|
+
res.write(
|
|
130
|
+
`event: heartbeat\ndata: ${JSON.stringify({ timestamp: Date.now() })}\n\n`
|
|
131
|
+
);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Client disconnected, stop heartbeat
|
|
134
|
+
clearInterval(heartbeatInterval);
|
|
135
|
+
}
|
|
136
|
+
}, 30000);
|
|
137
|
+
|
|
138
|
+
// Remove client on disconnect
|
|
139
|
+
req.on("close", () => {
|
|
140
|
+
clearInterval(heartbeatInterval);
|
|
141
|
+
sseRegistry.removeClient(res);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Handle API state endpoint (/api/state)
|
|
147
|
+
*/
|
|
148
|
+
async function handleApiState(req, res) {
|
|
149
|
+
if (req.method !== "GET") {
|
|
150
|
+
sendJson(res, 200, {
|
|
151
|
+
success: false,
|
|
152
|
+
error: "Method not allowed",
|
|
153
|
+
allowed: ["GET"],
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Prefer returning in-memory state when available (tests and runtime rely on state.getState()).
|
|
159
|
+
// If in-memory state is available, return it directly; otherwise fall back to
|
|
160
|
+
// building a filesystem-backed snapshot for client bootstrap.
|
|
161
|
+
try {
|
|
162
|
+
// Dynamically import state to avoid circular dependencies
|
|
163
|
+
const state = await import("../state.js");
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
if (state && typeof state.getState === "function") {
|
|
167
|
+
const inMemory = state.getState();
|
|
168
|
+
if (inMemory) {
|
|
169
|
+
sendJson(res, 200, inMemory);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch (innerErr) {
|
|
174
|
+
// If reading in-memory state throws for some reason, fall back to snapshot
|
|
175
|
+
console.warn("Warning: failed to retrieve in-memory state:", innerErr);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build a filesystem-backed snapshot for client bootstrap.
|
|
179
|
+
// Dynamically import the composer and dependencies to avoid circular import issues.
|
|
180
|
+
const [
|
|
181
|
+
{ buildSnapshotFromFilesystem },
|
|
182
|
+
jobScannerModule,
|
|
183
|
+
jobReaderModule,
|
|
184
|
+
statusTransformerModule,
|
|
185
|
+
configBridgeModule,
|
|
186
|
+
] = await Promise.all([
|
|
187
|
+
import("../state-snapshot.js"),
|
|
188
|
+
import("../job-scanner.js").catch(() => null),
|
|
189
|
+
import("../job-reader.js").catch(() => null),
|
|
190
|
+
import("../transformers/status-transformer.js").catch(() => null),
|
|
191
|
+
import("../config-bridge.js").catch(() => null),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const snapshot = await buildSnapshotFromFilesystem({
|
|
195
|
+
listAllJobs:
|
|
196
|
+
jobScannerModule && jobScannerModule.listAllJobs
|
|
197
|
+
? jobScannerModule.listAllJobs
|
|
198
|
+
: undefined,
|
|
199
|
+
readJob:
|
|
200
|
+
jobReaderModule && jobReaderModule.readJob
|
|
201
|
+
? jobReaderModule.readJob
|
|
202
|
+
: undefined,
|
|
203
|
+
transformMultipleJobs:
|
|
204
|
+
statusTransformerModule && statusTransformerModule.transformMultipleJobs
|
|
205
|
+
? statusTransformerModule.transformMultipleJobs
|
|
206
|
+
: undefined,
|
|
207
|
+
now: () => new Date(),
|
|
208
|
+
paths: (configBridgeModule && configBridgeModule.PATHS) || undefined,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
sendJson(res, 200, snapshot);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error("Failed to build /api/state snapshot:", err);
|
|
214
|
+
sendJson(res, 500, {
|
|
215
|
+
ok: false,
|
|
216
|
+
code: "snapshot_error",
|
|
217
|
+
message: "Failed to build state snapshot",
|
|
218
|
+
details: err && err.message ? err.message : String(err),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export { handleSseEvents, handleApiState, broadcastStateUpdate };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handle GET /api/state endpoint
|
|
3
|
+
*/
|
|
4
|
+
import * as state from "../state.js";
|
|
5
|
+
import { sendJson } from "../utils/http-utils.js";
|
|
6
|
+
|
|
7
|
+
export async function handleApiState(req, res) {
|
|
8
|
+
if (req.method !== "GET") {
|
|
9
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
10
|
+
res.end(
|
|
11
|
+
JSON.stringify({
|
|
12
|
+
success: false,
|
|
13
|
+
error: "Method not allowed",
|
|
14
|
+
allowed: ["GET"],
|
|
15
|
+
})
|
|
16
|
+
);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Prefer returning in-memory state when available (tests and runtime rely on state.getState()).
|
|
21
|
+
// If in-memory state is available, return it directly; otherwise fall back to
|
|
22
|
+
// building a filesystem-backed snapshot for client bootstrap.
|
|
23
|
+
try {
|
|
24
|
+
try {
|
|
25
|
+
if (state && typeof state.getState === "function") {
|
|
26
|
+
const inMemory = state.getState();
|
|
27
|
+
if (inMemory) {
|
|
28
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
29
|
+
res.end(JSON.stringify(inMemory));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch (innerErr) {
|
|
34
|
+
// If reading in-memory state throws for some reason, fall back to snapshot
|
|
35
|
+
console.warn("Warning: failed to retrieve in-memory state:", innerErr);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build a filesystem-backed snapshot for client bootstrap.
|
|
39
|
+
// Dynamically import the composer and dependencies to avoid circular import issues.
|
|
40
|
+
const [
|
|
41
|
+
{ buildSnapshotFromFilesystem },
|
|
42
|
+
jobScannerModule,
|
|
43
|
+
jobReaderModule,
|
|
44
|
+
statusTransformerModule,
|
|
45
|
+
configBridgeModule,
|
|
46
|
+
] = await Promise.all([
|
|
47
|
+
import("../state-snapshot.js"),
|
|
48
|
+
import("../job-scanner.js").catch(() => null),
|
|
49
|
+
import("../job-reader.js").catch(() => null),
|
|
50
|
+
import("../transformers/status-transformer.js").catch(() => null),
|
|
51
|
+
import("../config-bridge.js").catch(() => null),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const snapshot = await buildSnapshotFromFilesystem({
|
|
55
|
+
listAllJobs:
|
|
56
|
+
jobScannerModule && jobScannerModule.listAllJobs
|
|
57
|
+
? jobScannerModule.listAllJobs
|
|
58
|
+
: undefined,
|
|
59
|
+
readJob:
|
|
60
|
+
jobReaderModule && jobReaderModule.readJob
|
|
61
|
+
? jobReaderModule.readJob
|
|
62
|
+
: undefined,
|
|
63
|
+
transformMultipleJobs:
|
|
64
|
+
statusTransformerModule && statusTransformerModule.transformMultipleJobs
|
|
65
|
+
? statusTransformerModule.transformMultipleJobs
|
|
66
|
+
: undefined,
|
|
67
|
+
now: () => new Date(),
|
|
68
|
+
paths: (configBridgeModule && configBridgeModule.PATHS) || undefined,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
72
|
+
res.end(JSON.stringify(snapshot));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error("Failed to build /api/state snapshot:", err);
|
|
75
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
76
|
+
res.end(
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
ok: false,
|
|
79
|
+
code: "snapshot_error",
|
|
80
|
+
message: "Failed to build state snapshot",
|
|
81
|
+
details: err && err.message ? err.message : String(err),
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|