@ryanfw/prompt-orchestration-pipeline 0.10.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.
Files changed (42) hide show
  1. package/package.json +3 -1
  2. package/src/api/index.js +38 -1
  3. package/src/components/DAGGrid.jsx +180 -53
  4. package/src/components/JobDetail.jsx +11 -0
  5. package/src/components/TaskDetailSidebar.jsx +27 -3
  6. package/src/components/UploadSeed.jsx +2 -2
  7. package/src/components/ui/RestartJobModal.jsx +26 -6
  8. package/src/components/ui/StopJobModal.jsx +183 -0
  9. package/src/core/config.js +7 -3
  10. package/src/core/lifecycle-policy.js +62 -0
  11. package/src/core/orchestrator.js +32 -0
  12. package/src/core/pipeline-runner.js +312 -217
  13. package/src/core/status-initializer.js +155 -0
  14. package/src/core/status-writer.js +235 -13
  15. package/src/pages/Code.jsx +8 -1
  16. package/src/pages/PipelineDetail.jsx +85 -3
  17. package/src/pages/PromptPipelineDashboard.jsx +10 -11
  18. package/src/ui/client/adapters/job-adapter.js +81 -2
  19. package/src/ui/client/api.js +233 -8
  20. package/src/ui/client/hooks/useJobDetailWithUpdates.js +92 -0
  21. package/src/ui/client/hooks/useJobList.js +14 -1
  22. package/src/ui/dist/app.js +262 -0
  23. package/src/ui/dist/assets/{index-DqkbzXZ1.js → index-B320avRx.js} +5051 -2186
  24. package/src/ui/dist/assets/index-B320avRx.js.map +1 -0
  25. package/src/ui/dist/assets/style-BYCoLBnK.css +62 -0
  26. package/src/ui/dist/favicon.svg +12 -0
  27. package/src/ui/dist/index.html +2 -2
  28. package/src/ui/endpoints/file-endpoints.js +330 -0
  29. package/src/ui/endpoints/job-control-endpoints.js +1001 -0
  30. package/src/ui/endpoints/job-endpoints.js +62 -0
  31. package/src/ui/endpoints/sse-endpoints.js +223 -0
  32. package/src/ui/endpoints/state-endpoint.js +85 -0
  33. package/src/ui/endpoints/upload-endpoints.js +406 -0
  34. package/src/ui/express-app.js +182 -0
  35. package/src/ui/server.js +38 -1788
  36. package/src/ui/sse-broadcast.js +93 -0
  37. package/src/ui/utils/http-utils.js +139 -0
  38. package/src/ui/utils/mime-types.js +196 -0
  39. package/src/ui/vite.config.js +22 -0
  40. package/src/ui/zip-utils.js +103 -0
  41. package/src/utils/jobs.js +39 -0
  42. package/src/ui/dist/assets/style-DBF9NQGk.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
+ }