@ryanfw/prompt-orchestration-pipeline 0.6.0 → 0.8.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 (62) 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 +13 -11
  6. package/src/components/JobDetail.jsx +41 -71
  7. package/src/components/JobTable.jsx +32 -22
  8. package/src/components/Layout.jsx +0 -21
  9. package/src/components/LiveText.jsx +47 -0
  10. package/src/components/TaskDetailSidebar.jsx +216 -0
  11. package/src/components/TimerText.jsx +82 -0
  12. package/src/components/ui/RestartJobModal.jsx +140 -0
  13. package/src/components/ui/toast.jsx +138 -0
  14. package/src/config/models.js +322 -0
  15. package/src/config/statuses.js +119 -0
  16. package/src/core/config.js +2 -164
  17. package/src/core/file-io.js +1 -1
  18. package/src/core/module-loader.js +54 -40
  19. package/src/core/pipeline-runner.js +52 -26
  20. package/src/core/status-writer.js +147 -3
  21. package/src/core/symlink-bridge.js +55 -0
  22. package/src/core/symlink-utils.js +94 -0
  23. package/src/core/task-runner.js +267 -443
  24. package/src/llm/index.js +167 -52
  25. package/src/pages/Code.jsx +57 -3
  26. package/src/pages/PipelineDetail.jsx +92 -22
  27. package/src/pages/PromptPipelineDashboard.jsx +15 -36
  28. package/src/providers/anthropic.js +83 -69
  29. package/src/providers/base.js +52 -0
  30. package/src/providers/deepseek.js +17 -34
  31. package/src/providers/gemini.js +226 -0
  32. package/src/providers/openai.js +36 -106
  33. package/src/providers/zhipu.js +136 -0
  34. package/src/ui/client/adapters/job-adapter.js +16 -26
  35. package/src/ui/client/api.js +134 -0
  36. package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -178
  37. package/src/ui/client/index.css +9 -0
  38. package/src/ui/client/index.html +1 -0
  39. package/src/ui/client/main.jsx +18 -15
  40. package/src/ui/client/time-store.js +161 -0
  41. package/src/ui/config-bridge.js +15 -24
  42. package/src/ui/config-bridge.node.js +15 -24
  43. package/src/ui/dist/assets/{index-WgJUlSmE.js → index-DqkbzXZ1.js} +1408 -771
  44. package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
  45. package/src/ui/dist/index.html +3 -2
  46. package/src/ui/public/favicon.svg +12 -0
  47. package/src/ui/server.js +231 -38
  48. package/src/ui/transformers/status-transformer.js +18 -31
  49. package/src/ui/watcher.js +5 -1
  50. package/src/utils/dag.js +8 -4
  51. package/src/utils/duration.js +13 -19
  52. package/src/utils/formatters.js +27 -0
  53. package/src/utils/geometry-equality.js +83 -0
  54. package/src/utils/pipelines.js +5 -1
  55. package/src/utils/time-utils.js +40 -0
  56. package/src/utils/token-cost-calculator.js +4 -7
  57. package/src/utils/ui.jsx +14 -16
  58. package/src/components/ui/select.jsx +0 -27
  59. package/src/lib/utils.js +0 -6
  60. package/src/ui/client/hooks/useTicker.js +0 -26
  61. package/src/ui/config-bridge.browser.js +0 -149
  62. package/src/ui/dist/assets/style-x0V-5m8e.css +0 -62
@@ -10,8 +10,9 @@
10
10
  rel="stylesheet"
11
11
  />
12
12
  <title>Prompt Pipeline Dashboard</title>
13
- <script type="module" crossorigin src="/assets/index-WgJUlSmE.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/style-x0V-5m8e.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>
@@ -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 = (
@@ -1412,49 +1430,206 @@ function createServer() {
1412
1430
  // Route: GET /api/llm/functions
1413
1431
  if (pathname === "/api/llm/functions" && req.method === "GET") {
1414
1432
  try {
1415
- const { getConfig } = await import("../core/config.js");
1416
- const config = getConfig();
1417
-
1418
- // Helper to convert model alias to camelCase function name
1419
- const toCamelCase = (alias) => {
1420
- const [provider, ...modelParts] = alias.split(":");
1421
- const model = modelParts.join("-");
1422
- const camelModel = model.replace(/-([a-z0-9])/g, (match, char) =>
1423
- char.toUpperCase()
1424
- );
1425
- return camelModel;
1426
- };
1433
+ const { PROVIDER_FUNCTIONS } = await import("../config/models.js");
1427
1434
 
1428
- // Filter for deepseek, openai only (no gemini provider exists)
1429
- const targetProviders = ["deepseek", "openai"];
1430
- const functions = {};
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
+ }
1431
1446
 
1432
- for (const [alias, modelConfig] of Object.entries(config.llm.models)) {
1433
- const { provider } = modelConfig;
1434
- if (!targetProviders.includes(provider)) continue;
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
+ }
1435
1487
 
1436
- if (!functions[provider]) {
1437
- functions[provider] = [];
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;
1438
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
+ }
1439
1528
 
1440
- const functionName = toCamelCase(alias);
1441
- functions[provider].push({
1442
- alias,
1443
- functionName,
1444
- fullPath: `llm.${provider}.${functionName}`,
1445
- model: modelConfig.model,
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",
1446
1535
  });
1536
+ return;
1447
1537
  }
1448
1538
 
1449
- sendJson(res, 200, functions);
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
+ }
1450
1604
  } catch (error) {
1451
- console.error("Error handling /api/llm/functions:", error);
1452
- sendJson(res, 500, {
1453
- ok: false,
1454
- error: "internal_error",
1455
- message: "Failed to get LLM functions",
1456
- });
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
+ }
1457
1631
  }
1632
+
1458
1633
  return;
1459
1634
  }
1460
1635
 
@@ -1490,6 +1665,25 @@ function createServer() {
1490
1665
  return;
1491
1666
  }
1492
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
+
1493
1687
  // Unknown API endpoint fallback (keep API responses in JSON)
1494
1688
  if (pathname.startsWith("/api/")) {
1495
1689
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -1678,11 +1872,6 @@ async function startServer({ dataDir, port: customPort }) {
1678
1872
  const { initPATHS } = await import("./config-bridge.node.js");
1679
1873
  initPATHS(dataDir);
1680
1874
 
1681
- // Set the data directory environment variable
1682
- if (dataDir) {
1683
- process.env.PO_ROOT = dataDir;
1684
- }
1685
-
1686
1875
  // Require PO_ROOT for non-test runs
1687
1876
  if (!process.env.PO_ROOT) {
1688
1877
  if (process.env.NODE_ENV !== "test") {
@@ -1853,6 +2042,10 @@ export {
1853
2042
  initializeWatcher,
1854
2043
  state,
1855
2044
  resolveJobLifecycle,
2045
+ restartingJobs,
2046
+ isRestartInProgress,
2047
+ beginRestart,
2048
+ endRestart,
1856
2049
  };
1857
2050
 
1858
2051
  // Start server if run directly
@@ -4,24 +4,12 @@ import {
4
4
  calculateJobCosts,
5
5
  formatCostDataForAPI,
6
6
  } from "../../utils/token-cost-calculator.js";
7
-
8
- const VALID_TASK_STATES = new Set(["pending", "running", "done", "failed"]);
9
-
10
- /**
11
- * Determine job-level status from tasks mapping.
12
- */
13
- export function determineJobStatus(tasks = {}) {
14
- if (!tasks || typeof tasks !== "object") return "pending";
15
- const names = Object.keys(tasks);
16
- if (names.length === 0) return "pending";
17
-
18
- const states = names.map((n) => tasks[n]?.state);
19
-
20
- if (states.includes("failed")) return "failed";
21
- if (states.includes("running")) return "running";
22
- if (states.every((s) => s === "done")) return "complete";
23
- return "pending";
24
- }
7
+ import {
8
+ VALID_TASK_STATES,
9
+ normalizeTaskState,
10
+ deriveJobStatusFromTasks,
11
+ TaskState,
12
+ } from "../../config/statuses.js";
25
13
 
26
14
  /**
27
15
  * Compute job status object { status, progress } and emit warnings for unknown states.
@@ -50,14 +38,14 @@ export function computeJobStatus(tasksInput, existingProgress = null) {
50
38
  const t = tasksInput[name];
51
39
  const state = t && typeof t === "object" ? t.state : undefined;
52
40
 
53
- if (state == null || !VALID_TASK_STATES.has(state)) {
54
- if (state != null && !VALID_TASK_STATES.has(state)) {
55
- unknownStatesFound.add(state);
56
- }
57
- normalized[name] = { state: "pending" };
58
- } else {
59
- 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);
60
46
  }
47
+
48
+ normalized[name] = { state: normalizedState };
61
49
  }
62
50
 
63
51
  // Warn for unknown states
@@ -65,7 +53,7 @@ export function computeJobStatus(tasksInput, existingProgress = null) {
65
53
  console.warn(`Unknown task state "${s}"`);
66
54
  }
67
55
 
68
- const status = determineJobStatus(normalized);
56
+ const status = deriveJobStatusFromTasks(Object.values(normalized));
69
57
  // Use existing progress if provided, otherwise default to 0
70
58
  // Progress is pre-calculated in task-statuses.json, not computed from task states
71
59
  const progress = existingProgress !== null ? existingProgress : 0;
@@ -103,12 +91,11 @@ export function transformTasks(rawTasks) {
103
91
  const rawState =
104
92
  raw && typeof raw === "object" && "state" in raw ? raw.state : undefined;
105
93
 
106
- let finalState = "pending";
107
- if (rawState != null && VALID_TASK_STATES.has(rawState)) {
108
- finalState = rawState;
109
- } 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) {
110
98
  console.warn(`Invalid task state "${rawState}"`);
111
- finalState = "pending";
112
99
  }
113
100
 
114
101
  const task = {
package/src/ui/watcher.js CHANGED
@@ -37,7 +37,11 @@ export function start(paths, onChange, options = {}) {
37
37
 
38
38
  // Initialize chokidar watcher
39
39
  const watcher = chokidar.watch(paths, {
40
- ignored: /(^|[\/\\])(\.git|node_modules|dist)([\/\\]|$)/,
40
+ ignored: [
41
+ /(^|[\/\\])(\.git|node_modules|dist)([\/\\]|$)/,
42
+ /pipeline-data\/[^/]+\/[^/]+\/tasks\/[^/]+\/_task_root([\/\\]|$)/,
43
+ ],
44
+ followSymlinks: false,
41
45
  persistent: true,
42
46
  ignoreInitial: true,
43
47
  });
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;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Duration policy utilities for consistent time display across components
3
3
  */
4
+ import { TaskState, normalizeTaskState } from "../config/statuses.js";
4
5
 
5
6
  /**
6
7
  * Normalizes task state names to canonical values
@@ -8,21 +9,15 @@
8
9
  * @returns {string} Normalized state
9
10
  */
10
11
  export function normalizeState(state) {
11
- switch (state) {
12
- case "done":
13
- return "completed";
14
- case "failed":
15
- case "error":
16
- return "error";
17
- case "pending":
18
- case "running":
19
- case "current":
20
- case "completed":
21
- case "rejected":
22
- return state;
23
- default:
24
- return state; // Pass through unknown states
12
+ // Use centralized normalization, then map to duration-specific canonical forms
13
+ const canonicalState = normalizeTaskState(state);
14
+
15
+ // Duration utilities use "completed" instead of "done" for legacy compatibility
16
+ if (canonicalState === TaskState.DONE) {
17
+ return "completed";
25
18
  }
19
+
20
+ return canonicalState;
26
21
  }
27
22
 
28
23
  /**
@@ -36,18 +31,17 @@ export function taskDisplayDurationMs(task, now = Date.now()) {
36
31
  const normalizedState = normalizeState(state);
37
32
 
38
33
  switch (normalizedState) {
39
- case "pending":
34
+ case TaskState.PENDING:
40
35
  return 0;
41
36
 
42
- case "running":
43
- case "current":
37
+ case TaskState.RUNNING:
44
38
  if (!startedAt) {
45
39
  return 0;
46
40
  }
47
41
  const startTime = Date.parse(startedAt);
48
42
  return Math.max(0, now - startTime);
49
43
 
50
- case "completed":
44
+ case "completed": // Duration utilities still use "completed" for legacy compatibility
51
45
  // Prefer executionTimeMs or executionTime if available, even without startedAt
52
46
  const execTime =
53
47
  executionTimeMs != null ? executionTimeMs : executionTime;
@@ -63,7 +57,7 @@ export function taskDisplayDurationMs(task, now = Date.now()) {
63
57
  const endTime = endedAt ? Date.parse(endedAt) : now;
64
58
  return Math.max(0, endTime - completedStartTime);
65
59
 
66
- case "rejected":
60
+ case TaskState.FAILED:
67
61
  return 0;
68
62
 
69
63
  default:
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Format currency with 4 decimal places, trimming trailing zeros
3
+ * @param {number} x - The number to format
4
+ * @returns {string} Formatted currency string
5
+ */
6
+ export function formatCurrency4(x) {
7
+ if (typeof x !== "number" || x === 0) return "$0.0000";
8
+ const formatted = x.toFixed(4);
9
+ // Trim trailing zeros and unnecessary decimal point
10
+ return `$${formatted.replace(/\.?0+$/, "")}`;
11
+ }
12
+
13
+ /**
14
+ * Format tokens in compact notation (k, M suffixes)
15
+ * @param {number} n - The number of tokens to format
16
+ * @returns {string} Formatted tokens string
17
+ */
18
+ export function formatTokensCompact(n) {
19
+ if (typeof n !== "number" || n === 0) return "0 tok";
20
+
21
+ if (n >= 1000000) {
22
+ return `${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M tokens`;
23
+ } else if (n >= 1000) {
24
+ return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k tokens`;
25
+ }
26
+ return `${n} tokens`;
27
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Compare two geometry snapshots for layout-relevant changes using tolerance.
3
+ * @param {Object} prev - Previous geometry snapshot
4
+ * @param {Object} next - New geometry snapshot
5
+ * @param {number} epsilon - Tolerance in pixels for floating-point differences (default: 0.5)
6
+ * @returns {boolean} true if geometries are effectively equal for rendering purposes
7
+ */
8
+ export function areGeometriesEqual(prev, next, epsilon = 0.5) {
9
+ // Strict equality shortcut
10
+ if (prev === next) return true;
11
+ if (!prev || !next) return false;
12
+
13
+ // Compare top-level scalars
14
+ if (prev.itemsLength !== next.itemsLength) return false;
15
+ if (prev.effectiveCols !== next.effectiveCols) return false;
16
+
17
+ // Compare overlay box numeric fields only (DOMRect may have non-numeric props)
18
+ if (!areOverlayBoxesEqual(prev.overlayBox, next.overlayBox, epsilon)) {
19
+ return false;
20
+ }
21
+
22
+ // Compare boxes array length and each box's layout-relevant fields
23
+ const prevBoxes = prev.boxes;
24
+ const nextBoxes = next.boxes;
25
+ if (prevBoxes.length !== nextBoxes.length) return false;
26
+
27
+ for (let i = 0; i < prevBoxes.length; i++) {
28
+ if (!areBoxesEqual(prevBoxes[i], nextBoxes[i], epsilon)) {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * Compare overlay box numeric fields with tolerance.
38
+ * @param {DOMRect|Object} a
39
+ * @param {DOMRect|Object} b
40
+ * @param {number} epsilon
41
+ * @returns {boolean}
42
+ */
43
+ function areOverlayBoxesEqual(a, b, epsilon) {
44
+ return (
45
+ areNumbersClose(a.left, b.left, epsilon) &&
46
+ areNumbersClose(a.top, b.top, epsilon) &&
47
+ areNumbersClose(a.width, b.width, epsilon) &&
48
+ areNumbersClose(a.height, b.height, epsilon) &&
49
+ areNumbersClose(a.right, b.right, epsilon) &&
50
+ areNumbersClose(a.bottom, b.bottom, epsilon)
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Compare individual card box layout fields with tolerance.
56
+ * @param {Object} a - box object with left/top/width/height/right/bottom/headerMidY
57
+ * @param {Object} b - box object with same shape
58
+ * @param {number} epsilon
59
+ * @returns {boolean}
60
+ */
61
+ function areBoxesEqual(a, b, epsilon) {
62
+ if (!a || !b) return a === b;
63
+ return (
64
+ areNumbersClose(a.left, b.left, epsilon) &&
65
+ areNumbersClose(a.top, b.top, epsilon) &&
66
+ areNumbersClose(a.width, b.width, epsilon) &&
67
+ areNumbersClose(a.height, b.height, epsilon) &&
68
+ areNumbersClose(a.right, b.right, epsilon) &&
69
+ areNumbersClose(a.bottom, b.bottom, epsilon) &&
70
+ areNumbersClose(a.headerMidY, b.headerMidY, epsilon)
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Numeric comparison with tolerance.
76
+ * @param {number} a
77
+ * @param {number} b
78
+ * @param {number} epsilon
79
+ * @returns {boolean}
80
+ */
81
+ function areNumbersClose(a, b, epsilon) {
82
+ return Math.abs(a - b) <= epsilon;
83
+ }
@@ -33,8 +33,12 @@ export function derivePipelineMetadata(source = {}) {
33
33
  ? pipelineSlugFromSource
34
34
  : null);
35
35
 
36
+ // Also return string pipeline value directly if it's a string
37
+ const stringPipeline =
38
+ typeof pipelineValue === "string" ? pipelineValue : null;
39
+
36
40
  return {
37
- pipeline,
41
+ pipeline: pipeline || stringPipeline,
38
42
  pipelineSlug:
39
43
  typeof pipelineSlugFromSource === "string"
40
44
  ? pipelineSlugFromSource