@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
@@ -3,6 +3,8 @@ import {
3
3
  normalizeTaskState,
4
4
  deriveJobStatusFromTasks,
5
5
  } from "../../../config/statuses.js";
6
+ import { classifyJobForDisplay } from "../../../utils/jobs.js";
7
+ import { decideTransition } from "../../../core/lifecycle-policy.js";
6
8
 
7
9
  /**
8
10
  * Normalize a raw task state into canonical enum.
@@ -75,6 +77,8 @@ function normalizeTasks(rawTasks) {
75
77
  : undefined,
76
78
  // Preserve tokenUsage if present
77
79
  ...(t && t.tokenUsage ? { tokenUsage: t.tokenUsage } : {}),
80
+ // Preserve error object if present
81
+ ...(t && t.error ? { error: t.error } : {}),
78
82
  };
79
83
  tasks[name] = taskObj;
80
84
  });
@@ -106,11 +110,28 @@ function normalizeTasks(rawTasks) {
106
110
  ...(typeof t?.failedStage === "string" && t.failedStage.length > 0
107
111
  ? { failedStage: t.failedStage }
108
112
  : {}),
113
+ // Prefer new files.* schema, fallback to legacy artifacts
114
+ files:
115
+ t && t.files
116
+ ? {
117
+ artifacts: Array.isArray(t.files.artifacts)
118
+ ? t.files.artifacts.slice()
119
+ : [],
120
+ logs: Array.isArray(t.files.logs) ? t.files.logs.slice() : [],
121
+ tmp: Array.isArray(t.files.tmp) ? t.files.tmp.slice() : [],
122
+ }
123
+ : {
124
+ artifacts: [],
125
+ logs: [],
126
+ tmp: [],
127
+ },
109
128
  artifacts: Array.isArray(t && t.artifacts)
110
129
  ? t.artifacts.slice()
111
130
  : undefined,
112
131
  // Preserve tokenUsage if present
113
132
  ...(t && t.tokenUsage ? { tokenUsage: t.tokenUsage } : {}),
133
+ // Preserve error object if present
134
+ ...(t && t.error ? { error: t.error } : {}),
114
135
  };
115
136
  });
116
137
  return { tasks, warnings };
@@ -152,7 +173,7 @@ export function adaptJobSummary(apiJob) {
152
173
  // Demo-only: read canonical fields strictly
153
174
  const id = apiJob.jobId;
154
175
  const name = apiJob.title || "";
155
- const rawTasks = apiJob.tasks;
176
+ const rawTasks = apiJob.tasks || apiJob.tasksStatus; // Handle both formats for backward compatibility
156
177
  const location = apiJob.location;
157
178
 
158
179
  // Job-level stage metadata
@@ -206,6 +227,9 @@ export function adaptJobSummary(apiJob) {
206
227
  job.totalTokens = job.costsSummary.totalTokens;
207
228
  }
208
229
 
230
+ // Compute and attach display category for UI bucketing
231
+ job.displayCategory = classifyJobForDisplay(job);
232
+
209
233
  // Include warnings for debugging
210
234
  if (warnings.length > 0) job.__warnings = warnings;
211
235
 
@@ -221,7 +245,7 @@ export function adaptJobDetail(apiDetail) {
221
245
  // Demo-only: read canonical fields strictly
222
246
  const id = apiDetail.jobId;
223
247
  const name = apiDetail.title || "";
224
- const rawTasks = apiDetail.tasks;
248
+ const rawTasks = apiDetail.tasks || apiDetail.tasksStatus; // Handle both formats for backward compatibility
225
249
  const location = apiDetail.location;
226
250
 
227
251
  // Job-level stage metadata
@@ -265,8 +289,63 @@ export function adaptJobDetail(apiDetail) {
265
289
  detail.costs = apiDetail.costs;
266
290
  }
267
291
 
292
+ // Compute and attach display category for UI bucketing
293
+ detail.displayCategory = classifyJobForDisplay(detail);
294
+
268
295
  // Include warnings for debugging
269
296
  if (warnings.length > 0) detail.__warnings = warnings;
270
297
 
271
298
  return detail;
272
299
  }
300
+
301
+ /**
302
+ * deriveAllowedActions(adaptedJob, pipelineTasks)
303
+ * - adaptedJob: normalized job object from adaptJobSummary/adaptJobDetail
304
+ * - pipelineTasks: array of task names in execution order from pipeline.json
305
+ * Returns { start, restart } boolean flags for UI controls.
306
+ */
307
+ export function deriveAllowedActions(adaptedJob, pipelineTasks) {
308
+ // Check if any task is running
309
+ const hasRunningTask = Object.values(adaptedJob.tasks || {}).some(
310
+ (task) => task.state === "running"
311
+ );
312
+
313
+ // Default to disabled if job state is running or any task is running
314
+ if (adaptedJob.status === "running" || hasRunningTask) {
315
+ return { start: false, restart: false };
316
+ }
317
+
318
+ // Default to enabled for restart if not running
319
+ const restart = true;
320
+
321
+ // Start requires checking if ANY task can be started (not ALL tasks)
322
+ // Edge case: if no pipeline tasks, default to enabled
323
+ if (pipelineTasks.length === 0) {
324
+ return { start: true, restart };
325
+ }
326
+
327
+ const start = pipelineTasks.some((taskName) => {
328
+ const task = adaptedJob.tasks[taskName];
329
+ if (!task) return false; // Task not found, skip
330
+
331
+ const taskState = task.state || "pending";
332
+
333
+ // Check if all upstream tasks are done for dependency evaluation
334
+ const taskIndex = pipelineTasks.indexOf(taskName);
335
+ const upstreamTasks = pipelineTasks.slice(0, taskIndex);
336
+ const dependenciesReady = upstreamTasks.every((upstreamTaskName) => {
337
+ const upstreamTask = adaptedJob.tasks[upstreamTaskName];
338
+ return upstreamTask && upstreamTask.state === "done";
339
+ });
340
+
341
+ const startDecision = decideTransition({
342
+ op: "start",
343
+ taskState,
344
+ dependenciesReady,
345
+ });
346
+
347
+ return startDecision.ok;
348
+ });
349
+
350
+ return { start, restart };
351
+ }
@@ -3,12 +3,14 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Restart a job with clean-slate mode
6
+ * Restart a job with clean-slate mode or from a specific task
7
7
  *
8
8
  * @param {string} jobId - The ID of the job to restart
9
9
  * @param {Object} opts - Options object
10
- * @param {Object} opts.options - Additional options for the restart
11
- * @param {boolean} opts.options.clearTokenUsage - Whether to clear token usage (default: true)
10
+ * @param {string} [opts.fromTask] - Task ID to restart from (inclusive)
11
+ * @param {boolean} [opts.singleTask] - Whether to run only the target task and then stop
12
+ * @param {Object} [opts.options] - Additional options for the restart
13
+ * @param {boolean} [opts.options.clearTokenUsage=true] - Whether to clear token usage
12
14
  * @returns {Promise<Object>} Parsed JSON response from the server
13
15
  * @throws {Object} Structured error object with { code, message } for non-2xx responses
14
16
  */
@@ -19,8 +21,16 @@ export async function restartJob(jobId, opts = {}) {
19
21
  };
20
22
 
21
23
  const requestBody = opts.fromTask
22
- ? { fromTask: opts.fromTask, options }
23
- : { mode: "clean-slate", options };
24
+ ? {
25
+ fromTask: opts.fromTask,
26
+ options,
27
+ ...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
28
+ }
29
+ : {
30
+ mode: "clean-slate",
31
+ options,
32
+ ...(opts.singleTask !== undefined && { singleTask: opts.singleTask }),
33
+ };
24
34
 
25
35
  try {
26
36
  const response = await fetch(
@@ -67,6 +77,164 @@ export async function restartJob(jobId, opts = {}) {
67
77
  }
68
78
  }
69
79
 
80
+ /**
81
+ * Rescan a job to synchronize tasks with the pipeline definition, detecting both added and removed tasks.
82
+ *
83
+ * @param {string} jobId - The ID of the job to rescan
84
+ * @returns {Promise<Object>} Parsed JSON response from the server
85
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
86
+ */
87
+ export async function rescanJob(jobId) {
88
+ try {
89
+ const response = await fetch(
90
+ `/api/jobs/${encodeURIComponent(jobId)}/rescan`,
91
+ {
92
+ method: "POST",
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ },
96
+ }
97
+ );
98
+
99
+ if (!response.ok) {
100
+ // Try to parse error response, fall back to status text if parsing fails
101
+ let errorData;
102
+ try {
103
+ errorData = await response.json();
104
+ } catch {
105
+ errorData = { message: response.statusText };
106
+ }
107
+
108
+ // Throw structured error with code and message
109
+ throw {
110
+ code: errorData.code || getErrorCodeFromStatus(response.status),
111
+ message:
112
+ errorData.message || getErrorMessageFromStatus(response.status),
113
+ status: response.status,
114
+ };
115
+ }
116
+
117
+ // Return parsed JSON for successful responses
118
+ return await response.json();
119
+ } catch (error) {
120
+ // Re-throw structured errors as-is
121
+ if (error.code && error.message) {
122
+ throw error;
123
+ }
124
+
125
+ // Handle network errors or other unexpected errors
126
+ throw {
127
+ code: "network_error",
128
+ message: error.message || "Failed to connect to server",
129
+ };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Start a specific pending task for a job that is not actively running
135
+ *
136
+ * @param {string} jobId - The ID of the job
137
+ * @param {string} taskId - The ID of the task to start
138
+ * @returns {Promise<Object>} Parsed JSON response from the server
139
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
140
+ */
141
+ export async function startTask(jobId, taskId) {
142
+ try {
143
+ const response = await fetch(
144
+ `/api/jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(taskId)}/start`,
145
+ {
146
+ method: "POST",
147
+ headers: {
148
+ "Content-Type": "application/json",
149
+ },
150
+ }
151
+ );
152
+
153
+ if (!response.ok) {
154
+ // Try to parse error response, fall back to status text if parsing fails
155
+ let errorData;
156
+ try {
157
+ errorData = await response.json();
158
+ } catch {
159
+ errorData = { message: response.statusText };
160
+ }
161
+
162
+ // Throw structured error with code and message
163
+ throw {
164
+ code: errorData.code || getErrorCodeFromStatus(response.status),
165
+ message: getStartTaskErrorMessage(errorData, response.status),
166
+ status: response.status,
167
+ };
168
+ }
169
+
170
+ // Return parsed JSON for successful responses
171
+ return await response.json();
172
+ } catch (error) {
173
+ // Re-throw structured errors as-is
174
+ if (error.code && error.message) {
175
+ throw error;
176
+ }
177
+
178
+ // Handle network errors or other unexpected errors
179
+ throw {
180
+ code: "network_error",
181
+ message: error.message || "Failed to connect to server",
182
+ };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Stop a running job's pipeline
188
+ *
189
+ * @param {string} jobId - The ID of the job to stop
190
+ * @returns {Promise<Object>} Parsed JSON response from the server
191
+ * @throws {Object} Structured error object with { code, message } for non-2xx responses
192
+ */
193
+ export async function stopJob(jobId) {
194
+ try {
195
+ const response = await fetch(
196
+ `/api/jobs/${encodeURIComponent(jobId)}/stop`,
197
+ {
198
+ method: "POST",
199
+ headers: {
200
+ "Content-Type": "application/json",
201
+ },
202
+ }
203
+ );
204
+
205
+ if (!response.ok) {
206
+ // Try to parse error response, fall back to status text if parsing fails
207
+ let errorData;
208
+ try {
209
+ errorData = await response.json();
210
+ } catch {
211
+ errorData = { message: response.statusText };
212
+ }
213
+
214
+ // Throw structured error with code and message
215
+ throw {
216
+ code: errorData.code || getErrorCodeFromStatus(response.status),
217
+ message: getStopErrorMessage(errorData, response.status),
218
+ status: response.status,
219
+ };
220
+ }
221
+
222
+ // Return parsed JSON for successful responses
223
+ return await response.json();
224
+ } catch (error) {
225
+ // Re-throw structured errors as-is
226
+ if (error.code && error.message) {
227
+ throw error;
228
+ }
229
+
230
+ // Handle network errors or other unexpected errors
231
+ throw {
232
+ code: "network_error",
233
+ message: error.message || "Failed to connect to server",
234
+ };
235
+ }
236
+ }
237
+
70
238
  /**
71
239
  * Map HTTP status codes to error codes for structured error handling
72
240
  */
@@ -108,9 +276,6 @@ function getRestartErrorMessage(errorData, status) {
108
276
  if (errorData.code === "job_running") {
109
277
  return "Job is currently running; restart is unavailable.";
110
278
  }
111
- if (errorData.code === "unsupported_lifecycle") {
112
- return "Job must be in current to restart.";
113
- }
114
279
  if (errorData.message?.includes("job_running")) {
115
280
  return "Job is currently running; restart is unavailable.";
116
281
  }
@@ -132,3 +297,63 @@ function getRestartErrorMessage(errorData, status) {
132
297
  // Fall back to provided message or default
133
298
  return errorData.message || "Failed to restart job.";
134
299
  }
300
+
301
+ /**
302
+ * Get specific error message from error response for start task functionality
303
+ */
304
+ function getStartTaskErrorMessage(errorData, status) {
305
+ // Handle specific 409 conflict errors
306
+ if (status === 409) {
307
+ if (errorData.code === "job_running") {
308
+ return "Job is currently running; start is unavailable.";
309
+ }
310
+ if (errorData.code === "dependencies_not_satisfied") {
311
+ return "Dependencies not satisfied for task.";
312
+ }
313
+ if (errorData.code === "unsupported_lifecycle") {
314
+ return "Job must be in current to start a task.";
315
+ }
316
+ return "Request conflict.";
317
+ }
318
+
319
+ // Handle 404 errors
320
+ if (status === 404) {
321
+ return "Job not found.";
322
+ }
323
+
324
+ // Handle 400 errors
325
+ if (status === 400) {
326
+ return errorData.message || "Bad request";
327
+ }
328
+
329
+ // Handle 500 errors
330
+ if (status === 500) {
331
+ return "Internal server error";
332
+ }
333
+
334
+ // Fall back to provided message or default
335
+ return errorData.message || "Failed to start task.";
336
+ }
337
+
338
+ /**
339
+ * Get specific error message from error response for stop functionality
340
+ */
341
+ function getStopErrorMessage(errorData, status) {
342
+ // Handle 404 errors
343
+ if (status === 404) {
344
+ return "Job not found.";
345
+ }
346
+
347
+ // Handle 409 errors
348
+ if (status === 409) {
349
+ return errorData.message || "Job stop is already in progress.";
350
+ }
351
+
352
+ // Handle 500 errors
353
+ if (status === 500) {
354
+ return "Internal server error";
355
+ }
356
+
357
+ // Fall back to provided message or default
358
+ return errorData.message || `Request failed with status ${status}`;
359
+ }
@@ -347,6 +347,7 @@ export function useJobDetailWithUpdates(jobId) {
347
347
  newEs.addEventListener("job:removed", onJobRemoved);
348
348
  newEs.addEventListener("status:changed", onStatusChanged);
349
349
  newEs.addEventListener("state:change", onStateChange);
350
+ newEs.addEventListener("task:updated", onTaskUpdated);
350
351
  newEs.addEventListener("error", onError);
351
352
 
352
353
  esRef.current = newEs;
@@ -373,6 +374,94 @@ export function useJobDetailWithUpdates(jobId) {
373
374
  return;
374
375
  }
375
376
 
377
+ // Handle task:updated events with task-level merge logic
378
+ if (type === "task:updated") {
379
+ const p = payload || {};
380
+ const { jobId: eventJobId, taskId, task } = p;
381
+
382
+ // Filter by jobId
383
+ if (eventJobId && eventJobId !== jobId) {
384
+ return;
385
+ }
386
+
387
+ // Validate required fields
388
+ if (!taskId || !task) {
389
+ return;
390
+ }
391
+
392
+ startTransition(() => {
393
+ setData((prev) => {
394
+ // If no previous data or tasks, return unchanged
395
+ if (!prev || !prev.tasks) {
396
+ return prev;
397
+ }
398
+
399
+ const prevTask = prev.tasks[taskId];
400
+
401
+ // Compare observable fields to determine if update is needed
402
+ const fieldsToCompare = [
403
+ "state",
404
+ "currentStage",
405
+ "failedStage",
406
+ "startedAt",
407
+ "endedAt",
408
+ "attempts",
409
+ "executionTimeMs",
410
+ "error",
411
+ ];
412
+
413
+ let hasChanged = false;
414
+
415
+ // If task doesn't exist yet, always treat as changed
416
+ if (!prevTask) {
417
+ hasChanged = true;
418
+ } else {
419
+ // Compare observable fields to determine if update is needed
420
+ for (const field of fieldsToCompare) {
421
+ if (prevTask[field] !== task[field]) {
422
+ hasChanged = true;
423
+ break;
424
+ }
425
+ }
426
+
427
+ // Also compare tokenUsage and files arrays by reference
428
+ if (
429
+ prevTask.tokenUsage !== task.tokenUsage ||
430
+ prevTask.files !== task.files
431
+ ) {
432
+ hasChanged = true;
433
+ }
434
+ }
435
+
436
+ if (!hasChanged) {
437
+ return prev; // No change, preserve identity
438
+ }
439
+
440
+ // Create new tasks map with updated task
441
+ const nextTasks = { ...prev.tasks, [taskId]: task };
442
+
443
+ // Recompute job-level summary fields
444
+ const taskCount = Object.keys(nextTasks).length;
445
+ const doneCount = Object.values(nextTasks).filter(
446
+ (t) => t.state === "done"
447
+ ).length;
448
+ const progress =
449
+ taskCount > 0 ? (doneCount / taskCount) * 100 : 0;
450
+
451
+ // Return new job object with updated tasks and summary
452
+ return {
453
+ ...prev,
454
+ tasks: nextTasks,
455
+ doneCount,
456
+ taskCount,
457
+ progress,
458
+ lastUpdated: new Date().toISOString(),
459
+ };
460
+ });
461
+ });
462
+ return; // Skip generic handling for task:updated
463
+ }
464
+
376
465
  // Path-matching state:change → schedule debounced refetch
377
466
  if (type === "state:change") {
378
467
  const d = (payload && (payload.data || payload)) || {};
@@ -415,6 +504,7 @@ export function useJobDetailWithUpdates(jobId) {
415
504
  const onStatusChanged = (evt) =>
416
505
  handleIncomingEvent("status:changed", evt);
417
506
  const onStateChange = (evt) => handleIncomingEvent("state:change", evt);
507
+ const onTaskUpdated = (evt) => handleIncomingEvent("task:updated", evt);
418
508
 
419
509
  es.addEventListener("open", onOpen);
420
510
  es.addEventListener("job:updated", onJobUpdated);
@@ -422,6 +512,7 @@ export function useJobDetailWithUpdates(jobId) {
422
512
  es.addEventListener("job:removed", onJobRemoved);
423
513
  es.addEventListener("status:changed", onStatusChanged);
424
514
  es.addEventListener("state:change", onStateChange);
515
+ es.addEventListener("task:updated", onTaskUpdated);
425
516
  es.addEventListener("error", onError);
426
517
 
427
518
  // Set connection status from readyState when possible
@@ -439,6 +530,7 @@ export function useJobDetailWithUpdates(jobId) {
439
530
  es.removeEventListener("job:removed", onJobRemoved);
440
531
  es.removeEventListener("status:changed", onStatusChanged);
441
532
  es.removeEventListener("state:change", onStateChange);
533
+ es.removeEventListener("task:updated", onTaskUpdated);
442
534
  es.removeEventListener("error", onError);
443
535
  es.close();
444
536
  } catch (err) {
@@ -20,7 +20,20 @@ export function useJobList() {
20
20
  setData(null);
21
21
  } else {
22
22
  const json = await res.json();
23
- setData(json);
23
+ if (json && typeof json === "object" && "ok" in json) {
24
+ if (json.ok && Array.isArray(json.data)) {
25
+ setData(json.data);
26
+ } else if (!json.ok) {
27
+ setError({ code: json.code, message: json.message });
28
+ setData(null);
29
+ } else {
30
+ // Fallback: data is not an array; treat as empty to avoid crashes
31
+ setData([]);
32
+ }
33
+ } else {
34
+ // Legacy path: response is already an array
35
+ setData(Array.isArray(json) ? json : []);
36
+ }
24
37
  }
25
38
  } catch (err) {
26
39
  if (err.name === "AbortError") {