bosun 0.40.2 → 0.40.3
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.
- package/cli.mjs +86 -67
- package/infra/monitor.mjs +8 -3
- package/infra/update-check.mjs +1 -3
- package/package.json +1 -1
- package/server/ui-server.mjs +117 -0
- package/task/task-executor.mjs +690 -6
- package/task/task-store.mjs +116 -1
- package/ui/demo.html +26 -1
- package/ui/styles/components.css +43 -3
- package/ui/tabs/tasks.js +387 -97
- package/workflow/workflow-engine.mjs +30 -5
- package/workflow/workflow-nodes.mjs +102 -2
- package/workspace/workspace-manager.mjs +14 -0
- package/workspace/worktree-manager.mjs +2 -2
package/ui/tabs/tasks.js
CHANGED
|
@@ -96,12 +96,26 @@ const DAG_GLOBAL_ENDPOINT_CANDIDATES = [
|
|
|
96
96
|
"/api/tasks/dag/global",
|
|
97
97
|
"/api/tasks/graph/global",
|
|
98
98
|
];
|
|
99
|
+
const DAG_EPIC_DEPENDENCY_ENDPOINT_CANDIDATES = [
|
|
100
|
+
"/api/tasks/epic-dependencies",
|
|
101
|
+
"/api/tasks/epics/dependencies",
|
|
102
|
+
"/api/tasks/dag/epics",
|
|
103
|
+
];
|
|
99
104
|
const EMPTY_DAG_GRAPH = {
|
|
100
105
|
title: "",
|
|
101
106
|
description: "",
|
|
102
107
|
nodes: [],
|
|
103
108
|
edges: [],
|
|
104
109
|
};
|
|
110
|
+
const DAG_EDGE_STYLES = {
|
|
111
|
+
"depends-on": { color: "var(--accent)", dash: "" },
|
|
112
|
+
dependency: { color: "var(--accent)", dash: "" },
|
|
113
|
+
sequential: { color: "var(--color-warning)", dash: "7 4" },
|
|
114
|
+
sequence: { color: "var(--color-warning)", dash: "7 4" },
|
|
115
|
+
blocks: { color: "var(--color-error)", dash: "2 4" },
|
|
116
|
+
};
|
|
117
|
+
const DAG_MIN_ZOOM = 0.25;
|
|
118
|
+
const DAG_MAX_ZOOM = 2.4;
|
|
105
119
|
|
|
106
120
|
/* ─── Status/Priority → MUI Chip color ─── */
|
|
107
121
|
function statusChipColor(status) {
|
|
@@ -562,6 +576,120 @@ function normalizeDagGraph(raw, fallbackTitle = "") {
|
|
|
562
576
|
};
|
|
563
577
|
}
|
|
564
578
|
|
|
579
|
+
function normalizeEpicDependenciesPayload(raw) {
|
|
580
|
+
const payload = extractDagPayload(raw);
|
|
581
|
+
const rows = toArray(payload?.data || payload?.items || payload);
|
|
582
|
+
return rows
|
|
583
|
+
.map((entry) => {
|
|
584
|
+
if (!entry || typeof entry !== "object") return null;
|
|
585
|
+
const epicId = toText(entry.epicId || entry.id || entry.epic);
|
|
586
|
+
if (!epicId) return null;
|
|
587
|
+
return {
|
|
588
|
+
epicId,
|
|
589
|
+
dependencies: normalizeDependencyInput(entry.dependencies || entry.dependsOn || []),
|
|
590
|
+
};
|
|
591
|
+
})
|
|
592
|
+
.filter(Boolean);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function buildEpicDagGraph(tasks = [], epicDependencies = []) {
|
|
596
|
+
const epicMap = new Map();
|
|
597
|
+
const pushEpicNode = (epicId) => {
|
|
598
|
+
const id = toText(epicId);
|
|
599
|
+
if (!id) return null;
|
|
600
|
+
if (!epicMap.has(id)) {
|
|
601
|
+
epicMap.set(id, { id, title: id, taskIds: [], statusCounts: new Map(), dependencies: [] });
|
|
602
|
+
}
|
|
603
|
+
return epicMap.get(id);
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
for (const task of tasks || []) {
|
|
607
|
+
const epicId = toText(task?.epicId || task?.meta?.epicId);
|
|
608
|
+
if (!epicId) continue;
|
|
609
|
+
const node = pushEpicNode(epicId);
|
|
610
|
+
node.taskIds.push(task.id);
|
|
611
|
+
const status = toText(task?.status || "todo", "todo").toLowerCase();
|
|
612
|
+
node.statusCounts.set(status, (node.statusCounts.get(status) || 0) + 1);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const depMap = new Map();
|
|
616
|
+
const addEdge = (from, to, kind = "dependency") => {
|
|
617
|
+
const src = toText(from);
|
|
618
|
+
const dst = toText(to);
|
|
619
|
+
if (!src || !dst || src === dst) return;
|
|
620
|
+
pushEpicNode(src);
|
|
621
|
+
const node = pushEpicNode(dst);
|
|
622
|
+
if (node && !node.dependencies.includes(src)) node.dependencies.push(src);
|
|
623
|
+
const key = `${src}->${dst}:${kind}`;
|
|
624
|
+
if (!depMap.has(key)) depMap.set(key, { source: src, target: dst, kind });
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
for (const row of epicDependencies || []) {
|
|
628
|
+
for (const dep of row.dependencies || []) addEdge(dep, row.epicId, "blocks");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const taskById = new Map((tasks || []).map((task) => [String(task?.id || ""), task]));
|
|
632
|
+
for (const task of tasks || []) {
|
|
633
|
+
const targetEpic = toText(task?.epicId || task?.meta?.epicId);
|
|
634
|
+
if (!targetEpic) continue;
|
|
635
|
+
for (const depId of normalizeDependencyInput(task?.dependencyTaskIds || task?.dependsOn || task?.meta?.dependencyTaskIds || [])) {
|
|
636
|
+
const depTask = taskById.get(String(depId));
|
|
637
|
+
const sourceEpic = toText(depTask?.epicId || depTask?.meta?.epicId);
|
|
638
|
+
if (!sourceEpic || sourceEpic === targetEpic) continue;
|
|
639
|
+
addEdge(sourceEpic, targetEpic, "dependency");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const indegree = new Map();
|
|
644
|
+
const outgoing = new Map();
|
|
645
|
+
for (const epicId of epicMap.keys()) {
|
|
646
|
+
indegree.set(epicId, 0);
|
|
647
|
+
outgoing.set(epicId, []);
|
|
648
|
+
}
|
|
649
|
+
for (const edge of depMap.values()) {
|
|
650
|
+
outgoing.get(edge.source)?.push(edge.target);
|
|
651
|
+
indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const queue = [];
|
|
655
|
+
for (const [id, degree] of indegree.entries()) if (degree === 0) queue.push(id);
|
|
656
|
+
const level = new Map();
|
|
657
|
+
for (const id of queue) level.set(id, 0);
|
|
658
|
+
while (queue.length) {
|
|
659
|
+
const id = queue.shift();
|
|
660
|
+
const nextLevel = (level.get(id) || 0) + 1;
|
|
661
|
+
for (const dst of outgoing.get(id) || []) {
|
|
662
|
+
if (!level.has(dst) || (level.get(dst) || 0) < nextLevel) level.set(dst, nextLevel);
|
|
663
|
+
const degree = (indegree.get(dst) || 0) - 1;
|
|
664
|
+
indegree.set(dst, degree);
|
|
665
|
+
if (degree === 0) queue.push(dst);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const nodes = [...epicMap.values()].map((entry) => {
|
|
670
|
+
const statuses = [...entry.statusCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
671
|
+
const dominantStatus = statuses[0]?.[0] || "todo";
|
|
672
|
+
return {
|
|
673
|
+
id: entry.id,
|
|
674
|
+
taskId: null,
|
|
675
|
+
title: `Epic ${entry.title}`,
|
|
676
|
+
status: dominantStatus,
|
|
677
|
+
depth: level.get(entry.id) || 0,
|
|
678
|
+
order: entry.taskIds.length,
|
|
679
|
+
taskCount: entry.taskIds.length,
|
|
680
|
+
dependencies: [...entry.dependencies],
|
|
681
|
+
epicId: entry.id,
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
const edges = [...depMap.values()];
|
|
686
|
+
return {
|
|
687
|
+
title: "Epic Dependency DAG",
|
|
688
|
+
description: "Epic-level execution dependencies.",
|
|
689
|
+
nodes,
|
|
690
|
+
edges,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
565
693
|
function extractGlobalDagPayload(...sources) {
|
|
566
694
|
for (const source of sources) {
|
|
567
695
|
const payload = extractDagPayload(source);
|
|
@@ -3229,8 +3357,18 @@ function DagGraphSection({
|
|
|
3229
3357
|
description = "",
|
|
3230
3358
|
graph = EMPTY_DAG_GRAPH,
|
|
3231
3359
|
onOpenTask,
|
|
3360
|
+
onCreateEdge,
|
|
3361
|
+
allowWiring = false,
|
|
3362
|
+
graphKey = "dag",
|
|
3232
3363
|
emptyMessage = "No DAG nodes available for this view yet.",
|
|
3233
3364
|
}) {
|
|
3365
|
+
const stageRef = useRef(null);
|
|
3366
|
+
const [zoom, setZoom] = useState(1);
|
|
3367
|
+
const [pan, setPan] = useState({ x: 24, y: 24 });
|
|
3368
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
3369
|
+
const [wireSourceId, setWireSourceId] = useState("");
|
|
3370
|
+
const [wiringBusy, setWiringBusy] = useState(false);
|
|
3371
|
+
|
|
3234
3372
|
const sortedNodes = useMemo(() => {
|
|
3235
3373
|
const nodes = [...(graph?.nodes || [])];
|
|
3236
3374
|
nodes.sort((a, b) => {
|
|
@@ -3258,22 +3396,20 @@ function DagGraphSection({
|
|
|
3258
3396
|
list.push(node);
|
|
3259
3397
|
map.set(key, list);
|
|
3260
3398
|
}
|
|
3261
|
-
if (!map.size && sortedNodes.length)
|
|
3262
|
-
map.set(0, [...sortedNodes]);
|
|
3263
|
-
}
|
|
3399
|
+
if (!map.size && sortedNodes.length) map.set(0, [...sortedNodes]);
|
|
3264
3400
|
return [...map.entries()].sort((a, b) => a[0] - b[0]);
|
|
3265
3401
|
}, [sortedNodes]);
|
|
3266
3402
|
|
|
3267
3403
|
const layout = useMemo(() => {
|
|
3268
|
-
const nodeWidth =
|
|
3269
|
-
const nodeHeight =
|
|
3270
|
-
const colGap =
|
|
3404
|
+
const nodeWidth = 250;
|
|
3405
|
+
const nodeHeight = 92;
|
|
3406
|
+
const colGap = 130;
|
|
3271
3407
|
const rowGap = 34;
|
|
3272
|
-
const marginX =
|
|
3273
|
-
const marginY =
|
|
3408
|
+
const marginX = 40;
|
|
3409
|
+
const marginY = 28;
|
|
3274
3410
|
|
|
3275
3411
|
const positions = new Map();
|
|
3276
|
-
let maxRows =
|
|
3412
|
+
let maxRows = 1;
|
|
3277
3413
|
levels.forEach(([, nodes], colIdx) => {
|
|
3278
3414
|
maxRows = Math.max(maxRows, nodes.length);
|
|
3279
3415
|
nodes.forEach((node, rowIdx) => {
|
|
@@ -3283,8 +3419,8 @@ function DagGraphSection({
|
|
|
3283
3419
|
});
|
|
3284
3420
|
});
|
|
3285
3421
|
|
|
3286
|
-
const totalWidth = Math.max(
|
|
3287
|
-
const totalHeight = Math.max(
|
|
3422
|
+
const totalWidth = Math.max(720, marginX * 2 + Math.max(1, levels.length) * nodeWidth + Math.max(0, levels.length - 1) * colGap);
|
|
3423
|
+
const totalHeight = Math.max(360, marginY * 2 + maxRows * nodeHeight + Math.max(0, maxRows - 1) * rowGap);
|
|
3288
3424
|
return { positions, totalWidth, totalHeight };
|
|
3289
3425
|
}, [levels]);
|
|
3290
3426
|
|
|
@@ -3304,24 +3440,102 @@ function DagGraphSection({
|
|
|
3304
3440
|
.filter(Boolean);
|
|
3305
3441
|
}, [graph?.edges, layout.positions]);
|
|
3306
3442
|
|
|
3307
|
-
const
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3443
|
+
const worldBounds = useMemo(() => ({ width: layout.totalWidth, height: layout.totalHeight }), [layout]);
|
|
3444
|
+
|
|
3445
|
+
const fitToView = useCallback(() => {
|
|
3446
|
+
const el = stageRef.current;
|
|
3447
|
+
if (!el) return;
|
|
3448
|
+
const rect = el.getBoundingClientRect();
|
|
3449
|
+
const availableWidth = Math.max(320, rect.width - 24);
|
|
3450
|
+
const availableHeight = Math.max(240, rect.height - 24);
|
|
3451
|
+
const scaleX = availableWidth / Math.max(1, worldBounds.width);
|
|
3452
|
+
const scaleY = availableHeight / Math.max(1, worldBounds.height);
|
|
3453
|
+
const nextZoom = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, Math.min(scaleX, scaleY)));
|
|
3454
|
+
const nextPanX = (rect.width - worldBounds.width * nextZoom) / 2;
|
|
3455
|
+
const nextPanY = (rect.height - worldBounds.height * nextZoom) / 2;
|
|
3456
|
+
setZoom(nextZoom);
|
|
3457
|
+
setPan({ x: nextPanX, y: nextPanY });
|
|
3458
|
+
}, [worldBounds.width, worldBounds.height]);
|
|
3315
3459
|
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3460
|
+
useEffect(() => {
|
|
3461
|
+
fitToView();
|
|
3462
|
+
}, [fitToView, graphKey, sortedNodes.length, edges.length]);
|
|
3463
|
+
|
|
3464
|
+
const applyZoomAtPoint = useCallback((nextZoom, clientX, clientY) => {
|
|
3465
|
+
const el = stageRef.current;
|
|
3466
|
+
if (!el) return;
|
|
3467
|
+
const rect = el.getBoundingClientRect();
|
|
3468
|
+
const clamped = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, nextZoom));
|
|
3469
|
+
const localX = clientX - rect.left;
|
|
3470
|
+
const localY = clientY - rect.top;
|
|
3471
|
+
const worldX = (localX - pan.x) / zoom;
|
|
3472
|
+
const worldY = (localY - pan.y) / zoom;
|
|
3473
|
+
setZoom(clamped);
|
|
3474
|
+
setPan({ x: localX - worldX * clamped, y: localY - worldY * clamped });
|
|
3475
|
+
}, [pan.x, pan.y, zoom]);
|
|
3476
|
+
|
|
3477
|
+
const handleWheel = useCallback((event) => {
|
|
3478
|
+
event.preventDefault();
|
|
3479
|
+
const delta = event.deltaY < 0 ? 1.1 : 0.9;
|
|
3480
|
+
applyZoomAtPoint(zoom * delta, event.clientX, event.clientY);
|
|
3481
|
+
}, [applyZoomAtPoint, zoom]);
|
|
3482
|
+
|
|
3483
|
+
const handlePanStart = useCallback((event) => {
|
|
3484
|
+
if (event.button !== 0) return;
|
|
3485
|
+
const targetEl = event.target;
|
|
3486
|
+
if (targetEl?.closest?.(".dag-node")) return;
|
|
3487
|
+
event.preventDefault();
|
|
3488
|
+
const start = { x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y };
|
|
3489
|
+
setIsPanning(true);
|
|
3490
|
+
const onMove = (moveEvent) => {
|
|
3491
|
+
setPan({ x: start.panX + (moveEvent.clientX - start.x), y: start.panY + (moveEvent.clientY - start.y) });
|
|
3492
|
+
};
|
|
3493
|
+
const onUp = () => {
|
|
3494
|
+
setIsPanning(false);
|
|
3495
|
+
window.removeEventListener("pointermove", onMove);
|
|
3496
|
+
window.removeEventListener("pointerup", onUp);
|
|
3497
|
+
};
|
|
3498
|
+
window.addEventListener("pointermove", onMove);
|
|
3499
|
+
window.addEventListener("pointerup", onUp);
|
|
3500
|
+
}, [pan.x, pan.y]);
|
|
3501
|
+
|
|
3502
|
+
const nodeById = useMemo(() => {
|
|
3503
|
+
const map = new Map();
|
|
3504
|
+
for (const node of sortedNodes) map.set(String(node.id), node);
|
|
3505
|
+
return map;
|
|
3323
3506
|
}, [sortedNodes]);
|
|
3324
3507
|
|
|
3508
|
+
const handleNodeClick = useCallback(async (node, event) => {
|
|
3509
|
+
event?.stopPropagation?.();
|
|
3510
|
+
if (allowWiring && typeof onCreateEdge === "function") {
|
|
3511
|
+
const id = String(node?.id || "");
|
|
3512
|
+
if (!id || wiringBusy) return;
|
|
3513
|
+
if (!wireSourceId) {
|
|
3514
|
+
setWireSourceId(id);
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
if (wireSourceId === id) {
|
|
3518
|
+
setWireSourceId("");
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
const sourceNode = nodeById.get(wireSourceId) || null;
|
|
3522
|
+
const targetNode = nodeById.get(id) || null;
|
|
3523
|
+
if (!sourceNode || !targetNode) {
|
|
3524
|
+
setWireSourceId("");
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
setWiringBusy(true);
|
|
3528
|
+
try {
|
|
3529
|
+
await onCreateEdge({ sourceNode, targetNode });
|
|
3530
|
+
} finally {
|
|
3531
|
+
setWireSourceId("");
|
|
3532
|
+
setWiringBusy(false);
|
|
3533
|
+
}
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
if (node?.taskId) onOpenTask?.(node.taskId);
|
|
3537
|
+
}, [allowWiring, onCreateEdge, onOpenTask, wireSourceId, nodeById, wiringBusy]);
|
|
3538
|
+
|
|
3325
3539
|
if (!sortedNodes.length) {
|
|
3326
3540
|
return html`
|
|
3327
3541
|
<${Paper} variant="outlined" style=${{ padding: "12px", marginBottom: "10px" }}>
|
|
@@ -3332,90 +3546,93 @@ function DagGraphSection({
|
|
|
3332
3546
|
|
|
3333
3547
|
return html`
|
|
3334
3548
|
<div class="tasks-dag-section">
|
|
3335
|
-
<div class="
|
|
3549
|
+
<div class="task-dag-header-row">
|
|
3336
3550
|
<div>
|
|
3337
3551
|
<div style=${{ fontWeight: "700" }}>${title || "Task DAG"}</div>
|
|
3338
3552
|
${description && html`<div class="meta-text">${description}</div>`}
|
|
3553
|
+
<div class="meta-text">Drag to pan · wheel to zoom · click node to ${allowWiring ? "wire edges" : "open task"}.</div>
|
|
3339
3554
|
</div>
|
|
3340
|
-
<div class="
|
|
3341
|
-
<${
|
|
3342
|
-
<${
|
|
3343
|
-
<${
|
|
3344
|
-
<${
|
|
3345
|
-
|
|
3555
|
+
<div class="task-dag-controls">
|
|
3556
|
+
<${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.max(DAG_MIN_ZOOM, z * 0.9))}>-</${Button}>
|
|
3557
|
+
<${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.min(DAG_MAX_ZOOM, z * 1.1))}>+</${Button}>
|
|
3558
|
+
<${Button} size="small" variant="outlined" onClick=${fitToView}>Fit</${Button}>
|
|
3559
|
+
<${Button} size="small" variant="text" onClick=${() => { setZoom(1); setPan({ x: 24, y: 24 }); }}>Reset</${Button}>
|
|
3560
|
+
<span class="task-dag-zoom-pill">${Math.round(zoom * 100)}%</span>
|
|
3561
|
+
${allowWiring && html`<span class="task-dag-wire-pill">${wireSourceId ? `Source: ${wireSourceId}` : wiringBusy ? "Saving edge…" : "Wiring: click source then target"}</span>`}
|
|
3346
3562
|
</div>
|
|
3347
3563
|
</div>
|
|
3348
3564
|
<div class="task-dag-legend">
|
|
3349
3565
|
<span class="task-dag-legend-item"><span class="task-dag-legend-line" style=${{ background: "var(--accent)" }}></span>depends-on</span>
|
|
3350
3566
|
<span class="task-dag-legend-item"><span class="task-dag-legend-line task-dag-legend-line-dashed"></span>sequential</span>
|
|
3351
3567
|
<span class="task-dag-legend-item"><span class="task-dag-legend-line task-dag-legend-line-block"></span>blocks</span>
|
|
3352
|
-
${statusCounts.map(([status, count]) => html`<span class="task-dag-status-pill">${status} ${count}</span>`)}
|
|
3353
3568
|
</div>
|
|
3354
|
-
<div class
|
|
3355
|
-
<svg class="task-dag-canvas"
|
|
3569
|
+
<div class=${`task-dag-canvas-wrap ${isPanning ? "is-panning" : ""}`} ref=${stageRef} onWheel=${handleWheel} onPointerDown=${handlePanStart}>
|
|
3570
|
+
<svg class="task-dag-canvas" role="img" aria-label="Task dependency graph">
|
|
3356
3571
|
<defs>
|
|
3357
|
-
<marker id
|
|
3572
|
+
<marker id=${`dag-arrow-${graphKey}`} markerWidth="10" markerHeight="8" refX="10" refY="4" orient="auto">
|
|
3358
3573
|
<path d="M0,0 L10,4 L0,8 z" fill="var(--accent)" />
|
|
3359
3574
|
</marker>
|
|
3360
3575
|
</defs>
|
|
3361
|
-
${
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
${sortedNodes.map((node) => {
|
|
3382
|
-
const pos = layout.positions.get(String(node.id));
|
|
3383
|
-
if (!pos) return null;
|
|
3384
|
-
return html`
|
|
3385
|
-
<g
|
|
3386
|
-
key=${node.id}
|
|
3387
|
-
class="dag-node"
|
|
3388
|
-
onClick=${() => {
|
|
3389
|
-
if (node.taskId) onOpenTask?.(node.taskId);
|
|
3390
|
-
}}
|
|
3391
|
-
style=${{ cursor: node.taskId ? "pointer" : "default" }}
|
|
3392
|
-
>
|
|
3393
|
-
<rect
|
|
3394
|
-
x=${pos.x}
|
|
3395
|
-
y=${pos.y}
|
|
3396
|
-
width=${pos.width}
|
|
3397
|
-
height=${pos.height}
|
|
3398
|
-
rx="14"
|
|
3399
|
-
ry="14"
|
|
3400
|
-
fill="var(--bg-surface)"
|
|
3401
|
-
stroke="var(--border)"
|
|
3402
|
-
stroke-width="1.5"
|
|
3576
|
+
<g transform=${`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
|
|
3577
|
+
<rect x="0" y="0" width=${worldBounds.width} height=${worldBounds.height} fill="transparent" />
|
|
3578
|
+
${edges.map(({ source, target, kind }, idx) => {
|
|
3579
|
+
const x1 = source.x + source.width;
|
|
3580
|
+
const y1 = source.y + source.height / 2;
|
|
3581
|
+
const x2 = target.x;
|
|
3582
|
+
const y2 = target.y + target.height / 2;
|
|
3583
|
+
const c1 = x1 + Math.max(40, (x2 - x1) * 0.35);
|
|
3584
|
+
const c2 = x2 - Math.max(30, (x2 - x1) * 0.35);
|
|
3585
|
+
const style = DAG_EDGE_STYLES[kind] || DAG_EDGE_STYLES["depends-on"];
|
|
3586
|
+
return html`
|
|
3587
|
+
<path
|
|
3588
|
+
key=${`edge-${idx}`}
|
|
3589
|
+
d=${`M ${x1} ${y1} C ${c1} ${y1}, ${c2} ${y2}, ${x2} ${y2}`}
|
|
3590
|
+
fill="none"
|
|
3591
|
+
stroke=${style.color}
|
|
3592
|
+
stroke-dasharray=${style.dash || ""}
|
|
3593
|
+
stroke-opacity="0.75"
|
|
3594
|
+
stroke-width="2"
|
|
3595
|
+
marker-end=${`url(#dag-arrow-${graphKey})`}
|
|
3403
3596
|
/>
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3597
|
+
`;
|
|
3598
|
+
})}
|
|
3599
|
+
${sortedNodes.map((node) => {
|
|
3600
|
+
const pos = layout.positions.get(String(node.id));
|
|
3601
|
+
if (!pos) return null;
|
|
3602
|
+
const selected = wireSourceId && String(node.id) === wireSourceId;
|
|
3603
|
+
return html`
|
|
3604
|
+
<g
|
|
3605
|
+
key=${node.id}
|
|
3606
|
+
class=${`dag-node ${selected ? "dag-node-selected" : ""}`}
|
|
3607
|
+
onPointerDown=${(event) => event.stopPropagation()}
|
|
3608
|
+
onClick=${(event) => handleNodeClick(node, event)}
|
|
3609
|
+
style=${{ cursor: allowWiring || node.taskId ? "pointer" : "default" }}
|
|
3610
|
+
>
|
|
3611
|
+
<rect
|
|
3612
|
+
x=${pos.x}
|
|
3613
|
+
y=${pos.y}
|
|
3614
|
+
width=${pos.width}
|
|
3615
|
+
height=${pos.height}
|
|
3616
|
+
rx="14"
|
|
3617
|
+
ry="14"
|
|
3618
|
+
fill="var(--bg-surface)"
|
|
3619
|
+
stroke=${selected ? "var(--accent)" : "var(--border)"}
|
|
3620
|
+
stroke-width=${selected ? "2.2" : "1.5"}
|
|
3621
|
+
/>
|
|
3622
|
+
<text x=${pos.x + 12} y=${pos.y + 24} fill="var(--text-primary)" font-size="13" font-weight="700">
|
|
3623
|
+
${truncate(node.title || "(untitled)", 34)}
|
|
3624
|
+
</text>
|
|
3625
|
+
<text x=${pos.x + 12} y=${pos.y + 44} fill="var(--text-muted)" font-size="11">
|
|
3626
|
+
${truncate(node.taskId || node.id, 38)}
|
|
3627
|
+
</text>
|
|
3628
|
+
<text x=${pos.x + 12} y=${pos.y + 64} fill="var(--accent)" font-size="11">
|
|
3629
|
+
${String(node.status || "todo")}
|
|
3630
|
+
</text>
|
|
3631
|
+
${Number.isFinite(node.order) && html`<text x=${pos.x + pos.width - 16} y=${pos.y + 22} text-anchor="end" fill="var(--text-muted)" font-size="11">#${node.order}</text>`}
|
|
3632
|
+
</g>
|
|
3633
|
+
`;
|
|
3634
|
+
})}
|
|
3635
|
+
</g>
|
|
3419
3636
|
</svg>
|
|
3420
3637
|
</div>
|
|
3421
3638
|
</div>
|
|
@@ -3443,7 +3660,8 @@ export function TasksTab() {
|
|
|
3443
3660
|
const [dagSelectedSprint, setDagSelectedSprint] = useState("all");
|
|
3444
3661
|
const [dagSprintGraph, setDagSprintGraph] = useState(EMPTY_DAG_GRAPH);
|
|
3445
3662
|
const [dagGlobalGraph, setDagGlobalGraph] = useState(EMPTY_DAG_GRAPH);
|
|
3446
|
-
const [
|
|
3663
|
+
const [dagEpicGraph, setDagEpicGraph] = useState(EMPTY_DAG_GRAPH);
|
|
3664
|
+
const [dagSources, setDagSources] = useState({ sprints: "", sprintGraph: "", globalGraph: "", epicDeps: "", tasks: "" });
|
|
3447
3665
|
const [dagSprintOrderMode, setDagSprintOrderMode] = useState("parallel");
|
|
3448
3666
|
const [isCompact, setIsCompact] = useState(() => {
|
|
3449
3667
|
try { return globalThis.matchMedia?.("(max-width: 768px)")?.matches ?? false; }
|
|
@@ -3542,6 +3760,8 @@ export function TasksTab() {
|
|
|
3542
3760
|
|
|
3543
3761
|
const sprintGraphMeta = await fetchFirstAvailableDagPath(sprintGraphCandidates);
|
|
3544
3762
|
const globalGraphMeta = await fetchFirstAvailableDagPath(globalGraphCandidates);
|
|
3763
|
+
const epicDepsMeta = await fetchFirstAvailableDagPath(DAG_EPIC_DEPENDENCY_ENDPOINT_CANDIDATES);
|
|
3764
|
+
const tasksMeta = await fetchFirstAvailableDagPath(["/api/tasks?limit=1000", "/api/tasks?limit=500"]);
|
|
3545
3765
|
|
|
3546
3766
|
const globalSource =
|
|
3547
3767
|
extractGlobalDagPayload(
|
|
@@ -3556,15 +3776,29 @@ export function TasksTab() {
|
|
|
3556
3776
|
);
|
|
3557
3777
|
const nextGlobalGraph = normalizeDagGraph(globalSource, "DAG of DAGs");
|
|
3558
3778
|
|
|
3779
|
+
const allTasksPayload = extractDagPayload(tasksMeta?.payload);
|
|
3780
|
+
const allTasks = Array.isArray(allTasksPayload?.data)
|
|
3781
|
+
? allTasksPayload.data
|
|
3782
|
+
: Array.isArray(allTasksPayload?.tasks)
|
|
3783
|
+
? allTasksPayload.tasks
|
|
3784
|
+
: Array.isArray(allTasksPayload)
|
|
3785
|
+
? allTasksPayload
|
|
3786
|
+
: tasks;
|
|
3787
|
+
const epicDeps = normalizeEpicDependenciesPayload(epicDepsMeta?.payload);
|
|
3788
|
+
const nextEpicGraph = buildEpicDagGraph(allTasks, epicDeps);
|
|
3789
|
+
|
|
3559
3790
|
const sprintMetaEntry = sprintOptions.find((entry) => entry.id === resolvedSprint) || null;
|
|
3560
3791
|
setDagSprintOrderMode(toText(sprintMetaEntry?.executionMode || sprintMetaEntry?.taskOrderMode || sprintMetaEntry?.sprintOrderMode || "parallel", "parallel"));
|
|
3561
3792
|
setDagSprints(sprintOptions);
|
|
3562
3793
|
setDagSprintGraph(nextSprintGraph);
|
|
3563
3794
|
setDagGlobalGraph(nextGlobalGraph);
|
|
3795
|
+
setDagEpicGraph(nextEpicGraph);
|
|
3564
3796
|
setDagSources({
|
|
3565
3797
|
sprints: sprintMeta?.path || "",
|
|
3566
3798
|
sprintGraph: sprintGraphMeta?.path || "",
|
|
3567
3799
|
globalGraph: globalGraphMeta?.path || "",
|
|
3800
|
+
epicDeps: epicDepsMeta?.path || "",
|
|
3801
|
+
tasks: tasksMeta?.path || "",
|
|
3568
3802
|
});
|
|
3569
3803
|
|
|
3570
3804
|
if (resolvedSprint !== dagSelectedSprint) {
|
|
@@ -3573,11 +3807,12 @@ export function TasksTab() {
|
|
|
3573
3807
|
|
|
3574
3808
|
const hasAnyGraphData =
|
|
3575
3809
|
nextSprintGraph.nodes.length > 0 ||
|
|
3576
|
-
nextGlobalGraph.nodes.length > 0
|
|
3810
|
+
nextGlobalGraph.nodes.length > 0 ||
|
|
3811
|
+
nextEpicGraph.nodes.length > 0;
|
|
3577
3812
|
if (!hasAnyGraphData) {
|
|
3578
3813
|
throw new Error("No DAG data was returned from DAG endpoints.");
|
|
3579
3814
|
}
|
|
3580
|
-
}, [dagSelectedSprint]);
|
|
3815
|
+
}, [dagSelectedSprint, tasks]);
|
|
3581
3816
|
|
|
3582
3817
|
useEffect(() => {
|
|
3583
3818
|
if (!isDag) return;
|
|
@@ -3922,6 +4157,43 @@ export function TasksTab() {
|
|
|
3922
4157
|
setDagError("Failed to update sprint execution mode.");
|
|
3923
4158
|
}
|
|
3924
4159
|
}, [dagSelectedSprint, loadDagViews]);
|
|
4160
|
+
const handleCreateDagEdge = useCallback(async ({ sourceNode, targetNode, graphKind }) => {
|
|
4161
|
+
const srcTaskId = toText(sourceNode?.taskId || sourceNode?.id);
|
|
4162
|
+
const dstTaskId = toText(targetNode?.taskId || targetNode?.id);
|
|
4163
|
+
|
|
4164
|
+
if (graphKind === "epic") {
|
|
4165
|
+
const srcEpic = toText(sourceNode?.epicId || sourceNode?.id);
|
|
4166
|
+
const dstEpic = toText(targetNode?.epicId || targetNode?.id);
|
|
4167
|
+
if (!srcEpic || !dstEpic || srcEpic === dstEpic) return;
|
|
4168
|
+
const existing = normalizeDependencyInput(targetNode?.dependencies || []);
|
|
4169
|
+
const dependencies = normalizeDependencyInput([...existing, srcEpic]);
|
|
4170
|
+
await apiFetch("/api/tasks/epic-dependencies", {
|
|
4171
|
+
method: "PUT",
|
|
4172
|
+
body: JSON.stringify({ epicId: dstEpic, dependencies }),
|
|
4173
|
+
});
|
|
4174
|
+
showToast(`Wired epic dependency: ${srcEpic} -> ${dstEpic}`, "success");
|
|
4175
|
+
await loadDagViews();
|
|
4176
|
+
return;
|
|
4177
|
+
}
|
|
4178
|
+
|
|
4179
|
+
if (!srcTaskId || !dstTaskId || srcTaskId === dstTaskId) return;
|
|
4180
|
+
const existing = normalizeDependencyInput(
|
|
4181
|
+
targetNode?.dependencies ||
|
|
4182
|
+
targetNode?.dependencyTaskIds ||
|
|
4183
|
+
[],
|
|
4184
|
+
);
|
|
4185
|
+
const dependencies = normalizeDependencyInput([...existing, srcTaskId]);
|
|
4186
|
+
await apiFetch("/api/tasks/dependencies", {
|
|
4187
|
+
method: "PUT",
|
|
4188
|
+
body: JSON.stringify({
|
|
4189
|
+
taskId: dstTaskId,
|
|
4190
|
+
dependencies,
|
|
4191
|
+
}),
|
|
4192
|
+
});
|
|
4193
|
+
showToast(`Wired dependency: ${srcTaskId} -> ${dstTaskId}`, "success");
|
|
4194
|
+
await loadDagViews();
|
|
4195
|
+
}, [loadDagViews]);
|
|
4196
|
+
|
|
3925
4197
|
const handleSprintChange = useCallback((nextSprint) => {
|
|
3926
4198
|
const sprintId = toText(nextSprint, "all");
|
|
3927
4199
|
if (sprintId === dagSelectedSprint) return;
|
|
@@ -4539,6 +4811,7 @@ export function TasksTab() {
|
|
|
4539
4811
|
<span class="snapshot-view-tag">${iconText(":link: DAG")}</span>
|
|
4540
4812
|
<span class="pill">Sprint nodes: ${dagSprintGraph.nodes.length}</span>
|
|
4541
4813
|
<span class="pill">Global nodes: ${dagGlobalGraph.nodes.length}</span>
|
|
4814
|
+
<span class="pill">Epic nodes: ${dagEpicGraph.nodes.length}</span>
|
|
4542
4815
|
<span class="pill">Global edges: ${dagGlobalGraph.edges.length}</span>
|
|
4543
4816
|
</div>
|
|
4544
4817
|
`}
|
|
@@ -4589,16 +4862,31 @@ export function TasksTab() {
|
|
|
4589
4862
|
title=${dagSprintGraph.title || (dagSelectedSprint === "all" ? "All Sprint DAG" : `Sprint ${dagSelectedSprint} DAG`)}
|
|
4590
4863
|
description=${dagSprintGraph.description || "Task dependency order within the selected sprint."}
|
|
4591
4864
|
graph=${dagSprintGraph}
|
|
4865
|
+
graphKey="sprint"
|
|
4592
4866
|
onOpenTask=${openDetail}
|
|
4867
|
+
onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
|
|
4868
|
+
allowWiring=${true}
|
|
4593
4869
|
emptyMessage="No sprint DAG data available yet."
|
|
4594
4870
|
/>
|
|
4595
4871
|
<${DagGraphSection}
|
|
4596
4872
|
title=${dagGlobalGraph.title || "Global DAG of DAGs"}
|
|
4597
4873
|
description=${dagGlobalGraph.description || "Cross-sprint dependency overview."}
|
|
4598
4874
|
graph=${dagGlobalGraph}
|
|
4875
|
+
graphKey="global"
|
|
4599
4876
|
onOpenTask=${openDetail}
|
|
4877
|
+
onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
|
|
4878
|
+
allowWiring=${true}
|
|
4600
4879
|
emptyMessage="No global DAG data available yet."
|
|
4601
4880
|
/>
|
|
4881
|
+
<${DagGraphSection}
|
|
4882
|
+
title=${dagEpicGraph.title || "Epic Dependency DAG"}
|
|
4883
|
+
description=${dagEpicGraph.description || "Epics and their run prerequisites."}
|
|
4884
|
+
graph=${dagEpicGraph}
|
|
4885
|
+
graphKey="epic"
|
|
4886
|
+
onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "epic" })}
|
|
4887
|
+
allowWiring=${true}
|
|
4888
|
+
emptyMessage="No epic DAG data available yet."
|
|
4889
|
+
/>
|
|
4602
4890
|
</div>
|
|
4603
4891
|
`}
|
|
4604
4892
|
|
|
@@ -5196,6 +5484,8 @@ function CreateTaskModalInline({ onClose }) {
|
|
|
5196
5484
|
|
|
5197
5485
|
|
|
5198
5486
|
|
|
5487
|
+
|
|
5488
|
+
|
|
5199
5489
|
|
|
5200
5490
|
|
|
5201
5491
|
|