bosun 0.40.21 → 0.41.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 (41) hide show
  1. package/agent/agent-custom-tools.mjs +23 -5
  2. package/agent/agent-pool.mjs +6 -2
  3. package/agent/primary-agent.mjs +81 -7
  4. package/bench/swebench/bosun-swebench.mjs +5 -0
  5. package/cli.mjs +208 -3
  6. package/config/config-doctor.mjs +51 -2
  7. package/config/config.mjs +103 -3
  8. package/github/github-auth-manager.mjs +70 -19
  9. package/infra/library-manager.mjs +894 -60
  10. package/infra/monitor.mjs +8 -2
  11. package/infra/session-tracker.mjs +13 -3
  12. package/infra/test-runtime.mjs +267 -0
  13. package/package.json +8 -5
  14. package/server/setup-web-server.mjs +4 -1
  15. package/server/ui-server.mjs +1323 -20
  16. package/task/task-claims.mjs +6 -10
  17. package/ui/components/chat-view.js +18 -1
  18. package/ui/components/workspace-switcher.js +321 -9
  19. package/ui/demo-defaults.js +11746 -9470
  20. package/ui/demo.html +9 -1
  21. package/ui/modules/router.js +1 -1
  22. package/ui/modules/voice-client-sdk.js +1 -1
  23. package/ui/modules/voice-client.js +33 -2
  24. package/ui/styles/components.css +514 -1
  25. package/ui/tabs/library.js +410 -55
  26. package/ui/tabs/tasks.js +1052 -506
  27. package/ui/tabs/workflow-canvas-utils.mjs +30 -0
  28. package/ui/tabs/workflows.js +914 -298
  29. package/voice/voice-agents-sdk.mjs +1 -1
  30. package/voice/voice-relay.mjs +24 -16
  31. package/workflow/project-detection.mjs +559 -0
  32. package/workflow/workflow-contract.mjs +433 -232
  33. package/workflow/workflow-engine.mjs +181 -30
  34. package/workflow/workflow-nodes.mjs +304 -6
  35. package/workflow/workflow-templates.mjs +92 -16
  36. package/workflow-templates/agents.mjs +20 -19
  37. package/workflow-templates/code-quality.mjs +20 -14
  38. package/workflow-templates/task-batch.mjs +3 -2
  39. package/workflow-templates/task-execution.mjs +752 -0
  40. package/workflow-templates/task-lifecycle.mjs +34 -8
  41. package/workspace/workspace-manager.mjs +151 -0
@@ -10,13 +10,14 @@ import htm from "htm";
10
10
  const html = htm.bind(h);
11
11
 
12
12
  import { haptic } from "../modules/telegram.js";
13
- import { apiFetch } from "../modules/api.js";
13
+ import { apiFetch, onWsMessage } from "../modules/api.js";
14
14
  import { showToast, refreshTab } from "../modules/state.js";
15
15
  import { navigateTo, routeParams, setRouteParams } from "../modules/router.js";
16
16
  import { ICONS } from "../modules/icons.js";
17
17
  import { resolveIcon } from "../modules/icon-utils.js";
18
18
  import { formatDate, formatDuration, formatRelative } from "../modules/utils.js";
19
19
  import {
20
+ buildNodeStatusesFromRunDetail,
20
21
  createHistoryState,
21
22
  getNodeSearchMetadata,
22
23
  parseGraphSnapshot,
@@ -60,6 +61,11 @@ const connectingFrom = signal(null);
60
61
  const viewMode = signal("list"); // "list" | "canvas" | "runs"
61
62
  const WORKFLOW_RUN_PAGE_SIZE = 50;
62
63
  const WORKFLOW_RUN_MAX_FETCH = 5000;
64
+ const WORKFLOW_LIVE_POLL_MS = 3000;
65
+ const WORKFLOW_LIVE_WS_BATCH_MS = 90;
66
+ const NODE_COMPLETION_FLASH_MS = 1400;
67
+ const NODE_RUNNING_HINT_MS = 500;
68
+ const EDGE_FLOW_ANIMATION_MS = 1200;
63
69
  const workflowRunsLimit = signal(WORKFLOW_RUN_PAGE_SIZE);
64
70
 
65
71
  // ── Execute Dialog state ──────────────────────────────────────────────────
@@ -309,6 +315,11 @@ function humanizeVarKey(key) {
309
315
  /** Infer a short helper text for a variable key */
310
316
  function inferVarHelp(key, value) {
311
317
  const k = key.toLowerCase();
318
+ if (k.includes("testcommand") || k.includes("test_command") || k === "testframework" || k === "test_framework") return "Test command for your project — select from presets or enter custom";
319
+ if (k.includes("buildcommand") || k.includes("build_command")) return "Build command for your project — select from presets or enter custom";
320
+ if (k.includes("lintcommand") || k.includes("lint_command") || k.includes("lintcmd")) return "Lint/style check command — select from presets or enter custom";
321
+ if (k.includes("syntaxcheck") || k.includes("syntax_check")) return "Syntax/compile check command — select from presets or enter custom";
322
+ if (k === "basebranch" || k === "base_branch" || k === "defaultbasebranch") return "Base branch for PRs — select from common options or enter custom";
312
323
  if (k.includes("timeout") || k.includes("delay") || k.includes("cooldown")) return "Duration in milliseconds";
313
324
  if (k.includes("branch")) return "Git branch name";
314
325
  if (k.includes("url") || k.includes("endpoint")) return "URL / endpoint";
@@ -324,12 +335,24 @@ function inferVarHelp(key, value) {
324
335
  function inferVarOptions(key, value) {
325
336
  const k = String(key || "").toLowerCase();
326
337
  const options = [];
338
+
327
339
  if (k.includes("executor") || k.includes("sdk")) {
328
340
  options.push("auto", "codex", "claude", "copilot");
329
341
  } else if (k.includes("bumptype") || k.includes("bump_type")) {
330
342
  options.push("patch", "minor", "major");
343
+ } else if (k.includes("testcommand") || k.includes("test_command") || k === "testframework" || k === "test_framework") {
344
+ options.push("npm test", "yarn test", "pnpm test", "pytest", "poetry run pytest", "go test ./...", "cargo test", "mvn test", "./gradlew test", "dotnet test", "bundle exec rspec", "make test");
345
+ } else if (k.includes("buildcommand") || k.includes("build_command")) {
346
+ options.push("npm run build", "yarn build", "pnpm build", "go build ./...", "cargo build", "mvn package -DskipTests", "./gradlew build", "dotnet build", "python -m build", "make");
347
+ } else if (k.includes("lintcommand") || k.includes("lint_command") || k.includes("lintcmd")) {
348
+ options.push("npm run lint", "npx eslint .", "ruff check .", "golangci-lint run", "cargo clippy -- -D warnings", "dotnet format --verify-no-changes", "bundle exec rubocop");
349
+ } else if (k.includes("syntaxcheck") || k.includes("syntax_check")) {
350
+ options.push("node --check", "npx tsc --noEmit", "python -m py_compile", "go vet ./...", "cargo check", "dotnet build --no-restore");
351
+ } else if (k === "basebranch" || k === "base_branch" || k === "defaultbasebranch" || k === "targetbranch") {
352
+ options.push("main", "master", "develop", "staging");
331
353
  }
332
- // Keep typed value only when this field already has known preset options.
354
+
355
+ // Keep typed value when this field has known preset options.
333
356
  if (options.length > 0 && typeof value === "string" && value.trim()) {
334
357
  options.unshift(value.trim());
335
358
  }
@@ -378,7 +401,12 @@ function isQuickVarKey(key) {
378
401
  k.includes("sdk") ||
379
402
  k.includes("model") ||
380
403
  k.includes("branch") ||
381
- k.includes("title")
404
+ k.includes("title") ||
405
+ k.includes("testcommand") || k.includes("test_command") ||
406
+ k === "testframework" || k === "test_framework" ||
407
+ k.includes("buildcommand") || k.includes("build_command") ||
408
+ k.includes("lintcommand") || k.includes("lint_command") ||
409
+ k.includes("syntaxcheck") || k.includes("syntax_check")
382
410
  );
383
411
  }
384
412
 
@@ -1207,6 +1235,109 @@ function stripEmoji(text) {
1207
1235
  .trim();
1208
1236
  }
1209
1237
 
1238
+ function normalizeLiveNodeStatus(status) {
1239
+ const normalized = String(status || "").trim().toLowerCase();
1240
+ if (normalized === "completed" || normalized === "success") return "success";
1241
+ if (normalized === "failed" || normalized === "fail" || normalized === "error") return "fail";
1242
+ if (normalized === "skipped" || normalized === "skip") return "skipped";
1243
+ if (normalized === "running") return "running";
1244
+ if (normalized === "waiting" || normalized === "pending") return "waiting";
1245
+ return normalized || "";
1246
+ }
1247
+
1248
+ function toNodeStatusLabel(status) {
1249
+ if (status === "success") return "success";
1250
+ if (status === "fail") return "fail";
1251
+ if (status === "skipped") return "skipped";
1252
+ return status || "";
1253
+ }
1254
+
1255
+ function getCanvasNodeExecutionVisuals(status, isSelected, selectedColor, flashState = "") {
1256
+ if (status === "running") {
1257
+ return {
1258
+ fill: "#10233f",
1259
+ stroke: "#60a5fa",
1260
+ strokeWidth: 2.5,
1261
+ filter: "url(#node-glow)",
1262
+ };
1263
+ }
1264
+ if (status === "fail" || flashState === "fail") {
1265
+ return {
1266
+ fill: "#2a1217",
1267
+ stroke: "#ef4444",
1268
+ strokeWidth: 2.25,
1269
+ filter: "url(#node-shadow)",
1270
+ };
1271
+ }
1272
+ if (status === "success" || flashState === "success") {
1273
+ return {
1274
+ fill: "#0f2a23",
1275
+ stroke: "#10b981",
1276
+ strokeWidth: 2,
1277
+ filter: "url(#node-shadow)",
1278
+ };
1279
+ }
1280
+ if (status === "skipped" || flashState === "skipped") {
1281
+ return {
1282
+ fill: "#1f2430",
1283
+ stroke: "#94a3b8",
1284
+ strokeWidth: 2,
1285
+ filter: "url(#node-shadow)",
1286
+ };
1287
+ }
1288
+ if (status === "waiting" || status === "pending") {
1289
+ return {
1290
+ fill: "#2f2310",
1291
+ stroke: "#f59e0b",
1292
+ strokeWidth: 2,
1293
+ filter: "url(#node-shadow)",
1294
+ };
1295
+ }
1296
+ return {
1297
+ fill: isSelected ? "#1e293b" : "#1a1f2e",
1298
+ stroke: isSelected ? selectedColor : "#2a3040",
1299
+ strokeWidth: isSelected ? 2 : 1,
1300
+ filter: isSelected ? "url(#node-glow)" : "url(#node-shadow)",
1301
+ };
1302
+ }
1303
+
1304
+ function resolveNodeOutputPreview(nodeType, preview = {}, fallbackOutput = null) {
1305
+ const lines = Array.isArray(preview?.lines) ? preview.lines : [];
1306
+ const tokenCount = Number.isFinite(Number(preview?.tokenCount))
1307
+ ? Math.max(0, Math.round(Number(preview.tokenCount)))
1308
+ : null;
1309
+ if (lines.length) {
1310
+ return {
1311
+ lines: lines.slice(0, 3).map((line) => String(line || "").trim()).filter(Boolean),
1312
+ tokenCount,
1313
+ };
1314
+ }
1315
+ const type = String(nodeType || "").trim().toLowerCase();
1316
+ const output = fallbackOutput && typeof fallbackOutput === "object" ? fallbackOutput : {};
1317
+ const fallbackLines = [];
1318
+ if (type.startsWith("condition.")) {
1319
+ const branch = String(output.matchedPort || output.port || "").trim();
1320
+ if (branch) fallbackLines.push(`Branch: ${branch}`);
1321
+ if (Object.prototype.hasOwnProperty.call(output, "value")) {
1322
+ fallbackLines.push(`Value: ${String(output.value)}`);
1323
+ }
1324
+ } else if (type.startsWith("git.") || type.startsWith("github.")) {
1325
+ const shaRaw = String(output.commitSha || output.sha || output.head || "").trim();
1326
+ const sha = /^[0-9a-f]{7,40}$/i.test(shaRaw) ? shaRaw.slice(0, 12) : "";
1327
+ if (sha) fallbackLines.push(`Commit: ${sha}`);
1328
+ const prUrl = String(output.prUrl || output.url || "").trim();
1329
+ if (prUrl) fallbackLines.push(`PR: ${prUrl}`);
1330
+ }
1331
+ if (!fallbackLines.length) {
1332
+ const text = String(output.summary || output.output || output.message || output.error || "").trim();
1333
+ if (text) fallbackLines.push(...text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 3));
1334
+ }
1335
+ return {
1336
+ lines: fallbackLines.slice(0, 3),
1337
+ tokenCount,
1338
+ };
1339
+ }
1340
+
1210
1341
  /* ═══════════════════════════════════════════════════════════════
1211
1342
  * Canvas — SVG-based Workflow Editor
1212
1343
  * ═══════════════════════════════════════════════════════════════ */
@@ -1231,6 +1362,14 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1231
1362
  const [selectedNodeIds, setSelectedNodeIds] = useState(new Set());
1232
1363
  const [historyState, setHistoryState] = useState(() => createHistoryState(workflow?.nodes || [], workflow?.edges || []));
1233
1364
  const [marquee, setMarquee] = useState(null);
1365
+ const [liveHighlightEnabled, setLiveHighlightEnabled] = useState(true);
1366
+ const [liveRun, setLiveRun] = useState(null);
1367
+ const [liveNodeStatuses, setLiveNodeStatuses] = useState({});
1368
+ const [liveNodeOutputPreviews, setLiveNodeOutputPreviews] = useState({});
1369
+ const [liveNodeFlashStates, setLiveNodeFlashStates] = useState({});
1370
+ const [liveNodeRunningHints, setLiveNodeRunningHints] = useState({});
1371
+ const [liveEdgeActivity, setLiveEdgeActivity] = useState({});
1372
+ const [liveNowTick, setLiveNowTick] = useState(Date.now());
1234
1373
  const marqueeStartRef = useRef(null);
1235
1374
  const multiDragRef = useRef({});
1236
1375
  const nodesRef = useRef(nodes);
@@ -1240,10 +1379,17 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1240
1379
  const historyPendingSnapshotRef = useRef(null);
1241
1380
  const saveTimer = useRef(null);
1242
1381
  const selectedNodeIdsRef = useRef(selectedNodeIds);
1382
+ const liveEventQueueRef = useRef([]);
1383
+ const liveEventFlushTimerRef = useRef(null);
1243
1384
  const workflowSnapshotKey = useMemo(
1244
1385
  () => serializeGraphSnapshot(workflow?.nodes || [], workflow?.edges || []),
1245
1386
  [workflow?.nodes, workflow?.edges],
1246
1387
  );
1388
+ const hasLiveStatuses = Object.keys(liveNodeStatuses).length > 0;
1389
+ const liveRunDuration = liveRun?.status === "running" && liveRun?.startedAt
1390
+ ? Math.max(0, liveNowTick - Number(liveRun.startedAt))
1391
+ : Number(liveRun?.duration) || 0;
1392
+ const liveActiveNodes = Object.values(liveNodeStatuses).filter((status) => status === "running").length;
1247
1393
  useEffect(() => { selectedNodeIdsRef.current = selectedNodeIds; }, [selectedNodeIds]);
1248
1394
  useEffect(() => {
1249
1395
  nodesRef.current = nodes;
@@ -1268,11 +1414,293 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1268
1414
  setEditingNode(null);
1269
1415
  setContextMenu(null);
1270
1416
  setShowNodePalette(false);
1417
+ setLiveRun(null);
1418
+ setLiveNodeStatuses({});
1419
+ setLiveNodeOutputPreviews({});
1420
+ setLiveNodeFlashStates({});
1421
+ setLiveNodeRunningHints({});
1422
+ setLiveEdgeActivity({});
1271
1423
  }, [workflow?.id, workflowSnapshotKey]);
1272
1424
 
1425
+ useEffect(() => {
1426
+ if (!liveHighlightEnabled || !workflow?.id) {
1427
+ setLiveRun(null);
1428
+ setLiveNodeStatuses({});
1429
+ setLiveNodeOutputPreviews({});
1430
+ setLiveNodeFlashStates({});
1431
+ setLiveNodeRunningHints({});
1432
+ setLiveEdgeActivity({});
1433
+ return;
1434
+ }
1435
+ let cancelled = false;
1436
+
1437
+ const pollLiveRun = async () => {
1438
+ try {
1439
+ const data = await apiFetch(`/api/workflows/runs?workflowId=${encodeURIComponent(workflow.id)}&limit=10`);
1440
+ if (cancelled) return;
1441
+ const runs = Array.isArray(data?.runs) ? data.runs : [];
1442
+ const running = runs.find((run) => run?.status === "running");
1443
+ const targetRun = running || runs[0] || null;
1444
+ if (!targetRun?.runId) {
1445
+ setLiveRun(null);
1446
+ setLiveNodeStatuses({});
1447
+ setLiveNodeRunningHints({});
1448
+ setLiveNodeOutputPreviews({});
1449
+ setLiveNodeFlashStates({});
1450
+ setLiveEdgeActivity({});
1451
+ return;
1452
+ }
1453
+ if (targetRun.status !== "running") {
1454
+ setLiveRun(targetRun);
1455
+ setLiveNodeStatuses({});
1456
+ return;
1457
+ }
1458
+ const detailResponse = await apiFetch(`/api/workflows/runs/${targetRun.runId}`);
1459
+ if (cancelled) return;
1460
+ const detailedRun = detailResponse?.run || targetRun;
1461
+ setLiveRun(detailedRun);
1462
+ const runStatuses = buildNodeStatusesFromRunDetail(detailedRun);
1463
+ const normalizedStatuses = {};
1464
+ for (const [nodeId, status] of Object.entries(runStatuses || {})) {
1465
+ normalizedStatuses[nodeId] = normalizeLiveNodeStatus(status);
1466
+ }
1467
+ setLiveNodeStatuses(normalizedStatuses);
1468
+ const nodeOutputs = detailedRun?.detail?.nodeOutputs && typeof detailedRun.detail.nodeOutputs === "object"
1469
+ ? detailedRun.detail.nodeOutputs
1470
+ : {};
1471
+ setLiveNodeOutputPreviews((prev) => {
1472
+ const next = { ...prev };
1473
+ for (const node of nodesRef.current || []) {
1474
+ const nodeId = String(node?.id || "").trim();
1475
+ if (!nodeId || !Object.prototype.hasOwnProperty.call(nodeOutputs, nodeId)) continue;
1476
+ const preview = resolveNodeOutputPreview(node?.type, null, nodeOutputs[nodeId]);
1477
+ const lines = Array.isArray(preview?.lines)
1478
+ ? preview.lines.map((line) => String(line || "").trim()).filter(Boolean).slice(0, 3)
1479
+ : [];
1480
+ if (!lines.length && preview?.tokenCount == null) continue;
1481
+ next[nodeId] = {
1482
+ lines,
1483
+ tokenCount: Number.isFinite(Number(preview?.tokenCount))
1484
+ ? Math.max(0, Math.round(Number(preview.tokenCount)))
1485
+ : null,
1486
+ updatedAt: Date.now(),
1487
+ };
1488
+ }
1489
+ return next;
1490
+ });
1491
+ } catch {
1492
+ if (cancelled) return;
1493
+ }
1494
+ };
1495
+
1496
+ pollLiveRun();
1497
+ const pollTimer = setInterval(pollLiveRun, WORKFLOW_LIVE_POLL_MS);
1498
+ return () => {
1499
+ cancelled = true;
1500
+ clearInterval(pollTimer);
1501
+ };
1502
+ }, [liveHighlightEnabled, workflow?.id]);
1503
+
1504
+ useEffect(() => {
1505
+ if (!liveHighlightEnabled) return undefined;
1506
+ const timer = setInterval(() => setLiveNowTick(Date.now()), 1000);
1507
+ return () => clearInterval(timer);
1508
+ }, [liveHighlightEnabled, liveRun?.status]);
1509
+
1510
+ useEffect(() => {
1511
+ if (!liveHighlightEnabled) return;
1512
+ const now = Date.now();
1513
+ setLiveNodeFlashStates((prev) => {
1514
+ let changed = false;
1515
+ const next = { ...prev };
1516
+ for (const [nodeId, flash] of Object.entries(next)) {
1517
+ if (!flash || Number(flash.until) <= now) {
1518
+ delete next[nodeId];
1519
+ changed = true;
1520
+ }
1521
+ }
1522
+ return changed ? next : prev;
1523
+ });
1524
+ setLiveNodeRunningHints((prev) => {
1525
+ let changed = false;
1526
+ const next = { ...prev };
1527
+ for (const [nodeId, until] of Object.entries(next)) {
1528
+ if (Number(until || 0) <= now) {
1529
+ delete next[nodeId];
1530
+ changed = true;
1531
+ }
1532
+ }
1533
+ return changed ? next : prev;
1534
+ });
1535
+ setLiveEdgeActivity((prev) => {
1536
+ let changed = false;
1537
+ const next = { ...prev };
1538
+ for (const [edgeId, info] of Object.entries(next)) {
1539
+ if (!info || now - Number(info.ts || 0) > EDGE_FLOW_ANIMATION_MS) {
1540
+ delete next[edgeId];
1541
+ changed = true;
1542
+ }
1543
+ }
1544
+ return changed ? next : prev;
1545
+ });
1546
+ }, [liveNowTick, liveHighlightEnabled]);
1547
+
1548
+ useEffect(() => {
1549
+ if (!liveHighlightEnabled || !workflow?.id) return undefined;
1550
+ const flushQueuedEvents = () => {
1551
+ if (liveEventFlushTimerRef.current) {
1552
+ clearTimeout(liveEventFlushTimerRef.current);
1553
+ liveEventFlushTimerRef.current = null;
1554
+ }
1555
+ const queued = liveEventQueueRef.current.splice(0, liveEventQueueRef.current.length);
1556
+ if (!queued.length) return;
1557
+ setLiveNowTick(Date.now());
1558
+ setLiveRun((prev) => {
1559
+ let next = prev;
1560
+ for (const event of queued) {
1561
+ if (event.kind !== "run") continue;
1562
+ if (!next || next.runId !== event.runId) {
1563
+ next = {
1564
+ ...(next || {}),
1565
+ runId: event.runId,
1566
+ workflowId: event.workflowId || workflow.id,
1567
+ workflowName: event.workflowName || workflow.name,
1568
+ startedAt: event.timestamp || Date.now(),
1569
+ };
1570
+ }
1571
+ next = {
1572
+ ...next,
1573
+ runId: event.runId,
1574
+ workflowId: event.workflowId || next.workflowId || workflow.id,
1575
+ workflowName: event.workflowName || next.workflowName || workflow.name,
1576
+ status: event.status || next.status || "running",
1577
+ duration: Number.isFinite(Number(event.duration)) ? Number(event.duration) : next.duration,
1578
+ endedAt: event.status && event.status !== "running"
1579
+ ? (event.timestamp || Date.now())
1580
+ : next.endedAt,
1581
+ };
1582
+ }
1583
+ return next;
1584
+ });
1585
+ setLiveNodeStatuses((prev) => {
1586
+ const next = { ...prev };
1587
+ for (const event of queued) {
1588
+ if (event.kind !== "node" || !event.nodeId) continue;
1589
+ next[event.nodeId] = normalizeLiveNodeStatus(event.status);
1590
+ }
1591
+ return next;
1592
+ });
1593
+ setLiveNodeOutputPreviews((prev) => {
1594
+ const next = { ...prev };
1595
+ for (const event of queued) {
1596
+ if (event.kind !== "node" || !event.nodeId) continue;
1597
+ if (event.outputPreview || event.error) {
1598
+ const lines = Array.isArray(event.outputPreview?.lines)
1599
+ ? event.outputPreview.lines
1600
+ : (event.error ? [String(event.error)] : []);
1601
+ next[event.nodeId] = {
1602
+ lines: lines.slice(0, 3),
1603
+ tokenCount: Number.isFinite(Number(event.outputPreview?.tokenCount))
1604
+ ? Math.max(0, Math.round(Number(event.outputPreview.tokenCount)))
1605
+ : null,
1606
+ updatedAt: event.timestamp || Date.now(),
1607
+ };
1608
+ }
1609
+ }
1610
+ return next;
1611
+ });
1612
+ setLiveNodeFlashStates((prev) => {
1613
+ const next = { ...prev };
1614
+ const now = Date.now();
1615
+ for (const event of queued) {
1616
+ if (event.kind !== "node" || !event.nodeId) continue;
1617
+ const normalized = normalizeLiveNodeStatus(event.status);
1618
+ if (normalized === "success" || normalized === "fail" || normalized === "skipped") {
1619
+ next[event.nodeId] = {
1620
+ state: normalized,
1621
+ until: now + NODE_COMPLETION_FLASH_MS,
1622
+ };
1623
+ }
1624
+ }
1625
+ for (const [nodeId, flash] of Object.entries(next)) {
1626
+ if (!flash || Number(flash.until) <= now) delete next[nodeId];
1627
+ }
1628
+ return next;
1629
+ });
1630
+ setLiveNodeRunningHints((prev) => {
1631
+ const next = { ...prev };
1632
+ const now = Date.now();
1633
+ let changed = false;
1634
+ for (const event of queued) {
1635
+ if (event.kind !== "node" || !event.nodeId) continue;
1636
+ const normalized = normalizeLiveNodeStatus(event.status);
1637
+ if (normalized === "running") {
1638
+ next[event.nodeId] = now + NODE_RUNNING_HINT_MS;
1639
+ changed = true;
1640
+ continue;
1641
+ }
1642
+ if (normalized === "success" || normalized === "fail" || normalized === "skipped") {
1643
+ if (next[event.nodeId]) {
1644
+ delete next[event.nodeId];
1645
+ changed = true;
1646
+ }
1647
+ }
1648
+ }
1649
+ return changed ? next : prev;
1650
+ });
1651
+ setLiveEdgeActivity((prev) => {
1652
+ const next = { ...prev };
1653
+ const now = Date.now();
1654
+ for (const event of queued) {
1655
+ if (event.kind !== "edge" || !event.edgeId) continue;
1656
+ next[event.edgeId] = {
1657
+ ts: Number(event.timestamp) || now,
1658
+ source: event.source || null,
1659
+ target: event.target || null,
1660
+ reason: event.reason || "flow",
1661
+ };
1662
+ }
1663
+ for (const [edgeId, info] of Object.entries(next)) {
1664
+ if (!info || now - Number(info.ts || 0) > EDGE_FLOW_ANIMATION_MS) {
1665
+ delete next[edgeId];
1666
+ }
1667
+ }
1668
+ return next;
1669
+ });
1670
+ };
1671
+
1672
+ const scheduleEventFlush = () => {
1673
+ if (liveEventFlushTimerRef.current) return;
1674
+ liveEventFlushTimerRef.current = setTimeout(flushQueuedEvents, WORKFLOW_LIVE_WS_BATCH_MS);
1675
+ };
1676
+
1677
+ const unsub = onWsMessage((msg) => {
1678
+ if (msg?.type !== "workflow-run-events") return;
1679
+ const payload = msg?.payload || {};
1680
+ const payloadWorkflowId = String(payload.workflowId || "").trim();
1681
+ if (payloadWorkflowId !== String(workflow.id || "").trim()) return;
1682
+ const events = Array.isArray(payload.events) ? payload.events : [];
1683
+ if (!events.length) return;
1684
+ liveEventQueueRef.current.push(...events);
1685
+ scheduleEventFlush();
1686
+ });
1687
+
1688
+ return () => {
1689
+ if (liveEventFlushTimerRef.current) {
1690
+ clearTimeout(liveEventFlushTimerRef.current);
1691
+ liveEventFlushTimerRef.current = null;
1692
+ }
1693
+ liveEventQueueRef.current = [];
1694
+ try {
1695
+ unsub?.();
1696
+ } catch {}
1697
+ };
1698
+ }, [liveHighlightEnabled, workflow?.id, workflow?.name]);
1699
+
1273
1700
  // Canvas dimensions
1274
1701
  const NODE_W = 220;
1275
- const NODE_H = 60;
1702
+ const NODE_H = 138;
1703
+ const NODE_HEADER_H = 62;
1276
1704
  const PORT_R = 8;
1277
1705
  const HISTORY_LIMIT = 50;
1278
1706
  const HISTORY_COMMIT_DEBOUNCE_MS = 220;
@@ -1932,7 +2360,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1932
2360
  <span class="btn-icon">${resolveIcon("play")}</span>
1933
2361
  Run
1934
2362
  <//>
1935
- <${Button}
2363
+ ${workflow?.core !== true && html`<${Button}
1936
2364
  variant="outlined"
1937
2365
  size="small"
1938
2366
  onClick=${() => {
@@ -1942,7 +2370,8 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1942
2370
  >
1943
2371
  <span class="btn-icon">${resolveIcon(workflow?.enabled === false ? "play" : "pause")}</span>
1944
2372
  ${workflow?.enabled === false ? "Resume" : "Pause"}
1945
- <//>
2373
+ <//>`}
2374
+ ${workflow?.core === true && html`<span class="wf-badge" style="background: #8b5cf620; color: #a78bfa; font-size: 11px; font-weight: 600;">Core</span>`}
1946
2375
  <${Button} variant="text" size="small" disabled=${historyState.past.length === 0} onClick=${undoCanvas}>Undo<//>
1947
2376
  <${Button} variant="text" size="small" disabled=${historyState.future.length === 0} onClick=${redoCanvas}>Redo<//>
1948
2377
  <${Button} variant="text" size="small" onClick=${() => setShowShortcutOverlay(true)}>Shortcuts ?<//>
@@ -1958,6 +2387,24 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
1958
2387
  <span class="wf-badge" style="font-size: 11px; opacity: 0.75;">
1959
2388
  ${workflow?.enabled === false ? "Paused" : "Active"} · Pan: touch drag, Ctrl/Space + drag
1960
2389
  </span>
2390
+ <div style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--color-text-secondary, #8b95a5);">
2391
+ <${Switch}
2392
+ size="small"
2393
+ checked=${liveHighlightEnabled}
2394
+ onChange=${(e) => setLiveHighlightEnabled(Boolean(e.target.checked))}
2395
+ />
2396
+ <span>Live highlights</span>
2397
+ </div>
2398
+ ${liveHighlightEnabled && liveRun?.runId && html`
2399
+ <span class="wf-badge" style="font-size: 11px; background: ${getRunStatusBadgeStyles(liveRun.status).bg}; color: ${getRunStatusBadgeStyles(liveRun.status).color};">
2400
+ ${liveRun.status === "running" ? "Live Run" : "Last Run"} · ${formatDuration(liveRunDuration)}
2401
+ </span>
2402
+ `}
2403
+ ${liveHighlightEnabled && hasLiveStatuses && html`
2404
+ <span class="wf-badge" style="font-size: 11px; background: #3b82f630; color: #60a5fa;">
2405
+ ${liveActiveNodes} active node${liveActiveNodes === 1 ? "" : "s"}
2406
+ </span>
2407
+ `}
1961
2408
  <${Button} variant="text" size="small" onClick=${() => setZoom(1)}>Reset Zoom<//>
1962
2409
  <${Button} variant="text" size="small" onClick=${() => setPan({ x: 0, y: 0 })}>Reset Pan<//>
1963
2410
  <${Button} variant="text" size="small" onClick=${returnToWorkflowList}>← Back to Workflows<//>
@@ -2020,25 +2467,48 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2020
2467
  const to = getInputPort(edge.target);
2021
2468
  const isSelected = selectedEdgeId.value === edge.id;
2022
2469
  const hasCondition = !!edge.condition;
2470
+ const edgeKey = edge.id || `${edge.source}->${edge.target}`;
2471
+ const edgeActivity = liveHighlightEnabled ? liveEdgeActivity[edgeKey] : null;
2472
+ const isActiveFlow = Boolean(edgeActivity);
2473
+ const edgePath = curvePath(from.x, from.y, to.x, to.y);
2023
2474
  return html`
2024
2475
  <g key=${edge.id} class="wf-edge" onClick=${(e) => { e.stopPropagation(); selectedEdgeId.value = edge.id; }}>
2025
2476
  <path
2026
- d=${curvePath(from.x, from.y, to.x, to.y)}
2477
+ d=${edgePath}
2027
2478
  fill="none"
2028
- stroke=${isSelected ? "#3b82f6" : hasCondition ? "#f59e0b" : "#6b7280"}
2029
- stroke-width=${isSelected ? 3 : 2}
2479
+ stroke=${isSelected ? "#3b82f6" : isActiveFlow ? "#60a5fa" : hasCondition ? "#f59e0b" : "#6b7280"}
2480
+ stroke-width=${isSelected ? 3 : isActiveFlow ? 2.8 : 2}
2030
2481
  stroke-dasharray=${hasCondition ? "6,4" : "none"}
2031
2482
  marker-end="url(#arrowhead)"
2032
- style="cursor: pointer; transition: stroke 0.15s;"
2483
+ style=${`cursor: pointer; transition: stroke 0.15s, stroke-width 0.15s; ${isActiveFlow ? "filter: drop-shadow(0 0 6px rgba(96,165,250,0.45));" : ""}`}
2033
2484
  />
2485
+ ${isActiveFlow && html`
2486
+ <path
2487
+ d=${edgePath}
2488
+ fill="none"
2489
+ stroke="#93c5fd"
2490
+ stroke-width="1.6"
2491
+ stroke-dasharray="12,8"
2492
+ marker-end="url(#arrowhead)"
2493
+ opacity="0.9"
2494
+ style="pointer-events: none;"
2495
+ >
2496
+ <animate attributeName="stroke-dashoffset" values="0;-20" dur="0.45s" repeatCount="indefinite" />
2497
+ </path>
2498
+ `}
2034
2499
  <!-- Invisible wider hit area -->
2035
2500
  <path
2036
- d=${curvePath(from.x, from.y, to.x, to.y)}
2501
+ d=${edgePath}
2037
2502
  fill="none"
2038
2503
  stroke="transparent"
2039
2504
  stroke-width="12"
2040
2505
  style="cursor: pointer;"
2041
2506
  />
2507
+ ${isActiveFlow && html`
2508
+ <circle r="3.4" fill="#93c5fd" opacity="0.95">
2509
+ <animateMotion dur="0.95s" repeatCount="1" rotate="auto" path=${edgePath} />
2510
+ </circle>
2511
+ `}
2042
2512
  ${hasCondition && html`
2043
2513
  <text
2044
2514
  x=${(from.x + to.x) / 2}
@@ -2081,29 +2551,58 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2081
2551
  ${nodes.map(node => {
2082
2552
  const meta = getNodeMeta(node.type);
2083
2553
  const isSelected = selectedNodeIds.has(node.id);
2554
+ const nodeRunStatus = liveHighlightEnabled ? normalizeLiveNodeStatus(liveNodeStatuses[node.id]) : null;
2555
+ const nodeFlash = liveNodeFlashStates[node.id] || null;
2556
+ const flashState = nodeFlash?.state || "";
2557
+ const executionVisuals = getCanvasNodeExecutionVisuals(nodeRunStatus, isSelected, meta.color, flashState);
2558
+ const nodeStatusStyles = getRunStatusBadgeStyles(nodeRunStatus);
2559
+ const preview = resolveNodeOutputPreview(node.type, liveNodeOutputPreviews[node.id], null);
2560
+ const previewLines = preview.lines.slice(0, 3);
2561
+ const hasPreview = previewLines.length > 0 || preview.tokenCount != null;
2562
+ const runningHintUntil = Number(liveNodeRunningHints[node.id] || 0);
2563
+ const hasRunningHint = runningHintUntil > liveNowTick;
2564
+ const spinnerVisible = nodeRunStatus === "running" || hasRunningHint;
2565
+ const previewPanelY = NODE_HEADER_H + 8;
2566
+ const previewPanelH = Math.max(30, NODE_H - previewPanelY - 8);
2084
2567
  const x = node.position?.x || 0;
2085
2568
  const y = node.position?.y || 0;
2086
2569
  return html`
2087
2570
  <g
2088
2571
  key=${node.id}
2089
- class="wf-node"
2572
+ class=${`wf-node${spinnerVisible ? " wf-node-running" : ""}${flashState ? ` wf-node-flash-${flashState}` : ""}`}
2090
2573
  transform="translate(${x} ${y})"
2091
2574
  onMouseDown=${(e) => onNodeMouseDown(node.id, e)}
2092
2575
  onPointerDown=${(e) => onNodePointerDown(node.id, e)}
2093
2576
  onDblClick=${() => onNodeDoubleClick(node.id)}
2094
2577
  onContextMenu=${(e) => onNodeContextMenu(node.id, e)}
2095
2578
  style="cursor: grab;"
2096
- filter=${isSelected ? "url(#node-glow)" : "url(#node-shadow)"}
2579
+ filter=${executionVisuals.filter}
2097
2580
  >
2098
2581
  <!-- Node body -->
2099
2582
  <rect
2100
2583
  width=${NODE_W}
2101
2584
  height=${NODE_H}
2102
2585
  rx="8"
2103
- fill=${isSelected ? "#1e293b" : "#1a1f2e"}
2104
- stroke=${isSelected ? meta.color : "#2a3040"}
2105
- stroke-width=${isSelected ? 2 : 1}
2586
+ fill=${executionVisuals.fill}
2587
+ stroke=${executionVisuals.stroke}
2588
+ stroke-width=${executionVisuals.strokeWidth}
2106
2589
  />
2590
+ ${spinnerVisible && html`
2591
+ <rect
2592
+ x="1.5"
2593
+ y="1.5"
2594
+ width=${NODE_W - 3}
2595
+ height=${NODE_H - 3}
2596
+ rx="7"
2597
+ fill="none"
2598
+ stroke="#93c5fd"
2599
+ stroke-opacity="0.85"
2600
+ stroke-width="1.6"
2601
+ stroke-dasharray="10 6"
2602
+ >
2603
+ <animate attributeName="stroke-dashoffset" values="0;-32" dur="1s" repeatCount="indefinite" />
2604
+ </rect>
2605
+ `}
2107
2606
 
2108
2607
  <!-- Category color strip -->
2109
2608
  <rect
@@ -2116,7 +2615,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2116
2615
  <!-- Label -->
2117
2616
  <text
2118
2617
  x=${NODE_W / 2}
2119
- y=${NODE_H / 2 - 6}
2618
+ y="22"
2120
2619
  text-anchor="middle"
2121
2620
  fill="white"
2122
2621
  font-size="13"
@@ -2126,12 +2625,73 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] }
2126
2625
  <!-- Type subtitle -->
2127
2626
  <text
2128
2627
  x=${NODE_W / 2}
2129
- y=${NODE_H / 2 + 12}
2628
+ y="40"
2130
2629
  text-anchor="middle"
2131
2630
  fill="#94a3b8"
2132
2631
  font-size="10"
2133
2632
  >${node.type}</text>
2134
2633
 
2634
+ ${nodeRunStatus && html`
2635
+ <g transform="translate(${NODE_W - 74} 8)">
2636
+ <rect
2637
+ width="66"
2638
+ height="18"
2639
+ rx="9"
2640
+ fill=${nodeStatusStyles.bg}
2641
+ stroke=${nodeStatusStyles.color}
2642
+ stroke-opacity="0.4"
2643
+ />
2644
+ <text
2645
+ x="33"
2646
+ y="12"
2647
+ text-anchor="middle"
2648
+ fill=${nodeStatusStyles.color}
2649
+ font-size="10"
2650
+ font-weight="700"
2651
+ >${toNodeStatusLabel(nodeRunStatus)}</text>
2652
+ </g>
2653
+ `}
2654
+
2655
+ ${spinnerVisible && html`
2656
+ <g transform="translate(${NODE_W - 22} ${NODE_HEADER_H - 18})">
2657
+ <circle r="9" cx="0" cy="0" fill="rgba(15, 23, 42, 0.72)" stroke="#93c5fd44" stroke-width="1.2" />
2658
+ <circle r="6.8" cx="0" cy="0" fill="none" stroke="#93c5fd33" stroke-width="1.2" />
2659
+ <path d="M 0 -6.8 A 6.8 6.8 0 0 1 5.4 -4.2" fill="none" stroke="#93c5fd" stroke-width="1.8" stroke-linecap="round">
2660
+ <animateTransform attributeName="transform" type="rotate" from="0 0 0" to="360 0 0" dur="0.8s" repeatCount="indefinite" />
2661
+ </path>
2662
+ </g>
2663
+ `}
2664
+
2665
+ <line x1="8" y1=${NODE_HEADER_H} x2=${NODE_W - 8} y2=${NODE_HEADER_H} stroke="#2e3748" stroke-width="1" opacity="0.85" />
2666
+ <rect
2667
+ x="8"
2668
+ y=${previewPanelY}
2669
+ width=${NODE_W - 16}
2670
+ height=${previewPanelH}
2671
+ rx="6"
2672
+ fill="rgba(15, 23, 42, 0.45)"
2673
+ stroke=${hasPreview ? "rgba(148, 163, 184, 0.28)" : "rgba(71, 85, 105, 0.2)"}
2674
+ stroke-width="1"
2675
+ />
2676
+
2677
+ ${hasPreview && html`
2678
+ <text x="14" y=${previewPanelY + 14} fill="#cbd5e1" font-size="10" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace">
2679
+ ${previewLines.map((line, index) => html`
2680
+ <tspan x="14" dy=${index === 0 ? 0 : 13}>${line.slice(0, 44)}</tspan>
2681
+ `)}
2682
+ </text>
2683
+ `}
2684
+ ${preview.tokenCount != null && html`
2685
+ <text
2686
+ x=${NODE_W - 14}
2687
+ y=${NODE_H - 10}
2688
+ text-anchor="end"
2689
+ fill="#93c5fd"
2690
+ font-size="10"
2691
+ font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
2692
+ >${preview.tokenCount} tok</text>
2693
+ `}
2694
+
2135
2695
  <!-- Input port (left) -->
2136
2696
  <circle
2137
2697
  cx="0"
@@ -2381,14 +2941,45 @@ function KeyboardShortcutOverlay({ open, onClose, canUndo, canRedo }) {
2381
2941
 
2382
2942
  const COMMAND_PRESETS = {
2383
2943
  testing: [
2384
- { label: "Run Tests", cmd: "npm test", icon: "beaker" },
2944
+ { label: "Run Tests (npm)", cmd: "npm test", icon: "beaker" },
2945
+ { label: "Run Tests (yarn)", cmd: "yarn test", icon: "beaker" },
2946
+ { label: "Run Tests (pnpm)", cmd: "pnpm test", icon: "beaker" },
2947
+ { label: "Run Tests (pytest)", cmd: "pytest", icon: "beaker" },
2948
+ { label: "Run Tests (Go)", cmd: "go test ./...", icon: "beaker" },
2949
+ { label: "Run Tests (Rust)", cmd: "cargo test", icon: "beaker" },
2950
+ { label: "Run Tests (Java/Maven)", cmd: "mvn test", icon: "beaker" },
2951
+ { label: "Run Tests (Java/Gradle)", cmd: "./gradlew test", icon: "beaker" },
2952
+ { label: "Run Tests (.NET)", cmd: "dotnet test", icon: "beaker" },
2953
+ { label: "Run Tests (Ruby)", cmd: "bundle exec rspec", icon: "beaker" },
2385
2954
  { label: "Run Single File", cmd: 'npx vitest run tests/{{testFile}}', icon: "target" },
2386
- { label: "Syntax Check", cmd: "npm run syntax:check", icon: "check" },
2955
+ { label: "Syntax Check (Node)", cmd: "npm run syntax:check", icon: "check" },
2956
+ { label: "Syntax Check (Python)", cmd: "python -m py_compile", icon: "check" },
2957
+ { label: "Syntax Check (Go)", cmd: "go vet ./...", icon: "check" },
2958
+ { label: "Syntax Check (Rust)", cmd: "cargo check", icon: "check" },
2387
2959
  ],
2388
2960
  build: [
2389
- { label: "Build Project", cmd: "npm run build", icon: "hammer" },
2961
+ { label: "Build (npm)", cmd: "npm run build", icon: "hammer" },
2962
+ { label: "Build (yarn)", cmd: "yarn build", icon: "hammer" },
2963
+ { label: "Build (pnpm)", cmd: "pnpm build", icon: "hammer" },
2964
+ { label: "Build (Go)", cmd: "go build ./...", icon: "hammer" },
2965
+ { label: "Build (Rust)", cmd: "cargo build", icon: "hammer" },
2966
+ { label: "Build (Maven)", cmd: "mvn package -DskipTests", icon: "hammer" },
2967
+ { label: "Build (Gradle)", cmd: "./gradlew build", icon: "hammer" },
2968
+ { label: "Build (.NET)", cmd: "dotnet build", icon: "hammer" },
2969
+ { label: "Build (Python)", cmd: "python -m build", icon: "hammer" },
2970
+ { label: "Build (Make)", cmd: "make", icon: "hammer" },
2390
2971
  { label: "Build Watch", cmd: "npm run build -- --watch", icon: "eye" },
2391
- { label: "Type Check", cmd: "npx tsc --noEmit", icon: "ruler" },
2972
+ { label: "Type Check (TS)", cmd: "npx tsc --noEmit", icon: "ruler" },
2973
+ ],
2974
+ lint: [
2975
+ { label: "Lint (npm)", cmd: "npm run lint", icon: "search" },
2976
+ { label: "Lint (ESLint)", cmd: "npx eslint .", icon: "search" },
2977
+ { label: "Lint (Python/Ruff)", cmd: "ruff check .", icon: "search" },
2978
+ { label: "Lint (Python/Flake8)", cmd: "flake8", icon: "search" },
2979
+ { label: "Lint (Go)", cmd: "golangci-lint run", icon: "search" },
2980
+ { label: "Lint (Rust)", cmd: "cargo clippy -- -D warnings", icon: "search" },
2981
+ { label: "Lint (Ruby)", cmd: "bundle exec rubocop", icon: "search" },
2982
+ { label: "Lint (.NET)", cmd: "dotnet format --verify-no-changes", icon: "search" },
2392
2983
  ],
2393
2984
  git: [
2394
2985
  { label: "Diff Stats", cmd: "git diff --stat main...HEAD", icon: "chart" },
@@ -2858,16 +3449,33 @@ function NodeConfigEditor({ node, nodeTypes: types, onUpdate, onUpdateLabel, onC
2858
3449
  ${[
2859
3450
  ...(nodeAction === "build" ? [
2860
3451
  { label: "npm run build", cmd: "npm run build" },
3452
+ { label: "yarn build", cmd: "yarn build" },
3453
+ { label: "go build", cmd: "go build ./..." },
3454
+ { label: "cargo build", cmd: "cargo build" },
3455
+ { label: "mvn package", cmd: "mvn package -DskipTests" },
3456
+ { label: "gradlew build", cmd: "./gradlew build" },
3457
+ { label: "dotnet build", cmd: "dotnet build" },
3458
+ { label: "make", cmd: "make" },
2861
3459
  { label: "Zero Warnings", cmd: "npm run build", extra: { zeroWarnings: true } },
2862
3460
  ] : []),
2863
3461
  ...(nodeAction === "tests" ? [
2864
3462
  { label: "npm test", cmd: "npm test" },
2865
3463
  { label: "Vitest", cmd: "npx vitest run" },
2866
3464
  { label: "Jest", cmd: "npx jest" },
3465
+ { label: "pytest", cmd: "pytest" },
3466
+ { label: "go test", cmd: "go test ./..." },
3467
+ { label: "cargo test", cmd: "cargo test" },
3468
+ { label: "mvn test", cmd: "mvn test" },
3469
+ { label: "dotnet test", cmd: "dotnet test" },
3470
+ { label: "rspec", cmd: "bundle exec rspec" },
2867
3471
  ] : []),
2868
3472
  ...(nodeAction === "lint" ? [
2869
3473
  { label: "npm run lint", cmd: "npm run lint" },
2870
3474
  { label: "ESLint", cmd: "npx eslint ." },
3475
+ { label: "Ruff", cmd: "ruff check ." },
3476
+ { label: "golangci-lint", cmd: "golangci-lint run" },
3477
+ { label: "Clippy", cmd: "cargo clippy -- -D warnings" },
3478
+ { label: "Rubocop", cmd: "bundle exec rubocop" },
2871
3479
  ] : []),
2872
3480
  ].map(p => html`
2873
3481
  <${Button}
@@ -3017,6 +3625,52 @@ function NodeConfigEditor({ node, nodeTypes: types, onUpdate, onUpdateLabel, onC
3017
3625
  * Workflow List View
3018
3626
  * ═══════════════════════════════════════════════════════════════ */
3019
3627
 
3628
+ function humanizeWorkflowCategory(category) {
3629
+ const normalized = String(category || "custom").trim();
3630
+ if (!normalized) return "Custom";
3631
+ return normalized
3632
+ .replace(/[-_]+/g, " ")
3633
+ .replace(/\b\w/g, (char) => char.toUpperCase());
3634
+ }
3635
+
3636
+ function normalizeWorkflowCategoryMeta(source, fallbackCategory = "custom") {
3637
+ const key = String(source?.category || fallbackCategory || "custom").trim() || "custom";
3638
+ const order = Number(source?.categoryOrder);
3639
+ return {
3640
+ key,
3641
+ label: String(source?.categoryLabel || humanizeWorkflowCategory(key)),
3642
+ icon: String(source?.categoryIcon || "settings"),
3643
+ order: Number.isFinite(order) ? order : 99,
3644
+ };
3645
+ }
3646
+
3647
+ function groupItemsByWorkflowCategory(items, getSource) {
3648
+ const groups = new Map();
3649
+ for (const item of items || []) {
3650
+ const meta = normalizeWorkflowCategoryMeta(getSource(item), item?.category);
3651
+ if (!groups.has(meta.key)) groups.set(meta.key, { ...meta, items: [] });
3652
+ groups.get(meta.key).items.push(item);
3653
+ }
3654
+ return Array.from(groups.values()).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
3655
+ }
3656
+
3657
+ function resolveWorkflowTemplateSource(workflow, templateLookupById, templateLookupByName) {
3658
+ const templateState = workflow?.metadata?.templateState || null;
3659
+ const candidates = [
3660
+ templateState?.templateId,
3661
+ workflow?.metadata?.installedFrom,
3662
+ templateState?.templateName,
3663
+ workflow?.name,
3664
+ ];
3665
+ for (const candidate of candidates) {
3666
+ const key = String(candidate || "").trim();
3667
+ if (!key) continue;
3668
+ if (templateLookupById.has(key)) return templateLookupById.get(key);
3669
+ if (templateLookupByName.has(key)) return templateLookupByName.get(key);
3670
+ }
3671
+ return null;
3672
+ }
3673
+
3020
3674
  function WorkflowListView() {
3021
3675
  const wfs = workflows.value || [];
3022
3676
  const tmpls = templates.value || [];
@@ -3029,6 +3683,28 @@ function WorkflowListView() {
3029
3683
  if (installedTemplateIds.has(t.id) || installedTemplateIds.has(t.name)) return false;
3030
3684
  return true;
3031
3685
  });
3686
+ const templateLookup = useMemo(() => {
3687
+ const byId = new Map();
3688
+ const byName = new Map();
3689
+ tmpls.forEach((template) => {
3690
+ const id = String(template?.id || "").trim();
3691
+ const name = String(template?.name || "").trim();
3692
+ if (id) byId.set(id, template);
3693
+ if (name) byName.set(name, template);
3694
+ });
3695
+ return { byId, byName };
3696
+ }, [tmpls]);
3697
+ const workflowGroups = useMemo(() => {
3698
+ return groupItemsByWorkflowCategory(wfs, (wf) => {
3699
+ return (
3700
+ resolveWorkflowTemplateSource(wf, templateLookup.byId, templateLookup.byName)
3701
+ || { category: wf?.category || "custom" }
3702
+ );
3703
+ });
3704
+ }, [wfs, templateLookup]);
3705
+ const availableTemplateGroups = useMemo(() => {
3706
+ return groupItemsByWorkflowCategory(availableTemplates, (template) => template);
3707
+ }, [availableTemplates]);
3032
3708
 
3033
3709
  return html`
3034
3710
  <div style="padding: 0 4px;">
@@ -3073,127 +3749,144 @@ function WorkflowListView() {
3073
3749
  <h3 style="font-size: 14px; font-weight: 600; color: var(--color-text-secondary, #8b95a5); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
3074
3750
  Your Workflows (${wfs.length})
3075
3751
  </h3>
3076
- <div style="display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
3077
- ${wfs.map(wf => html`
3078
- ${(() => {
3079
- const templateState = wf.metadata?.templateState || null;
3080
- const hasTemplateUpdate = templateState?.updateAvailable === true;
3081
- const isCustomizedTemplate = templateState?.isCustomized === true;
3082
- return html`
3083
- <div key=${wf.id} class="wf-card" style="background: var(--color-bg-secondary, #1a1f2e); border-radius: 12px; padding: 14px; border: 1px solid var(--color-border, #2a3040); cursor: pointer; transition: border-color 0.15s;"
3084
- onClick=${() => {
3085
- apiFetch("/api/workflows/" + wf.id).then(d => {
3086
- activeWorkflow.value = d?.workflow || wf;
3087
- viewMode.value = "canvas";
3088
- }).catch(() => { activeWorkflow.value = wf; viewMode.value = "canvas"; });
3089
- }}>
3090
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
3091
- <span class="icon-inline" style="font-size: 14px;">${resolveIcon(getNodeMeta(wf.trigger || "action")?.icon) || ICONS.dot}</span>
3092
- <span style="font-weight: 600; font-size: 14px; flex: 1;">${wf.name}</span>
3093
- <span class="wf-badge" style="background: ${wf.enabled ? '#10b98130' : '#6b728030'}; color: ${wf.enabled ? '#10b981' : '#6b7280'}; font-size: 10px;">
3094
- ${wf.enabled ? "Active" : "Paused"}
3095
- </span>
3096
- ${templateState?.templateId && html`
3097
- <span class="wf-badge" style="background: #3b82f620; color: #60a5fa; font-size: 10px;">
3098
- Template
3099
- </span>
3100
- `}
3101
- ${isCustomizedTemplate && html`
3102
- <span class="wf-badge" style="background: #f59e0b20; color: #f59e0b; font-size: 10px;">
3103
- Customized
3104
- </span>
3105
- `}
3106
- ${hasTemplateUpdate && html`
3107
- <span class="wf-badge" style="background: #ef444420; color: #f87171; font-size: 10px;">
3108
- Update Available
3109
- </span>
3110
- `}
3111
- </div>
3112
- ${wf.description && html`
3113
- <div style="font-size: 12px; color: var(--color-text-secondary, #8b95a5); margin-bottom: 8px; line-height: 1.4;">
3114
- ${wf.description.slice(0, 120)}${wf.description.length > 120 ? "…" : ""}
3115
- </div>
3116
- `}
3117
- ${templateState?.templateId && html`
3118
- <div style="font-size: 11px; color: var(--color-text-secondary, #7f8aa0); margin-bottom: 8px;">
3119
- ${templateState.templateName || templateState.templateId}
3120
- ${templateState.installedTemplateVersion && templateState.templateVersion && templateState.installedTemplateVersion !== templateState.templateVersion && html`
3121
- <span> · v${templateState.installedTemplateVersion} → v${templateState.templateVersion}</span>
3752
+ ${workflowGroups.map((group) => html`
3753
+ <div key=${group.key} style="margin-bottom: 20px;">
3754
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--color-border, #2a304060);">
3755
+ <span class="icon-inline" style="font-size: 16px;">${resolveIcon(group.icon) || ICONS.dot}</span>
3756
+ <span style="font-size: 13px; font-weight: 600; color: var(--color-text-secondary, #8b95a5);">${group.label}</span>
3757
+ <span style="font-size: 11px; color: var(--color-text-secondary, #6b7280);">(${group.items.length})</span>
3758
+ </div>
3759
+ <div style="display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
3760
+ ${group.items.map(wf => html`
3761
+ ${(() => {
3762
+ const templateState = wf.metadata?.templateState || null;
3763
+ const hasTemplateUpdate = templateState?.updateAvailable === true;
3764
+ const isCustomizedTemplate = templateState?.isCustomized === true;
3765
+ const isCore = wf.core === true;
3766
+ return html`
3767
+ <div key=${wf.id} class="wf-card" style="background: var(--color-bg-secondary, #1a1f2e); border-radius: 12px; padding: 14px; border: 1px solid var(--color-border, #2a3040); cursor: pointer; transition: border-color 0.15s;"
3768
+ onClick=${() => {
3769
+ apiFetch("/api/workflows/" + wf.id).then(d => {
3770
+ activeWorkflow.value = d?.workflow || wf;
3771
+ viewMode.value = "canvas";
3772
+ }).catch(() => { activeWorkflow.value = wf; viewMode.value = "canvas"; });
3773
+ }}>
3774
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
3775
+ <span class="icon-inline" style="font-size: 14px;">${resolveIcon(getNodeMeta(wf.trigger || "action")?.icon) || ICONS.dot}</span>
3776
+ <span style="font-weight: 600; font-size: 14px; flex: 1;">${wf.name}</span>
3777
+ <span class="wf-badge" style="background: ${wf.enabled ? '#10b98130' : '#6b728030'}; color: ${wf.enabled ? '#10b981' : '#6b7280'}; font-size: 10px;">
3778
+ ${wf.enabled ? "Active" : "Paused"}
3779
+ </span>
3780
+ ${isCore && html`
3781
+ <span class="wf-badge" style="background: #8b5cf620; color: #a78bfa; font-size: 10px; font-weight: 600;">
3782
+ Core
3783
+ </span>
3784
+ `}
3785
+ ${templateState?.templateId && html`
3786
+ <span class="wf-badge" style="background: #3b82f620; color: #60a5fa; font-size: 10px;">
3787
+ Template
3788
+ </span>
3789
+ `}
3790
+ ${isCustomizedTemplate && html`
3791
+ <span class="wf-badge" style="background: #f59e0b20; color: #f59e0b; font-size: 10px;">
3792
+ Customized
3793
+ </span>
3794
+ `}
3795
+ ${hasTemplateUpdate && html`
3796
+ <span class="wf-badge" style="background: #ef444420; color: #f87171; font-size: 10px;">
3797
+ Update Available
3798
+ </span>
3799
+ `}
3800
+ </div>
3801
+ ${wf.description && html`
3802
+ <div style="font-size: 12px; color: var(--color-text-secondary, #8b95a5); margin-bottom: 8px; line-height: 1.4;">
3803
+ ${wf.description.slice(0, 120)}${wf.description.length > 120 ? "…" : ""}
3804
+ </div>
3805
+ `}
3806
+ ${templateState?.templateId && html`
3807
+ <div style="font-size: 11px; color: var(--color-text-secondary, #7f8aa0); margin-bottom: 8px;">
3808
+ ${templateState.templateName || templateState.templateId}
3809
+ ${templateState.installedTemplateVersion && templateState.templateVersion && templateState.installedTemplateVersion !== templateState.templateVersion && html`
3810
+ <span> · v${templateState.installedTemplateVersion} → v${templateState.templateVersion}</span>
3811
+ `}
3812
+ </div>
3122
3813
  `}
3814
+ <div style="display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--color-text-secondary, #6b7280);">
3815
+ <span>${wf.nodeCount || 0} nodes</span>
3816
+ <span>·</span>
3817
+ <span class="wf-badge" style="font-size: 10px; padding: 2px 8px; background: var(--color-bg, #0d1117); color: var(--color-text-secondary, #8b95a5);">
3818
+ ${group.label}
3819
+ </span>
3820
+ <div style="flex: 1;"></div>
3821
+ ${hasTemplateUpdate && html`
3822
+ <${Button}
3823
+ variant="text"
3824
+ size="small"
3825
+ sx=${{ fontSize: '11px', borderColor: '#f59e0b80', color: '#f59e0b', textTransform: 'none' }}
3826
+ onClick=${async (e) => {
3827
+ e.stopPropagation();
3828
+ if (!isCustomizedTemplate) {
3829
+ await applyTemplateUpdate(wf.id, "replace", true);
3830
+ return;
3831
+ }
3832
+ const choice = window.prompt(
3833
+ "Template update available for customized workflow.\nType 'copy' to create an updated copy, or 'replace' to overwrite this workflow.",
3834
+ "copy",
3835
+ );
3836
+ const normalized = String(choice || "").trim().toLowerCase();
3837
+ if (normalized === "copy") {
3838
+ await applyTemplateUpdate(wf.id, "copy", false);
3839
+ return;
3840
+ }
3841
+ if (normalized === "replace") {
3842
+ const ok = window.confirm("Replace this customized workflow with latest template? This cannot be undone.");
3843
+ if (!ok) return;
3844
+ await applyTemplateUpdate(wf.id, "replace", true);
3845
+ }
3846
+ }}
3847
+ >
3848
+ <span class="icon-inline">${resolveIcon("refresh")}</span>
3849
+ Update
3850
+ <//>
3851
+ `}
3852
+ ${!isCore && html`<${Button}
3853
+ variant="text"
3854
+ size="small"
3855
+ sx=${{ fontSize: '11px', textTransform: 'none' }}
3856
+ onClick=${(e) => {
3857
+ e.stopPropagation();
3858
+ setWorkflowEnabled(wf.id, !wf.enabled);
3859
+ }}
3860
+ >
3861
+ <span class="icon-inline">${resolveIcon(wf.enabled ? "pause" : "play")}</span>
3862
+ ${wf.enabled ? "Pause" : "Resume"}
3863
+ <//>`}
3864
+ <${Button}
3865
+ variant="text"
3866
+ size="small"
3867
+ sx=${{ fontSize: '11px', textTransform: 'none', ...(wf.enabled ? {} : { opacity: 0.65 }) }}
3868
+ onClick=${(e) => {
3869
+ e.stopPropagation();
3870
+ if (!wf.enabled) {
3871
+ showToast("Workflow is paused. Resume it before running.", "warning");
3872
+ return;
3873
+ }
3874
+ openExecuteDialog(wf.id);
3875
+ }}
3876
+ >
3877
+ <span class="icon-inline">${resolveIcon("play")}</span>
3878
+ <//>
3879
+ ${!isCore && html`<${Button} variant="text" size="small" sx=${{ fontSize: '11px', color: '#ef4444', textTransform: 'none' }} onClick=${(e) => { e.stopPropagation(); if (confirm("Delete " + wf.name + "?")) deleteWorkflow(wf.id); }}>
3880
+ <span class="icon-inline">${resolveIcon("trash")}</span>
3881
+ <//>`}
3882
+ </div>
3123
3883
  </div>
3124
- `}
3125
- <div style="display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--color-text-secondary, #6b7280);">
3126
- <span>${wf.nodeCount || 0} nodes</span>
3127
- <span>·</span>
3128
- <span>${wf.category || "custom"}</span>
3129
- <div style="flex: 1;"></div>
3130
- ${hasTemplateUpdate && html`
3131
- <${Button}
3132
- variant="text"
3133
- size="small"
3134
- sx=${{ fontSize: '11px', borderColor: '#f59e0b80', color: '#f59e0b', textTransform: 'none' }}
3135
- onClick=${async (e) => {
3136
- e.stopPropagation();
3137
- if (!isCustomizedTemplate) {
3138
- await applyTemplateUpdate(wf.id, "replace", true);
3139
- return;
3140
- }
3141
- const choice = window.prompt(
3142
- "Template update available for customized workflow.\nType 'copy' to create an updated copy, or 'replace' to overwrite this workflow.",
3143
- "copy",
3144
- );
3145
- const normalized = String(choice || "").trim().toLowerCase();
3146
- if (normalized === "copy") {
3147
- await applyTemplateUpdate(wf.id, "copy", false);
3148
- return;
3149
- }
3150
- if (normalized === "replace") {
3151
- const ok = window.confirm("Replace this customized workflow with latest template? This cannot be undone.");
3152
- if (!ok) return;
3153
- await applyTemplateUpdate(wf.id, "replace", true);
3154
- }
3155
- }}
3156
- >
3157
- <span class="icon-inline">${resolveIcon("refresh")}</span>
3158
- Update
3159
- <//>
3160
- `}
3161
- <${Button}
3162
- variant="text"
3163
- size="small"
3164
- sx=${{ fontSize: '11px', textTransform: 'none' }}
3165
- onClick=${(e) => {
3166
- e.stopPropagation();
3167
- setWorkflowEnabled(wf.id, !wf.enabled);
3168
- }}
3169
- >
3170
- <span class="icon-inline">${resolveIcon(wf.enabled ? "pause" : "play")}</span>
3171
- ${wf.enabled ? "Pause" : "Resume"}
3172
- <//>
3173
- <${Button}
3174
- variant="text"
3175
- size="small"
3176
- sx=${{ fontSize: '11px', textTransform: 'none', ...(wf.enabled ? {} : { opacity: 0.65 }) }}
3177
- onClick=${(e) => {
3178
- e.stopPropagation();
3179
- if (!wf.enabled) {
3180
- showToast("Workflow is paused. Resume it before running.", "warning");
3181
- return;
3182
- }
3183
- openExecuteDialog(wf.id);
3184
- }}
3185
- >
3186
- <span class="icon-inline">${resolveIcon("play")}</span>
3187
- <//>
3188
- <${Button} variant="text" size="small" sx=${{ fontSize: '11px', color: '#ef4444', textTransform: 'none' }} onClick=${(e) => { e.stopPropagation(); if (confirm("Delete " + wf.name + "?")) deleteWorkflow(wf.id); }}>
3189
- <span class="icon-inline">${resolveIcon("trash")}</span>
3190
- <//>
3191
- </div>
3884
+ `;
3885
+ })()}
3886
+ `)}
3192
3887
  </div>
3193
- `;
3194
- })()}
3195
- `)}
3196
- </div>
3888
+ </div>
3889
+ `)}
3197
3890
  </div>
3198
3891
  `}
3199
3892
 
@@ -3233,60 +3926,50 @@ function WorkflowListView() {
3233
3926
  <span>All templates are installed!</span>
3234
3927
  </div>
3235
3928
  `}
3236
- ${(() => {
3237
- // Group templates by category
3238
- const groups = {};
3239
- availableTemplates.forEach(t => {
3240
- const key = t.category || "custom";
3241
- if (!groups[key]) groups[key] = { label: t.categoryLabel || key, icon: t.categoryIcon || "settings", order: t.categoryOrder || 99, items: [] };
3242
- groups[key].items.push(t);
3243
- });
3244
- const sorted = Object.entries(groups).sort((a, b) => a[1].order - b[1].order);
3245
- return sorted.map(([cat, group]) => html`
3246
- <div key=${cat} style="margin-bottom: 20px;">
3247
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--color-border, #2a304060);">
3248
- <span class="icon-inline" style="font-size: 16px;">${resolveIcon(group.icon) || ICONS.dot}</span>
3249
- <span style="font-size: 13px; font-weight: 600; color: var(--color-text-secondary, #8b95a5);">${group.label}</span>
3250
- <span style="font-size: 11px; color: var(--color-text-secondary, #6b7280);">(${group.items.length})</span>
3251
- </div>
3252
- <div style="display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));">
3253
- ${group.items.map(t => html`
3254
- <div key=${t.id} class="wf-card wf-template-card" style="background: var(--color-bg-secondary, #1a1f2e); border-radius: 12px; padding: 14px; border: 1px solid var(--color-border, #2a304080);">
3255
- <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
3256
- <span class="icon-inline" style="font-size: 14px;">${resolveIcon(t.categoryIcon || group.icon) || ICONS.dot}</span>
3257
- <span style="font-weight: 600; font-size: 14px; flex: 1;">${t.name}</span>
3258
- ${t.recommended && html`
3259
- <span class="wf-badge" style="background: #10b98125; color: #10b981; border-color: #10b98140; font-size: 10px; padding: 2px 8px; font-weight: 600; letter-spacing: 0.3px; display: inline-flex; align-items: center; gap: 4px;">
3260
- <span class="icon-inline">${resolveIcon("star")}</span>
3261
- Recommended
3262
- </span>
3263
- `}
3264
- </div>
3265
- <div style="font-size: 12px; color: var(--color-text-secondary, #8b95a5); margin-bottom: 10px; line-height: 1.4;">
3266
- ${t.description?.slice(0, 120)}${(t.description?.length || 0) > 120 ? "…" : ""}
3267
- </div>
3268
- <div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px;">
3269
- ${(t.tags || []).map(tag => html`
3270
- <span key=${tag} class="wf-badge" style="font-size: 10px; padding: 2px 6px;">${tag}</span>
3271
- `)}
3272
- </div>
3273
- <div style="display: flex; gap: 8px; align-items: center;">
3274
- <span style="font-size: 11px; color: var(--color-text-secondary, #6b7280);">${t.nodeCount} nodes</span>
3275
- <div style="flex: 1;"></div>
3276
- <${Button}
3277
- variant="contained"
3278
- size="small"
3279
- onClick=${() => openInstallTemplateDialog(t.id)}
3280
- >
3281
- Install →
3282
- <//>
3283
- </div>
3929
+ ${availableTemplateGroups.map((group) => html`
3930
+ <div key=${group.key} style="margin-bottom: 20px;">
3931
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--color-border, #2a304060);">
3932
+ <span class="icon-inline" style="font-size: 16px;">${resolveIcon(group.icon) || ICONS.dot}</span>
3933
+ <span style="font-size: 13px; font-weight: 600; color: var(--color-text-secondary, #8b95a5);">${group.label}</span>
3934
+ <span style="font-size: 11px; color: var(--color-text-secondary, #6b7280);">(${group.items.length})</span>
3935
+ </div>
3936
+ <div style="display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));">
3937
+ ${group.items.map(t => html`
3938
+ <div key=${t.id} class="wf-card wf-template-card" style="background: var(--color-bg-secondary, #1a1f2e); border-radius: 12px; padding: 14px; border: 1px solid var(--color-border, #2a304080);">
3939
+ <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
3940
+ <span class="icon-inline" style="font-size: 14px;">${resolveIcon(t.categoryIcon || group.icon) || ICONS.dot}</span>
3941
+ <span style="font-weight: 600; font-size: 14px; flex: 1;">${t.name}</span>
3942
+ ${t.recommended && html`
3943
+ <span class="wf-badge" style="background: #10b98125; color: #10b981; border-color: #10b98140; font-size: 10px; padding: 2px 8px; font-weight: 600; letter-spacing: 0.3px; display: inline-flex; align-items: center; gap: 4px;">
3944
+ <span class="icon-inline">${resolveIcon("star")}</span>
3945
+ Recommended
3946
+ </span>
3947
+ `}
3284
3948
  </div>
3285
- `)}
3286
- </div>
3949
+ <div style="font-size: 12px; color: var(--color-text-secondary, #8b95a5); margin-bottom: 10px; line-height: 1.4;">
3950
+ ${t.description?.slice(0, 120)}${(t.description?.length || 0) > 120 ? "…" : ""}
3951
+ </div>
3952
+ <div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px;">
3953
+ ${(t.tags || []).map(tag => html`
3954
+ <span key=${tag} class="wf-badge" style="font-size: 10px; padding: 2px 6px;">${tag}</span>
3955
+ `)}
3956
+ </div>
3957
+ <div style="display: flex; gap: 8px; align-items: center;">
3958
+ <span style="font-size: 11px; color: var(--color-text-secondary, #6b7280);">${t.nodeCount} nodes</span>
3959
+ <div style="flex: 1;"></div>
3960
+ <${Button}
3961
+ variant="contained"
3962
+ size="small"
3963
+ onClick=${() => openInstallTemplateDialog(t.id)}
3964
+ >
3965
+ Install →
3966
+ <//>
3967
+ </div>
3968
+ </div>
3969
+ `)}
3287
3970
  </div>
3288
- `);
3289
- })()}
3971
+ </div>
3972
+ `)}
3290
3973
  </div>
3291
3974
  </div>
3292
3975
  `;
@@ -3297,19 +3980,22 @@ function WorkflowListView() {
3297
3980
  * ═══════════════════════════════════════════════════════════════ */
3298
3981
 
3299
3982
  function getRunStatusBadgeStyles(status) {
3300
- if (status === "completed") return { bg: "#10b98130", color: "#10b981" };
3301
- if (status === "failed") return { bg: "#ef444430", color: "#ef4444" };
3302
- if (status === "running") return { bg: "#3b82f630", color: "#60a5fa" };
3983
+ const normalized = normalizeLiveNodeStatus(status) || String(status || "").trim().toLowerCase();
3984
+ if (normalized === "completed" || normalized === "success") return { bg: "#10b98130", color: "#10b981" };
3985
+ if (normalized === "failed" || normalized === "fail") return { bg: "#ef444430", color: "#ef4444" };
3986
+ if (normalized === "running") return { bg: "#3b82f630", color: "#60a5fa" };
3987
+ if (normalized === "skipped") return { bg: "#94a3b830", color: "#94a3b8" };
3303
3988
  return { bg: "#6b728030", color: "#9ca3af" };
3304
3989
  }
3305
3990
 
3306
3991
  function getNodeStatusRank(status) {
3307
- if (status === "running") return 0;
3308
- if (status === "failed") return 1;
3992
+ const normalized = normalizeLiveNodeStatus(status) || status;
3993
+ if (normalized === "running") return 0;
3994
+ if (normalized === "failed" || normalized === "fail") return 1;
3309
3995
  if (status === "waiting") return 2;
3310
3996
  if (status === "pending") return 3;
3311
- if (status === "completed") return 4;
3312
- if (status === "skipped") return 5;
3997
+ if (normalized === "completed" || normalized === "success") return 4;
3998
+ if (normalized === "skipped") return 5;
3313
3999
  return 6;
3314
4000
  }
3315
4001
 
@@ -3367,36 +4053,6 @@ function getWorkflowRunTriggerLabel(run) {
3367
4053
  return rawSource || normalizedSource || "unknown";
3368
4054
  }
3369
4055
 
3370
- function buildNodeStatusesFromRunDetail(run) {
3371
- const detail = run?.detail || {};
3372
- const statuses = { ...(detail?.nodeStatuses || {}) };
3373
- const statusEvents = Array.isArray(detail?.nodeStatusEvents) ? detail.nodeStatusEvents : [];
3374
- const logs = Array.isArray(detail?.logs) ? detail.logs : [];
3375
-
3376
- for (const event of statusEvents) {
3377
- const nodeId = String(event?.nodeId || "").trim();
3378
- const status = String(event?.status || "").trim();
3379
- if (!nodeId || !status) continue;
3380
- statuses[nodeId] = status;
3381
- }
3382
-
3383
- // Backfill older runs that only recorded nodeId in logs.
3384
- if (Object.keys(statuses).length === 0) {
3385
- const fallbackStatus = run?.status === "failed"
3386
- ? "failed"
3387
- : run?.status === "completed"
3388
- ? "completed"
3389
- : "running";
3390
- for (const entry of logs) {
3391
- const nodeId = String(entry?.nodeId || "").trim();
3392
- if (!nodeId || statuses[nodeId]) continue;
3393
- statuses[nodeId] = fallbackStatus;
3394
- }
3395
- }
3396
-
3397
- return statuses;
3398
- }
3399
-
3400
4056
  function getNodeCardBorder(status) {
3401
4057
  if (status === "running") return "#3b82f680";
3402
4058
  if (status === "failed") return "#ef444480";
@@ -3426,9 +4082,6 @@ function RunHistoryView() {
3426
4082
  const [nowTick, setNowTick] = useState(Date.now());
3427
4083
  const hasRunningRuns = runs.some((run) => run?.status === "running");
3428
4084
  const selectedRunIsRunning = selectedRun?.status === "running";
3429
- const autoLoadMoreRef = useRef(false);
3430
- const tailSentinelRef = useRef(null);
3431
- const lastAutoLoadCountRef = useRef(-1);
3432
4085
  const [statusFilter, setStatusFilter] = useState("all");
3433
4086
  const [workflowFilter, setWorkflowFilter] = useState("all");
3434
4087
  const [triggerFilter, setTriggerFilter] = useState("all");
@@ -3513,12 +4166,6 @@ function RunHistoryView() {
3513
4166
 
3514
4167
  const canLoadMoreRuns =
3515
4168
  hasMoreRuns && runs.length < WORKFLOW_RUN_MAX_FETCH;
3516
- const hasRunFilters =
3517
- statusFilter !== "all" ||
3518
- workflowFilter !== "all" ||
3519
- triggerFilter !== "all" ||
3520
- Boolean(normalizedSearch);
3521
-
3522
4169
  const triggerLoadMoreRuns = useCallback(() => {
3523
4170
  if (!canLoadMoreRuns || loadingMoreRuns) return false;
3524
4171
  const nextOffset = Number(workflowRunsNextOffset.value || runs.length);
@@ -3531,61 +4178,6 @@ function RunHistoryView() {
3531
4178
  return true;
3532
4179
  }, [canLoadMoreRuns, loadingMoreRuns, runs.length, totalRuns]);
3533
4180
 
3534
- useEffect(() => {
3535
- if (!hasRunFilters || filteredRuns.length > 0 || !canLoadMoreRuns) {
3536
- autoLoadMoreRef.current = false;
3537
- return;
3538
- }
3539
- if (autoLoadMoreRef.current) return;
3540
- autoLoadMoreRef.current = true;
3541
- Promise.resolve(triggerLoadMoreRuns())
3542
- .finally(() => {
3543
- autoLoadMoreRef.current = false;
3544
- });
3545
- }, [hasRunFilters, filteredRuns.length, canLoadMoreRuns, triggerLoadMoreRuns, statusFilter, workflowFilter, triggerFilter, normalizedSearch]);
3546
-
3547
- useEffect(() => {
3548
- if (selectedRun || !canLoadMoreRuns || typeof IntersectionObserver !== "function") {
3549
- lastAutoLoadCountRef.current = -1;
3550
- return;
3551
- }
3552
- const sentinel = tailSentinelRef.current;
3553
- if (!sentinel) return;
3554
- const observer = new IntersectionObserver(
3555
- (entries) => {
3556
- for (const entry of entries) {
3557
- if (!entry.isIntersecting) continue;
3558
- const key = runs.length;
3559
- if (lastAutoLoadCountRef.current === key || loadingMoreRuns) continue;
3560
- lastAutoLoadCountRef.current = key;
3561
- triggerLoadMoreRuns();
3562
- }
3563
- },
3564
- {
3565
- root: null,
3566
- rootMargin: "0px 0px 240px 0px",
3567
- threshold: 0,
3568
- },
3569
- );
3570
- observer.observe(sentinel);
3571
- return () => observer.disconnect();
3572
- }, [selectedRun, canLoadMoreRuns, loadingMoreRuns, runs.length, triggerLoadMoreRuns]);
3573
-
3574
- useEffect(() => {
3575
- if (selectedRun || !canLoadMoreRuns || loadingMoreRuns || typeof window === "undefined") return;
3576
- const doc = document?.documentElement;
3577
- const body = document?.body;
3578
- const scrollHeight = Math.max(
3579
- Number(doc?.scrollHeight || 0),
3580
- Number(body?.scrollHeight || 0),
3581
- );
3582
- if (scrollHeight > window.innerHeight + 240) return;
3583
- const key = runs.length;
3584
- if (lastAutoLoadCountRef.current === key) return;
3585
- lastAutoLoadCountRef.current = key;
3586
- triggerLoadMoreRuns();
3587
- }, [selectedRun, canLoadMoreRuns, loadingMoreRuns, runs.length, filteredRuns.length, triggerLoadMoreRuns]);
3588
-
3589
4181
  if (selectedRun) {
3590
4182
  const statusStyles = getRunStatusBadgeStyles(selectedRun.status);
3591
4183
  const logs = Array.isArray(selectedRun?.detail?.logs) ? selectedRun.detail.logs : [];
@@ -3787,7 +4379,7 @@ function RunHistoryView() {
3787
4379
  <div style="text-align: center; padding: 28px; opacity: 0.6;">
3788
4380
  <div>No runs match the current filters yet.</div>
3789
4381
  ${canLoadMoreRuns && html`
3790
- <div style="margin-top: 6px;">Bosun has loaded ${runs.length} of ${totalRuns} run(s); older history will keep loading while the filter is active.</div>
4382
+ <div style="margin-top: 6px;">Bosun has loaded ${runs.length} of ${totalRuns} run(s); use Load more runs to search older history.</div>
3791
4383
  `}
3792
4384
  </div>
3793
4385
  `}
@@ -3842,9 +4434,6 @@ function RunHistoryView() {
3842
4434
  <//>
3843
4435
  `;
3844
4436
  })}
3845
- ${canLoadMoreRuns && html`
3846
- <div ref=${tailSentinelRef} style="height: 1px;"></div>
3847
- `}
3848
4437
  </div>
3849
4438
  ${canLoadMoreRuns && html`
3850
4439
  <div style="display: flex; justify-content: center; margin-top: 12px;">
@@ -4094,6 +4683,34 @@ export function WorkflowsTab() {
4094
4683
  .wf-preset-btn:hover { border-color: #3b82f6 !important; background: var(--bg-card-hover) !important; }
4095
4684
  .wf-preset-section { animation: wf-fade-in 0.15s ease; }
4096
4685
  @keyframes wf-fade-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
4686
+ .wf-node-running rect:first-child {
4687
+ animation: wf-node-running-pulse 1.1s ease-in-out infinite;
4688
+ }
4689
+ .wf-node-flash-success rect:first-child {
4690
+ animation: wf-node-flash-success 0.45s ease-in-out 2;
4691
+ }
4692
+ .wf-node-flash-fail rect:first-child {
4693
+ animation: wf-node-flash-fail 0.45s ease-in-out 2;
4694
+ }
4695
+ .wf-node-flash-skipped rect:first-child {
4696
+ animation: wf-node-flash-skipped 0.45s ease-in-out 2;
4697
+ }
4698
+ @keyframes wf-node-running-pulse {
4699
+ 0%, 100% { stroke-opacity: 0.6; }
4700
+ 50% { stroke-opacity: 1; }
4701
+ }
4702
+ @keyframes wf-node-flash-success {
4703
+ 0%, 100% { filter: none; }
4704
+ 50% { filter: drop-shadow(0 0 10px rgba(16, 185, 129, 0.65)); }
4705
+ }
4706
+ @keyframes wf-node-flash-fail {
4707
+ 0%, 100% { filter: none; }
4708
+ 50% { filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.65)); }
4709
+ }
4710
+ @keyframes wf-node-flash-skipped {
4711
+ 0%, 100% { filter: none; }
4712
+ 50% { filter: drop-shadow(0 0 8px rgba(148, 163, 184, 0.55)); }
4713
+ }
4097
4714
  .wf-canvas-container { height: calc(100vh - 140px); min-height: 500px; }
4098
4715
  @media (min-width: 1200px) { .wf-canvas-container { height: calc(100vh - 120px); min-height: 700px; } }
4099
4716
  </style>
@@ -4113,4 +4730,3 @@ export function WorkflowsTab() {
4113
4730
  </div>
4114
4731
  `;
4115
4732
  }
4116
-