@ryanfw/prompt-orchestration-pipeline 0.5.0 → 0.7.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 (67) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -2
  3. package/src/api/validators/json.js +39 -0
  4. package/src/components/DAGGrid.jsx +392 -303
  5. package/src/components/JobCard.jsx +14 -12
  6. package/src/components/JobDetail.jsx +54 -51
  7. package/src/components/JobTable.jsx +72 -23
  8. package/src/components/Layout.jsx +145 -42
  9. package/src/components/LiveText.jsx +47 -0
  10. package/src/components/PageSubheader.jsx +75 -0
  11. package/src/components/TaskDetailSidebar.jsx +216 -0
  12. package/src/components/TimerText.jsx +82 -0
  13. package/src/components/UploadSeed.jsx +0 -70
  14. package/src/components/ui/Logo.jsx +16 -0
  15. package/src/components/ui/RestartJobModal.jsx +140 -0
  16. package/src/components/ui/toast.jsx +138 -0
  17. package/src/config/models.js +322 -0
  18. package/src/config/statuses.js +119 -0
  19. package/src/core/config.js +4 -34
  20. package/src/core/file-io.js +13 -28
  21. package/src/core/module-loader.js +54 -40
  22. package/src/core/pipeline-runner.js +65 -26
  23. package/src/core/status-writer.js +213 -58
  24. package/src/core/symlink-bridge.js +57 -0
  25. package/src/core/symlink-utils.js +94 -0
  26. package/src/core/task-runner.js +321 -437
  27. package/src/llm/index.js +258 -86
  28. package/src/pages/Code.jsx +351 -0
  29. package/src/pages/PipelineDetail.jsx +124 -15
  30. package/src/pages/PromptPipelineDashboard.jsx +20 -88
  31. package/src/providers/anthropic.js +83 -69
  32. package/src/providers/base.js +52 -0
  33. package/src/providers/deepseek.js +20 -21
  34. package/src/providers/gemini.js +226 -0
  35. package/src/providers/openai.js +36 -106
  36. package/src/providers/zhipu.js +136 -0
  37. package/src/ui/client/adapters/job-adapter.js +42 -28
  38. package/src/ui/client/api.js +134 -0
  39. package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -179
  40. package/src/ui/client/index.css +15 -0
  41. package/src/ui/client/index.html +2 -1
  42. package/src/ui/client/main.jsx +19 -14
  43. package/src/ui/client/time-store.js +161 -0
  44. package/src/ui/config-bridge.js +15 -24
  45. package/src/ui/config-bridge.node.js +15 -24
  46. package/src/ui/dist/assets/{index-CxcrauYR.js → index-DqkbzXZ1.js} +2132 -1086
  47. package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
  48. package/src/ui/dist/index.html +4 -3
  49. package/src/ui/job-reader.js +0 -108
  50. package/src/ui/public/favicon.svg +12 -0
  51. package/src/ui/server.js +252 -0
  52. package/src/ui/sse-enhancer.js +0 -1
  53. package/src/ui/transformers/list-transformer.js +32 -12
  54. package/src/ui/transformers/status-transformer.js +29 -42
  55. package/src/utils/dag.js +8 -4
  56. package/src/utils/duration.js +13 -19
  57. package/src/utils/formatters.js +27 -0
  58. package/src/utils/geometry-equality.js +83 -0
  59. package/src/utils/pipelines.js +5 -1
  60. package/src/utils/time-utils.js +40 -0
  61. package/src/utils/token-cost-calculator.js +294 -0
  62. package/src/utils/ui.jsx +18 -20
  63. package/src/components/ui/select.jsx +0 -27
  64. package/src/lib/utils.js +0 -6
  65. package/src/ui/client/hooks/useTicker.js +0 -26
  66. package/src/ui/config-bridge.browser.js +0 -149
  67. package/src/ui/dist/assets/style-D6K_oQ12.css +0 -62
@@ -6,12 +6,13 @@
6
6
  <link rel="preconnect" href="https://fonts.googleapis.com" />
7
7
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8
8
  <link
9
- href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
9
+ href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap"
10
10
  rel="stylesheet"
11
11
  />
12
12
  <title>Prompt Pipeline Dashboard</title>
13
- <script type="module" crossorigin src="/assets/index-CxcrauYR.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/style-D6K_oQ12.css">
13
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
+ <script type="module" crossorigin src="/assets/index-DqkbzXZ1.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/style-DBF9NQGk.css">
15
16
  </head>
16
17
  <body>
17
18
  <div id="root"></div>
@@ -5,7 +5,6 @@
5
5
  * - readJob(jobId)
6
6
  * - readMultipleJobs(jobIds)
7
7
  * - getJobReadingStats(jobIds, results)
8
- * - validateJobData(jobData, expectedJobId)
9
8
  *
10
9
  * Uses config-bridge for paths/constants and file-reader for safe file I/O.
11
10
  */
@@ -44,14 +43,6 @@ export async function readJob(jobId) {
44
43
  `readJob: will check lock at ${jobDir} and attempt to read ${tasksPath}`
45
44
  );
46
45
 
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
46
  // Check lock with a small, deterministic retry loop.
56
47
  // Tests mock isLocked to return true once then false; this loop allows that behavior.
57
48
  // Single-check lock flow with one re-check after a short wait.
@@ -173,102 +164,3 @@ export function getJobReadingStats(jobIds = [], results = []) {
173
164
  locations,
174
165
  };
175
166
  }
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,12 @@
1
+ <svg
2
+ xmlns="http://www.w3.org/2000/svg"
3
+ width="32"
4
+ height="32"
5
+ viewBox="0 0 1200 1200"
6
+ >
7
+ <path
8
+ fill="#009966"
9
+ d="M406.13 988.31c-17.297 75.047-84.562 131.11-164.86 131.11-93.375 0-169.18-75.797-169.18-169.18s75.797-169.18 169.18-169.18 169.18 75.797 169.18 169.18v1.266h447.74v-167.9H671.63c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.703-16.406-39.562v-37.312h-317.16c-10.312 0-18.656-8.344-18.656-18.656V355.78h-147.94c-14.859 0-29.062-5.906-39.562-16.406s-16.406-24.75-16.406-39.562v-111.94c0-14.859 5.906-29.109 16.406-39.562 10.5-10.5 24.75-16.406 39.562-16.406h391.78c14.859 0 29.062 5.906 39.562 16.406s16.406 24.75 16.406 39.562v37.312h202.4c9.281-84.609 81.094-150.52 168.14-150.52 93.375 0 169.18 75.797 169.18 169.18s-75.797 169.18-169.18 169.18c-87.047 0-158.86-65.906-168.14-150.52h-202.4v37.312c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-206.53v297.24h298.5v-37.312c0-14.859 5.906-29.062 16.406-39.562s24.703-16.406 39.562-16.406h392.63c14.859 0 29.062 5.906 39.562 16.406s16.406 24.703 16.406 39.562v111.94c0 14.859-5.906 29.062-16.406 39.562s-24.75 16.406-39.562 16.406h-168.74v186.56c0 10.312-8.344 18.656-18.656 18.656h-466.4c-1.5 0-2.906-.187-4.312-.516zM225.19 262.45h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H225.19c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H411.75c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm616.18 0h85.5c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-85.5l29.062-22.594c8.109-6.328 9.609-18.047 3.281-26.156s-18.047-9.609-26.156-3.281l-71.953 55.969a18.61 18.61 0 0 0 0 29.438l71.953 55.969c8.109 6.328 19.875 4.875 26.156-3.281 6.328-8.109 4.875-19.875-3.281-26.203l-29.062-22.594zm-779.95 696.66l50.391 50.391c7.266 7.313 19.078 7.313 26.391 0l100.73-100.73c7.266-7.266 7.266-19.078 0-26.391-7.266-7.266-19.078-7.266-26.391 0l-87.562 87.562-37.172-37.172c-7.266-7.266-19.078-7.266-26.391 0-7.266 7.266-7.266 19.078 0 26.391zm797.21-268.78h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm-186.56 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656h-18.656c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656zm93.281 0h18.656c10.312 0 18.656-8.344 18.656-18.656s-8.344-18.656-18.656-18.656H858.63c-10.312 0-18.656 8.344-18.656 18.656s8.344 18.656 18.656 18.656z"
10
+ fill-rule="evenodd"
11
+ />
12
+ </svg>
package/src/ui/server.js CHANGED
@@ -12,6 +12,8 @@ import * as state from "./state.js";
12
12
  // Import orchestrator-related functions only in non-test mode
13
13
  let submitJobWithValidation;
14
14
  import { sseRegistry } from "./sse.js";
15
+ import { resetJobToCleanSlate } from "../core/status-writer.js";
16
+ import { spawn } from "node:child_process";
15
17
  import {
16
18
  getPendingSeedPath,
17
19
  resolvePipelinePaths,
@@ -29,6 +31,22 @@ const __dirname = path.dirname(__filename);
29
31
  // Vite dev server instance (populated in development mode)
30
32
  let viteServer = null;
31
33
 
34
+ // In-memory restart guard to prevent duplicate concurrent restarts per job
35
+ const restartingJobs = new Set();
36
+
37
+ // Helper functions for restart guard
38
+ function isRestartInProgress(jobId) {
39
+ return restartingJobs.has(jobId);
40
+ }
41
+
42
+ function beginRestart(jobId) {
43
+ restartingJobs.add(jobId);
44
+ }
45
+
46
+ function endRestart(jobId) {
47
+ restartingJobs.delete(jobId);
48
+ }
49
+
32
50
  // Configuration
33
51
  const PORT = process.env.PORT || 4000;
34
52
  const WATCHED_PATHS = (
@@ -1072,6 +1090,11 @@ function createServer() {
1072
1090
  `http://${req.headers.host}`
1073
1091
  );
1074
1092
 
1093
+ // DEBUG: Log all API requests
1094
+ if (pathname.startsWith("/api/")) {
1095
+ console.log(`DEBUG: API Request: ${req.method} ${pathname}`);
1096
+ }
1097
+
1075
1098
  // CORS headers for API endpoints
1076
1099
  if (pathname.startsWith("/api/")) {
1077
1100
  // Important for tests: avoid idle keep-alive sockets on short API calls
@@ -1404,6 +1427,212 @@ function createServer() {
1404
1427
  return;
1405
1428
  }
1406
1429
 
1430
+ // Route: GET /api/llm/functions
1431
+ if (pathname === "/api/llm/functions" && req.method === "GET") {
1432
+ try {
1433
+ const { PROVIDER_FUNCTIONS } = await import("../config/models.js");
1434
+
1435
+ sendJson(res, 200, PROVIDER_FUNCTIONS);
1436
+ } catch (error) {
1437
+ console.error("Error handling /api/llm/functions:", error);
1438
+ sendJson(res, 500, {
1439
+ ok: false,
1440
+ error: "internal_error",
1441
+ message: "Failed to get LLM functions",
1442
+ });
1443
+ }
1444
+ return;
1445
+ }
1446
+
1447
+ // Route: POST /api/jobs/:jobId/restart
1448
+ if (
1449
+ pathname.startsWith("/api/jobs/") &&
1450
+ pathname.endsWith("/restart") &&
1451
+ req.method === "POST"
1452
+ ) {
1453
+ const pathMatch = pathname.match(/^\/api\/jobs\/([^\/]+)\/restart$/);
1454
+ if (!pathMatch) {
1455
+ sendJson(res, 400, {
1456
+ ok: false,
1457
+ error: "bad_request",
1458
+ message: "Invalid path format",
1459
+ });
1460
+ return;
1461
+ }
1462
+
1463
+ const [, jobId] = pathMatch;
1464
+ const dataDir = process.env.PO_ROOT || DATA_DIR;
1465
+
1466
+ try {
1467
+ // Validate jobId
1468
+ if (!jobId || typeof jobId !== "string" || jobId.trim() === "") {
1469
+ sendJson(res, 400, {
1470
+ ok: false,
1471
+ error: "bad_request",
1472
+ message: "jobId is required",
1473
+ });
1474
+ return;
1475
+ }
1476
+
1477
+ // Resolve job lifecycle
1478
+ const lifecycle = await resolveJobLifecycle(dataDir, jobId);
1479
+ if (!lifecycle) {
1480
+ sendJson(res, 404, {
1481
+ ok: false,
1482
+ code: "job_not_found",
1483
+ message: "Job not found",
1484
+ });
1485
+ return;
1486
+ }
1487
+
1488
+ // Only support current lifecycle for MVP
1489
+ if (lifecycle !== "current") {
1490
+ sendJson(res, 409, {
1491
+ ok: false,
1492
+ code: "unsupported_lifecycle",
1493
+ message:
1494
+ "Job restart is only supported for jobs in 'current' lifecycle",
1495
+ });
1496
+ return;
1497
+ }
1498
+
1499
+ // Check if job is already running
1500
+ const jobDir = getJobDirectoryPath(dataDir, jobId, "current");
1501
+ const statusPath = path.join(jobDir, "tasks-status.json");
1502
+
1503
+ let snapshot;
1504
+ try {
1505
+ const content = await fs.promises.readFile(statusPath, "utf8");
1506
+ snapshot = JSON.parse(content);
1507
+ } catch (error) {
1508
+ if (error.code === "ENOENT") {
1509
+ sendJson(res, 404, {
1510
+ ok: false,
1511
+ code: "job_not_found",
1512
+ message: "Job status file not found",
1513
+ });
1514
+ return;
1515
+ }
1516
+ throw error;
1517
+ }
1518
+
1519
+ // Guard against running jobs
1520
+ if (snapshot.state === "running") {
1521
+ sendJson(res, 409, {
1522
+ ok: false,
1523
+ code: "job_running",
1524
+ message: "Job is currently running",
1525
+ });
1526
+ return;
1527
+ }
1528
+
1529
+ // Guard against concurrent restarts
1530
+ if (isRestartInProgress(jobId)) {
1531
+ sendJson(res, 409, {
1532
+ ok: false,
1533
+ code: "job_running",
1534
+ message: "Job restart is already in progress",
1535
+ });
1536
+ return;
1537
+ }
1538
+
1539
+ // Begin restart guard
1540
+ beginRestart(jobId);
1541
+
1542
+ try {
1543
+ // Parse optional fromTask from request body for targeted restart
1544
+ let body = {};
1545
+ try {
1546
+ const rawBody = await readRawBody(req);
1547
+ body = JSON.parse(rawBody.toString("utf8"));
1548
+ } catch (error) {
1549
+ sendJson(res, 400, {
1550
+ ok: false,
1551
+ error: "bad_request",
1552
+ message: "Invalid JSON in request body",
1553
+ });
1554
+ return;
1555
+ }
1556
+
1557
+ const { fromTask } = body;
1558
+
1559
+ // Reset job: clean-slate or partial from a specific task
1560
+ const { resetJobFromTask } = await import("../core/status-writer.js");
1561
+ if (fromTask) {
1562
+ await resetJobFromTask(jobDir, fromTask, { clearTokenUsage: true });
1563
+ } else {
1564
+ await resetJobToCleanSlate(jobDir, { clearTokenUsage: true });
1565
+ }
1566
+
1567
+ // Spawn detached pipeline-runner process
1568
+ const runnerPath = path.join(__dirname, "../core/pipeline-runner.js");
1569
+ const base = process.env.PO_ROOT || DATA_DIR;
1570
+ const env = {
1571
+ ...process.env,
1572
+ PO_ROOT: base,
1573
+ PO_DATA_DIR: path.join(base, "pipeline-data"),
1574
+ PO_PENDING_DIR: path.join(base, "pipeline-data", "pending"),
1575
+ PO_CURRENT_DIR: path.join(base, "pipeline-data", "current"),
1576
+ PO_COMPLETE_DIR: path.join(base, "pipeline-data", "complete"),
1577
+ ...(fromTask && { PO_START_FROM_TASK: fromTask }),
1578
+ };
1579
+
1580
+ const child = spawn(process.execPath, [runnerPath, jobId], {
1581
+ env,
1582
+ stdio: "ignore",
1583
+ detached: true,
1584
+ });
1585
+
1586
+ // Unref the child process so it runs in the background
1587
+ child.unref();
1588
+
1589
+ // Send success response
1590
+ sendJson(res, 202, {
1591
+ ok: true,
1592
+ jobId,
1593
+ mode: "clean-slate",
1594
+ spawned: true,
1595
+ });
1596
+
1597
+ console.log(
1598
+ `Job ${jobId} restarted successfully, detached runner PID: ${child.pid}`
1599
+ );
1600
+ } finally {
1601
+ // Always end restart guard
1602
+ endRestart(jobId);
1603
+ }
1604
+ } catch (error) {
1605
+ console.error(`Error handling POST /api/jobs/${jobId}/restart:`, error);
1606
+
1607
+ // Clean up restart guard on error
1608
+ if (isRestartInProgress(jobId)) {
1609
+ endRestart(jobId);
1610
+ }
1611
+
1612
+ if (error.code === "ENOENT") {
1613
+ sendJson(res, 404, {
1614
+ ok: false,
1615
+ code: "job_not_found",
1616
+ message: "Job directory not found",
1617
+ });
1618
+ } else if (error.code === "spawn failed") {
1619
+ sendJson(res, 500, {
1620
+ ok: false,
1621
+ code: "spawn_failed",
1622
+ message: error.message || "Failed to spawn pipeline runner",
1623
+ });
1624
+ } else {
1625
+ sendJson(res, 500, {
1626
+ ok: false,
1627
+ code: "internal_error",
1628
+ message: "Internal server error",
1629
+ });
1630
+ }
1631
+ }
1632
+
1633
+ return;
1634
+ }
1635
+
1407
1636
  // Route: GET /api/jobs/:jobId
1408
1637
  if (pathname.startsWith("/api/jobs/") && req.method === "GET") {
1409
1638
  const jobId = pathname.substring("/api/jobs/".length);
@@ -1436,6 +1665,25 @@ function createServer() {
1436
1665
  return;
1437
1666
  }
1438
1667
 
1668
+ // Route: GET /favicon.svg
1669
+ if (pathname === "/favicon.svg" && req.method === "GET") {
1670
+ const faviconPath = path.join(__dirname, "public", "favicon.svg");
1671
+
1672
+ try {
1673
+ const content = await fs.promises.readFile(faviconPath, "utf8");
1674
+ res.writeHead(200, {
1675
+ "Content-Type": "image/svg+xml",
1676
+ "Cache-Control": "public, max-age=3600", // Cache for 1 hour
1677
+ });
1678
+ res.end(content);
1679
+ } catch (error) {
1680
+ console.error("Error serving favicon:", error);
1681
+ res.writeHead(404);
1682
+ res.end("Favicon not found");
1683
+ }
1684
+ return;
1685
+ }
1686
+
1439
1687
  // Unknown API endpoint fallback (keep API responses in JSON)
1440
1688
  if (pathname.startsWith("/api/")) {
1441
1689
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1799,6 +2047,10 @@ export {
1799
2047
  initializeWatcher,
1800
2048
  state,
1801
2049
  resolveJobLifecycle,
2050
+ restartingJobs,
2051
+ isRestartInProgress,
2052
+ beginRestart,
2053
+ endRestart,
1802
2054
  };
1803
2055
 
1804
2056
  // Start server if run directly
@@ -14,7 +14,6 @@
14
14
  * - cleanup() clears timers
15
15
  */
16
16
 
17
- import { detectJobChange } from "./job-change-detector.js";
18
17
  import { transformJobStatus } from "./transformers/status-transformer.js";
19
18
  import { transformJobListForAPI } from "./transformers/list-transformer.js";
20
19
 
@@ -250,28 +250,48 @@ export function transformJobListForAPI(jobs = [], options = {}) {
250
250
  base.currentStage = job.currentStage;
251
251
  }
252
252
 
253
- if (job.tasksStatus && typeof job.tasksStatus === "object") {
254
- // Include tasksStatus with all required fields for UI computation
255
- const tasksStatus = {};
256
- for (const [taskId, task] of Object.entries(job.tasksStatus)) {
253
+ if (job.tasks && typeof job.tasks === "object") {
254
+ // Include tasks with all required fields for UI computation
255
+ const tasks = {};
256
+ for (const [taskId, task] of Object.entries(job.tasks)) {
257
257
  if (task && typeof task === "object") {
258
- tasksStatus[taskId] = {
258
+ tasks[taskId] = {
259
259
  state: task.state || "pending",
260
260
  };
261
261
 
262
262
  // Include optional fields if present
263
- if (task.startedAt != null)
264
- tasksStatus[taskId].startedAt = task.startedAt;
265
- if (task.endedAt != null) tasksStatus[taskId].endedAt = task.endedAt;
263
+ if (task.startedAt != null) tasks[taskId].startedAt = task.startedAt;
264
+ if (task.endedAt != null) tasks[taskId].endedAt = task.endedAt;
266
265
  if (task.executionTimeMs != null)
267
- tasksStatus[taskId].executionTimeMs = task.executionTimeMs;
266
+ tasks[taskId].executionTimeMs = task.executionTimeMs;
268
267
  if (task.currentStage != null)
269
- tasksStatus[taskId].currentStage = task.currentStage;
268
+ tasks[taskId].currentStage = task.currentStage;
270
269
  if (task.failedStage != null)
271
- tasksStatus[taskId].failedStage = task.failedStage;
270
+ tasks[taskId].failedStage = task.failedStage;
272
271
  }
273
272
  }
274
- base.tasksStatus = tasksStatus;
273
+ base.tasks = tasks;
274
+ }
275
+
276
+ // Add costs summary with zeroed structure if job.costs is absent
277
+ if (job.costs && job.costs.summary) {
278
+ base.costsSummary = {
279
+ totalTokens: job.costs.summary.totalTokens || 0,
280
+ totalInputTokens: job.costs.summary.totalInputTokens || 0,
281
+ totalOutputTokens: job.costs.summary.totalOutputTokens || 0,
282
+ totalCost: job.costs.summary.totalCost || 0,
283
+ totalInputCost: job.costs.summary.totalInputCost || 0,
284
+ totalOutputCost: job.costs.summary.totalOutputCost || 0,
285
+ };
286
+ } else {
287
+ base.costsSummary = {
288
+ totalTokens: 0,
289
+ totalInputTokens: 0,
290
+ totalOutputTokens: 0,
291
+ totalCost: 0,
292
+ totalInputCost: 0,
293
+ totalOutputCost: 0,
294
+ };
275
295
  }
276
296
 
277
297
  // Only include pipeline metadata if option is enabled
@@ -1,23 +1,15 @@
1
1
  import { normalizeTaskFiles } from "../../utils/task-files.js";
2
2
  import { derivePipelineMetadata } from "../../utils/pipelines.js";
3
-
4
- const VALID_TASK_STATES = new Set(["pending", "running", "done", "failed"]);
5
-
6
- /**
7
- * Determine job-level status from tasks mapping.
8
- */
9
- export function determineJobStatus(tasks = {}) {
10
- if (!tasks || typeof tasks !== "object") return "pending";
11
- const names = Object.keys(tasks);
12
- if (names.length === 0) return "pending";
13
-
14
- const states = names.map((n) => tasks[n]?.state);
15
-
16
- if (states.includes("failed")) return "failed";
17
- if (states.includes("running")) return "running";
18
- if (states.every((s) => s === "done")) return "complete";
19
- return "pending";
20
- }
3
+ import {
4
+ calculateJobCosts,
5
+ formatCostDataForAPI,
6
+ } from "../../utils/token-cost-calculator.js";
7
+ import {
8
+ VALID_TASK_STATES,
9
+ normalizeTaskState,
10
+ deriveJobStatusFromTasks,
11
+ TaskState,
12
+ } from "../../config/statuses.js";
21
13
 
22
14
  /**
23
15
  * Compute job status object { status, progress } and emit warnings for unknown states.
@@ -46,14 +38,14 @@ export function computeJobStatus(tasksInput, existingProgress = null) {
46
38
  const t = tasksInput[name];
47
39
  const state = t && typeof t === "object" ? t.state : undefined;
48
40
 
49
- if (state == null || !VALID_TASK_STATES.has(state)) {
50
- if (state != null && !VALID_TASK_STATES.has(state)) {
51
- unknownStatesFound.add(state);
52
- }
53
- normalized[name] = { state: "pending" };
54
- } else {
55
- normalized[name] = { state };
41
+ const normalizedState = normalizeTaskState(state);
42
+
43
+ // Track unknown states for warning
44
+ if (state != null && state !== normalizedState) {
45
+ unknownStatesFound.add(state);
56
46
  }
47
+
48
+ normalized[name] = { state: normalizedState };
57
49
  }
58
50
 
59
51
  // Warn for unknown states
@@ -61,7 +53,7 @@ export function computeJobStatus(tasksInput, existingProgress = null) {
61
53
  console.warn(`Unknown task state "${s}"`);
62
54
  }
63
55
 
64
- const status = determineJobStatus(normalized);
56
+ const status = deriveJobStatusFromTasks(Object.values(normalized));
65
57
  // Use existing progress if provided, otherwise default to 0
66
58
  // Progress is pre-calculated in task-statuses.json, not computed from task states
67
59
  const progress = existingProgress !== null ? existingProgress : 0;
@@ -99,12 +91,11 @@ export function transformTasks(rawTasks) {
99
91
  const rawState =
100
92
  raw && typeof raw === "object" && "state" in raw ? raw.state : undefined;
101
93
 
102
- let finalState = "pending";
103
- if (rawState != null && VALID_TASK_STATES.has(rawState)) {
104
- finalState = rawState;
105
- } else if (rawState != null && !VALID_TASK_STATES.has(rawState)) {
94
+ const finalState = normalizeTaskState(rawState);
95
+
96
+ // Warn for invalid states (different from normalized)
97
+ if (rawState != null && rawState !== finalState) {
106
98
  console.warn(`Invalid task state "${rawState}"`);
107
- finalState = "pending";
108
99
  }
109
100
 
110
101
  const task = {
@@ -168,7 +159,7 @@ export function transformTasks(rawTasks) {
168
159
  * - createdAt / updatedAt: ISO strings | null
169
160
  * - location: lifecycle bucket
170
161
  * - current / currentStage: stage metadata (optional)
171
- * - tasksStatus: object keyed by task name
162
+ * - tasks: object keyed by task name
172
163
  * - files: normalized job-level files
173
164
  */
174
165
  export function transformJobStatus(raw, jobId, location) {
@@ -189,17 +180,13 @@ export function transformJobStatus(raw, jobId, location) {
189
180
  const updatedAt = raw.updatedAt || raw.lastUpdated || createdAt || null;
190
181
  const resolvedLocation = location || raw.location || null;
191
182
 
192
- // Support both canonical (tasksStatus) and legacy (tasks) schema
193
- const tasksStatus = transformTasks(raw.tasksStatus || raw.tasks);
194
- const jobStatusObj = computeJobStatus(tasksStatus, raw.progress);
195
-
183
+ const tasks = transformTasks(raw.tasks);
184
+ const jobStatusObj = computeJobStatus(tasks, raw.progress);
196
185
  const jobFiles = normalizeTaskFiles(raw.files);
197
186
 
198
- // Convert tasksStatus object to tasks array for API compatibility
199
- const tasks = Object.entries(tasksStatus).map(([name, task]) => ({
200
- name,
201
- ...task,
202
- }));
187
+ // Calculate costs for this job
188
+ const costs = calculateJobCosts(raw);
189
+ const costData = formatCostDataForAPI(costs);
203
190
 
204
191
  const job = {
205
192
  id: jobId, // API expects 'id' not 'jobId'
@@ -211,9 +198,9 @@ export function transformJobStatus(raw, jobId, location) {
211
198
  createdAt,
212
199
  updatedAt,
213
200
  location: resolvedLocation,
214
- tasksStatus, // Keep tasksStatus for backward compatibility
215
201
  tasks, // API expects 'tasks' array
216
202
  files: jobFiles,
203
+ costs: costData, // Add cost data to job response
217
204
  };
218
205
 
219
206
  if (raw.current != null) job.current = raw.current;
package/src/utils/dag.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { TaskState } from "../config/statuses.js";
2
+
1
3
  function normalizeJobTasks(tasks) {
2
4
  if (!tasks) return {};
3
5
 
@@ -53,7 +55,7 @@ export function computeDagItems(job, pipeline) {
53
55
  const jobTask = jobTasks[taskId];
54
56
  return {
55
57
  id: taskId,
56
- status: jobTask ? jobTask.state : "pending",
58
+ status: jobTask ? jobTask.state : TaskState.PENDING,
57
59
  source: "pipeline",
58
60
  stage: computeTaskStage(job, taskId),
59
61
  };
@@ -82,18 +84,20 @@ export function computeActiveIndex(items) {
82
84
 
83
85
  // Find first running task
84
86
  const firstRunningIndex = items.findIndex(
85
- (item) => item.status === "running"
87
+ (item) => item.status === TaskState.RUNNING
86
88
  );
87
89
  if (firstRunningIndex !== -1) return firstRunningIndex;
88
90
 
89
91
  // Find first failed task
90
- const firstFailedIndex = items.findIndex((item) => item.status === "failed");
92
+ const firstFailedIndex = items.findIndex(
93
+ (item) => item.status === TaskState.FAILED
94
+ );
91
95
  if (firstFailedIndex !== -1) return firstFailedIndex;
92
96
 
93
97
  // Find last completed task
94
98
  let lastDoneIndex = -1;
95
99
  items.forEach((item, index) => {
96
- if (item.status === "done") lastDoneIndex = index;
100
+ if (item.status === TaskState.DONE) lastDoneIndex = index;
97
101
  });
98
102
 
99
103
  if (lastDoneIndex !== -1) return lastDoneIndex;