bosun 0.40.2 → 0.40.4
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/README.md +4 -0
- package/cli.mjs +127 -69
- package/config/config.mjs +35 -15
- package/desktop/package.json +2 -2
- package/infra/monitor.mjs +52 -16
- package/infra/session-tracker.mjs +1 -0
- package/infra/sync-engine.mjs +6 -1
- package/infra/update-check.mjs +16 -10
- package/kanban/kanban-adapter.mjs +19 -4
- package/kanban/ve-orchestrator.ps1 +25 -0
- package/package.json +1 -1
- package/server/ui-server.mjs +502 -39
- package/task/task-executor.mjs +690 -6
- package/task/task-store.mjs +116 -1
- package/ui/components/kanban-board.js +137 -9
- package/ui/components/shared.js +107 -45
- package/ui/demo-defaults.js +20 -20
- package/ui/demo.html +26 -1
- package/ui/modules/mui.js +600 -397
- package/ui/styles/components.css +43 -3
- package/ui/styles/kanban.css +66 -11
- package/ui/styles.monolith.css +89 -0
- package/ui/tabs/agents.js +194 -20
- package/ui/tabs/tasks.js +673 -162
- package/workflow/workflow-engine.mjs +30 -29
- package/workflow/workflow-nodes.mjs +321 -22
- package/workflow/workflow-templates.mjs +1 -1
- package/workflow-templates/task-batch.mjs +10 -10
- package/workspace/workspace-manager.mjs +25 -0
- package/workspace/worktree-manager.mjs +8 -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,291 @@ 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
|
+
}
|
|
693
|
+
function slugifyPlanningId(value, fallback = "item") {
|
|
694
|
+
const normalized = String(value || "")
|
|
695
|
+
.trim()
|
|
696
|
+
.toLowerCase()
|
|
697
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
698
|
+
.replace(/^-+|-+$/g, "");
|
|
699
|
+
return normalized || fallback;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function normalizeTaskTypeValue(value, fallback = "task") {
|
|
703
|
+
const normalized = toText(value, fallback).toLowerCase();
|
|
704
|
+
return ["epic", "task", "subtask"].includes(normalized) ? normalized : fallback;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function normalizeTaskStatusValue(value) {
|
|
708
|
+
return toText(value).toLowerCase();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function isTerminalTaskStatus(value) {
|
|
712
|
+
return ["done", "completed", "closed", "merged", "cancelled"].includes(normalizeTaskStatusValue(value));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function isQueuedTask(task) {
|
|
716
|
+
const runtime = getTaskRuntimeSnapshot(task);
|
|
717
|
+
return runtime?.state === "queued" || normalizeTaskStatusValue(task?.status) === "queued";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function isBacklogDraftTask(task) {
|
|
721
|
+
const status = normalizeTaskStatusValue(task?.status);
|
|
722
|
+
return status === "draft" || status === "todo" || status === "backlog" || status === "planned" || status === "";
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function isExecutionTask(task) {
|
|
726
|
+
return isActiveStatus(task?.status) || isReviewStatus(task?.status) || isQueuedTask(task);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function filterDagGraphByIds(graph = EMPTY_DAG_GRAPH, allowedIds = null, resolver = (node) => toText(node?.id || node?.taskId)) {
|
|
730
|
+
if (!allowedIds || allowedIds === "all") return graph;
|
|
731
|
+
const allowed = allowedIds instanceof Set ? allowedIds : new Set(Array.isArray(allowedIds) ? allowedIds : []);
|
|
732
|
+
const nodes = (graph?.nodes || []).filter((node) => allowed.has(resolver(node)) || allowed.has(toText(node?.id || node?.taskId)));
|
|
733
|
+
const nodeIds = new Set(nodes.map((node) => toText(node?.id || node?.taskId)).filter(Boolean));
|
|
734
|
+
const edges = (graph?.edges || []).filter((edge) => nodeIds.has(toText(edge?.source || edge?.from)) && nodeIds.has(toText(edge?.target || edge?.to)));
|
|
735
|
+
return { ...graph, nodes, edges };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function buildEpicCatalog(tasks = [], epicDependencies = []) {
|
|
739
|
+
const catalog = new Map();
|
|
740
|
+
const ensureEpic = (epicId) => {
|
|
741
|
+
const id = toText(epicId);
|
|
742
|
+
if (!id) return null;
|
|
743
|
+
if (!catalog.has(id)) {
|
|
744
|
+
catalog.set(id, {
|
|
745
|
+
id,
|
|
746
|
+
label: id,
|
|
747
|
+
taskIds: [],
|
|
748
|
+
dependencies: [],
|
|
749
|
+
completedCount: 0,
|
|
750
|
+
activeCount: 0,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return catalog.get(id);
|
|
754
|
+
};
|
|
755
|
+
for (const task of tasks || []) {
|
|
756
|
+
const epicId = toText(task?.epicId || task?.meta?.epicId);
|
|
757
|
+
if (!epicId) continue;
|
|
758
|
+
const entry = ensureEpic(epicId);
|
|
759
|
+
if (!entry) continue;
|
|
760
|
+
entry.taskIds.push(task.id);
|
|
761
|
+
if (normalizeTaskTypeValue(task?.type) === "epic") {
|
|
762
|
+
entry.label = toText(task?.title, epicId);
|
|
763
|
+
entry.anchorTaskId = task.id;
|
|
764
|
+
}
|
|
765
|
+
if (isTerminalTaskStatus(task?.status)) entry.completedCount += 1;
|
|
766
|
+
if (isExecutionTask(task)) entry.activeCount += 1;
|
|
767
|
+
}
|
|
768
|
+
for (const row of epicDependencies || []) {
|
|
769
|
+
const entry = ensureEpic(row?.epicId);
|
|
770
|
+
if (!entry) continue;
|
|
771
|
+
entry.dependencies = normalizeDependencyInput(row?.dependencies || []);
|
|
772
|
+
}
|
|
773
|
+
return [...catalog.values()]
|
|
774
|
+
.map((entry) => ({
|
|
775
|
+
...entry,
|
|
776
|
+
taskIds: normalizeDependencyInput(entry.taskIds),
|
|
777
|
+
taskCount: normalizeDependencyInput(entry.taskIds).length,
|
|
778
|
+
}))
|
|
779
|
+
.sort((a, b) => String(a.label || a.id).localeCompare(String(b.label || b.id)));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function buildDagPlanningState({ tasks = [], sprintId = "all", sprintOrderMode = "parallel", sprintOptions = [], epicDependencies = [] }) {
|
|
783
|
+
const scopedTasks = (tasks || []).filter((task) => sprintId === "all" ? true : getTaskSprintId(task) === sprintId);
|
|
784
|
+
const allTaskMap = new Map((tasks || []).map((task) => [toText(task?.id), task]));
|
|
785
|
+
const scopedTaskIds = new Set(scopedTasks.map((task) => toText(task?.id)).filter(Boolean));
|
|
786
|
+
const sprintModeMap = new Map((sprintOptions || []).map((sprint) => [sprint.id, toText(sprint.executionMode || sprint.taskOrderMode || 'parallel', 'parallel')]));
|
|
787
|
+
const epicDependencyMap = new Map((epicDependencies || []).map((row) => [toText(row?.epicId), normalizeDependencyInput(row?.dependencies || [])]));
|
|
788
|
+
const tasksByEpic = new Map();
|
|
789
|
+
for (const task of tasks || []) {
|
|
790
|
+
const epicId = toText(task?.epicId || task?.meta?.epicId);
|
|
791
|
+
if (!epicId) continue;
|
|
792
|
+
const list = tasksByEpic.get(epicId) || [];
|
|
793
|
+
list.push(task);
|
|
794
|
+
tasksByEpic.set(epicId, list);
|
|
795
|
+
}
|
|
796
|
+
const isSequentialBlocked = (task) => {
|
|
797
|
+
const taskSprintId = getTaskSprintId(task);
|
|
798
|
+
const mode = sprintModeMap.get(taskSprintId) || sprintOrderMode;
|
|
799
|
+
if (mode !== 'sequential') return false;
|
|
800
|
+
const currentOrder = Number(getTaskSprintOrder(task));
|
|
801
|
+
if (!Number.isFinite(currentOrder) || currentOrder <= 1) return false;
|
|
802
|
+
return scopedTasks.some((candidate) => getTaskSprintId(candidate) === taskSprintId && Number(getTaskSprintOrder(candidate)) < currentOrder && !isTerminalTaskStatus(candidate?.status));
|
|
803
|
+
};
|
|
804
|
+
const isEpicBlocked = (task) => {
|
|
805
|
+
const epicId = toText(task?.epicId || task?.meta?.epicId);
|
|
806
|
+
if (!epicId) return false;
|
|
807
|
+
const requiredEpics = epicDependencyMap.get(epicId) || [];
|
|
808
|
+
if (!requiredEpics.length) return false;
|
|
809
|
+
return requiredEpics.some((requiredEpicId) => {
|
|
810
|
+
const epicTasks = tasksByEpic.get(requiredEpicId) || [];
|
|
811
|
+
if (!epicTasks.length) return true;
|
|
812
|
+
return epicTasks.some((candidate) => !isTerminalTaskStatus(candidate?.status));
|
|
813
|
+
});
|
|
814
|
+
};
|
|
815
|
+
const isDependencyBlocked = (task) => getTaskDependencyIds(task).some((depId) => {
|
|
816
|
+
const dependencyTask = allTaskMap.get(depId);
|
|
817
|
+
return !dependencyTask || !isTerminalTaskStatus(dependencyTask?.status);
|
|
818
|
+
});
|
|
819
|
+
const backlogTaskIds = new Set();
|
|
820
|
+
const executionTaskIds = new Set();
|
|
821
|
+
const readyTaskIds = new Set();
|
|
822
|
+
const sprintIdsByFocus = { all: new Set(), backlog: new Set(), execution: new Set(), ready: new Set() };
|
|
823
|
+
const epicIdsByFocus = { all: new Set(), backlog: new Set(), execution: new Set(), ready: new Set() };
|
|
824
|
+
for (const task of scopedTasks) {
|
|
825
|
+
const taskId = toText(task?.id);
|
|
826
|
+
const taskSprintId = getTaskSprintId(task);
|
|
827
|
+
const epicId = toText(task?.epicId || task?.meta?.epicId);
|
|
828
|
+
sprintIdsByFocus.all.add(taskSprintId || 'unassigned');
|
|
829
|
+
if (epicId) epicIdsByFocus.all.add(epicId);
|
|
830
|
+
if (isBacklogDraftTask(task)) {
|
|
831
|
+
backlogTaskIds.add(taskId);
|
|
832
|
+
sprintIdsByFocus.backlog.add(taskSprintId || 'unassigned');
|
|
833
|
+
if (epicId) epicIdsByFocus.backlog.add(epicId);
|
|
834
|
+
}
|
|
835
|
+
if (isExecutionTask(task)) {
|
|
836
|
+
executionTaskIds.add(taskId);
|
|
837
|
+
sprintIdsByFocus.execution.add(taskSprintId || 'unassigned');
|
|
838
|
+
if (epicId) epicIdsByFocus.execution.add(epicId);
|
|
839
|
+
}
|
|
840
|
+
const ready = !isBacklogDraftTask(task) && !isExecutionTask(task) && !isTerminalTaskStatus(task?.status) && !isDependencyBlocked(task) && !isEpicBlocked(task) && !isSequentialBlocked(task);
|
|
841
|
+
if (ready) {
|
|
842
|
+
readyTaskIds.add(taskId);
|
|
843
|
+
sprintIdsByFocus.ready.add(taskSprintId || 'unassigned');
|
|
844
|
+
if (epicId) epicIdsByFocus.ready.add(epicId);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
scopedTaskIds,
|
|
849
|
+
backlogTaskIds,
|
|
850
|
+
executionTaskIds,
|
|
851
|
+
readyTaskIds,
|
|
852
|
+
sprintIdsByFocus,
|
|
853
|
+
epicIdsByFocus,
|
|
854
|
+
epicCatalog: buildEpicCatalog(tasks, epicDependencies),
|
|
855
|
+
counts: {
|
|
856
|
+
all: scopedTaskIds.size,
|
|
857
|
+
backlog: backlogTaskIds.size,
|
|
858
|
+
execution: executionTaskIds.size,
|
|
859
|
+
ready: readyTaskIds.size,
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
565
864
|
function extractGlobalDagPayload(...sources) {
|
|
566
865
|
for (const source of sources) {
|
|
567
866
|
const payload = extractDagPayload(source);
|
|
@@ -802,7 +1101,9 @@ async function applyTaskLifecycleTransition(task, requestedStatus) {
|
|
|
802
1101
|
|
|
803
1102
|
const wantsDraft = decision.nextStatus === "draft";
|
|
804
1103
|
const prevTasks = cloneValue(tasksData.value);
|
|
805
|
-
const optimisticStatus = decision.action === "start"
|
|
1104
|
+
const optimisticStatus = decision.action === "start"
|
|
1105
|
+
? String(task?.status || "todo")
|
|
1106
|
+
: decision.nextStatus;
|
|
806
1107
|
let apiResult = null;
|
|
807
1108
|
|
|
808
1109
|
await runOptimistic(
|
|
@@ -1435,6 +1736,18 @@ function isReviewStatus(s) {
|
|
|
1435
1736
|
return ["inreview", "review", "pr-open", "pr-review"].includes(String(s || ""));
|
|
1436
1737
|
}
|
|
1437
1738
|
|
|
1739
|
+
function getTaskRuntimeSnapshot(task) {
|
|
1740
|
+
return task?.runtimeSnapshot || task?.meta?.runtimeSnapshot || null;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function hasLiveExecutionEvidence(task) {
|
|
1744
|
+
const runtime = getTaskRuntimeSnapshot(task);
|
|
1745
|
+
if (runtime?.isLive === true) return true;
|
|
1746
|
+
if (runtime?.state === "running") return true;
|
|
1747
|
+
if (runtime?.slot?.taskId) return true;
|
|
1748
|
+
return false;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1438
1751
|
async function reactivateTaskSession(taskId, options = {}) {
|
|
1439
1752
|
const normalizedTaskId = String(taskId || "").trim();
|
|
1440
1753
|
if (!normalizedTaskId) return false;
|
|
@@ -1462,41 +1775,20 @@ async function reactivateTaskSession(taskId, options = {}) {
|
|
|
1462
1775
|
showToast("Agent session reactivated", "success");
|
|
1463
1776
|
}
|
|
1464
1777
|
|
|
1778
|
+
const nextStatus = String(res?.data?.status || (res?.queued ? "queued" : "inprogress")).trim() || "todo";
|
|
1465
1779
|
tasksData.value = (tasksData.value || []).map((t) =>
|
|
1466
1780
|
String(t?.id || "").trim() === normalizedTaskId
|
|
1467
|
-
? {
|
|
1781
|
+
? {
|
|
1782
|
+
...t,
|
|
1783
|
+
...(res?.data || {}),
|
|
1784
|
+
status: nextStatus,
|
|
1785
|
+
}
|
|
1468
1786
|
: t,
|
|
1469
1787
|
);
|
|
1470
1788
|
scheduleRefresh(150);
|
|
1471
1789
|
return true;
|
|
1472
1790
|
}
|
|
1473
1791
|
|
|
1474
|
-
/* ─── Derive agent steps from task title/description ─── */
|
|
1475
|
-
function deriveSteps(task) {
|
|
1476
|
-
const t = String(task?.title || "").toLowerCase();
|
|
1477
|
-
const steps = [];
|
|
1478
|
-
if (/feat|add|implement|build|create/.test(t)) steps.push({ label: "Understand requirements & read context" });
|
|
1479
|
-
if (/fix|bug|patch|resolve|repair/.test(t)) steps.push({ label: "Reproduce and diagnose issue" });
|
|
1480
|
-
if (/refactor|restructure|cleanup|reorganize/.test(t)) steps.push({ label: "Map current code structure" });
|
|
1481
|
-
if (/test|spec|coverage/.test(t)) steps.push({ label: "Write test cases" });
|
|
1482
|
-
if (/docs|document|readme/.test(t)) steps.push({ label: "Draft documentation content" });
|
|
1483
|
-
steps.push({ label: "Write implementation" });
|
|
1484
|
-
if (!/docs|readme/.test(t)) steps.push({ label: "Run tests & fix issues" });
|
|
1485
|
-
steps.push({ label: "Commit changes" });
|
|
1486
|
-
steps.push({ label: "Handoff PR lifecycle to Bosun" });
|
|
1487
|
-
return steps;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
/* ─── Estimate step progress from timing ─── */
|
|
1491
|
-
function estimateStep(task, steps) {
|
|
1492
|
-
const elapsed = task?.updated ? (Date.now() - new Date(task.updated).getTime()) : 0;
|
|
1493
|
-
const totalDur = task?.created ? (Date.now() - new Date(task.created).getTime()) : 60000;
|
|
1494
|
-
const pct = Math.min(0.85, totalDur > 0 ? (elapsed / totalDur) : 0);
|
|
1495
|
-
// Bias: show 40-75% through to look realistic
|
|
1496
|
-
const biasedPct = 0.35 + pct * 0.4;
|
|
1497
|
-
return Math.max(0, Math.min(steps.length - 1, Math.floor(biasedPct * steps.length)));
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
1792
|
/* ─── TaskProgressModal — live view for in-progress tasks ─── */
|
|
1501
1793
|
export function TaskProgressModal({ task, onClose }) {
|
|
1502
1794
|
const [liveTask, setLiveTask] = useState(task);
|
|
@@ -1544,8 +1836,7 @@ export function TaskProgressModal({ task, onClose }) {
|
|
|
1544
1836
|
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
1545
1837
|
}, [logs]);
|
|
1546
1838
|
|
|
1547
|
-
const
|
|
1548
|
-
const currentStep = useMemo(() => estimateStep(liveTask || task, steps), [liveTask?.updated]);
|
|
1839
|
+
const runtime = getTaskRuntimeSnapshot(liveTask || task);
|
|
1549
1840
|
|
|
1550
1841
|
const healthScore = health?.currentHealthScore ?? health?.averageHealthScore ?? null;
|
|
1551
1842
|
const healthColor =
|
|
@@ -1557,6 +1848,10 @@ export function TaskProgressModal({ task, onClose }) {
|
|
|
1557
1848
|
const startedRelative = liveTask?.created ? formatRelative(liveTask.created) : "—";
|
|
1558
1849
|
const agentLabel = liveTask?.assignee || task.assignee || "Agent";
|
|
1559
1850
|
const branchLabel = liveTask?.branch || task.branch || "—";
|
|
1851
|
+
const runtimeState = runtime?.state || "pending";
|
|
1852
|
+
const runtimeLabel = runtime?.statusLabel || "Live execution";
|
|
1853
|
+
const activeSlots = Number(runtime?.executor?.activeSlots || 0);
|
|
1854
|
+
const maxParallel = Number(runtime?.executor?.maxParallel || 0);
|
|
1560
1855
|
|
|
1561
1856
|
const handleCancel = async () => {
|
|
1562
1857
|
const ok = await showConfirm("Cancel this task?");
|
|
@@ -1608,9 +1903,9 @@ export function TaskProgressModal({ task, onClose }) {
|
|
|
1608
1903
|
<div class="tp-hero">
|
|
1609
1904
|
<div class="tp-pulse-dot"></div>
|
|
1610
1905
|
<div class="tp-hero-title">
|
|
1611
|
-
<div class="tp-hero-status-label">${iconText(
|
|
1906
|
+
<div class="tp-hero-status-label">${iconText(`:zap: ${runtimeLabel}`)}</div>
|
|
1612
1907
|
</div>
|
|
1613
|
-
<${Badge} status="inprogress" text
|
|
1908
|
+
<${Badge} status="inprogress" text=${runtimeState} />
|
|
1614
1909
|
</div>
|
|
1615
1910
|
|
|
1616
1911
|
|
|
@@ -3229,8 +3524,19 @@ function DagGraphSection({
|
|
|
3229
3524
|
description = "",
|
|
3230
3525
|
graph = EMPTY_DAG_GRAPH,
|
|
3231
3526
|
onOpenTask,
|
|
3527
|
+
onCreateEdge,
|
|
3528
|
+
allowWiring = false,
|
|
3529
|
+
graphKey = "dag",
|
|
3232
3530
|
emptyMessage = "No DAG nodes available for this view yet.",
|
|
3531
|
+
highlightNodeIds = null,
|
|
3233
3532
|
}) {
|
|
3533
|
+
const stageRef = useRef(null);
|
|
3534
|
+
const [zoom, setZoom] = useState(1);
|
|
3535
|
+
const [pan, setPan] = useState({ x: 24, y: 24 });
|
|
3536
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
3537
|
+
const [wireSourceId, setWireSourceId] = useState("");
|
|
3538
|
+
const [wiringBusy, setWiringBusy] = useState(false);
|
|
3539
|
+
|
|
3234
3540
|
const sortedNodes = useMemo(() => {
|
|
3235
3541
|
const nodes = [...(graph?.nodes || [])];
|
|
3236
3542
|
nodes.sort((a, b) => {
|
|
@@ -3258,22 +3564,20 @@ function DagGraphSection({
|
|
|
3258
3564
|
list.push(node);
|
|
3259
3565
|
map.set(key, list);
|
|
3260
3566
|
}
|
|
3261
|
-
if (!map.size && sortedNodes.length)
|
|
3262
|
-
map.set(0, [...sortedNodes]);
|
|
3263
|
-
}
|
|
3567
|
+
if (!map.size && sortedNodes.length) map.set(0, [...sortedNodes]);
|
|
3264
3568
|
return [...map.entries()].sort((a, b) => a[0] - b[0]);
|
|
3265
3569
|
}, [sortedNodes]);
|
|
3266
3570
|
|
|
3267
3571
|
const layout = useMemo(() => {
|
|
3268
|
-
const nodeWidth =
|
|
3269
|
-
const nodeHeight =
|
|
3270
|
-
const colGap =
|
|
3572
|
+
const nodeWidth = 250;
|
|
3573
|
+
const nodeHeight = 92;
|
|
3574
|
+
const colGap = 130;
|
|
3271
3575
|
const rowGap = 34;
|
|
3272
|
-
const marginX =
|
|
3273
|
-
const marginY =
|
|
3576
|
+
const marginX = 40;
|
|
3577
|
+
const marginY = 28;
|
|
3274
3578
|
|
|
3275
3579
|
const positions = new Map();
|
|
3276
|
-
let maxRows =
|
|
3580
|
+
let maxRows = 1;
|
|
3277
3581
|
levels.forEach(([, nodes], colIdx) => {
|
|
3278
3582
|
maxRows = Math.max(maxRows, nodes.length);
|
|
3279
3583
|
nodes.forEach((node, rowIdx) => {
|
|
@@ -3283,8 +3587,8 @@ function DagGraphSection({
|
|
|
3283
3587
|
});
|
|
3284
3588
|
});
|
|
3285
3589
|
|
|
3286
|
-
const totalWidth = Math.max(
|
|
3287
|
-
const totalHeight = Math.max(
|
|
3590
|
+
const totalWidth = Math.max(720, marginX * 2 + Math.max(1, levels.length) * nodeWidth + Math.max(0, levels.length - 1) * colGap);
|
|
3591
|
+
const totalHeight = Math.max(360, marginY * 2 + maxRows * nodeHeight + Math.max(0, maxRows - 1) * rowGap);
|
|
3288
3592
|
return { positions, totalWidth, totalHeight };
|
|
3289
3593
|
}, [levels]);
|
|
3290
3594
|
|
|
@@ -3304,24 +3608,109 @@ function DagGraphSection({
|
|
|
3304
3608
|
.filter(Boolean);
|
|
3305
3609
|
}, [graph?.edges, layout.positions]);
|
|
3306
3610
|
|
|
3307
|
-
const
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3611
|
+
const worldBounds = useMemo(() => ({ width: layout.totalWidth, height: layout.totalHeight }), [layout]);
|
|
3612
|
+
|
|
3613
|
+
const fitToView = useCallback(() => {
|
|
3614
|
+
const el = stageRef.current;
|
|
3615
|
+
if (!el) return;
|
|
3616
|
+
const rect = el.getBoundingClientRect();
|
|
3617
|
+
const availableWidth = Math.max(320, rect.width - 24);
|
|
3618
|
+
const availableHeight = Math.max(240, rect.height - 24);
|
|
3619
|
+
const scaleX = availableWidth / Math.max(1, worldBounds.width);
|
|
3620
|
+
const scaleY = availableHeight / Math.max(1, worldBounds.height);
|
|
3621
|
+
const nextZoom = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, Math.min(scaleX, scaleY)));
|
|
3622
|
+
const nextPanX = (rect.width - worldBounds.width * nextZoom) / 2;
|
|
3623
|
+
const nextPanY = (rect.height - worldBounds.height * nextZoom) / 2;
|
|
3624
|
+
setZoom(nextZoom);
|
|
3625
|
+
setPan({ x: nextPanX, y: nextPanY });
|
|
3626
|
+
}, [worldBounds.width, worldBounds.height]);
|
|
3315
3627
|
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3628
|
+
useEffect(() => {
|
|
3629
|
+
fitToView();
|
|
3630
|
+
}, [fitToView, graphKey, sortedNodes.length, edges.length]);
|
|
3631
|
+
|
|
3632
|
+
const applyZoomAtPoint = useCallback((nextZoom, clientX, clientY) => {
|
|
3633
|
+
const el = stageRef.current;
|
|
3634
|
+
if (!el) return;
|
|
3635
|
+
const rect = el.getBoundingClientRect();
|
|
3636
|
+
const clamped = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, nextZoom));
|
|
3637
|
+
const localX = clientX - rect.left;
|
|
3638
|
+
const localY = clientY - rect.top;
|
|
3639
|
+
const worldX = (localX - pan.x) / zoom;
|
|
3640
|
+
const worldY = (localY - pan.y) / zoom;
|
|
3641
|
+
setZoom(clamped);
|
|
3642
|
+
setPan({ x: localX - worldX * clamped, y: localY - worldY * clamped });
|
|
3643
|
+
}, [pan.x, pan.y, zoom]);
|
|
3644
|
+
|
|
3645
|
+
const handleWheel = useCallback((event) => {
|
|
3646
|
+
event.preventDefault();
|
|
3647
|
+
const delta = event.deltaY < 0 ? 1.1 : 0.9;
|
|
3648
|
+
applyZoomAtPoint(zoom * delta, event.clientX, event.clientY);
|
|
3649
|
+
}, [applyZoomAtPoint, zoom]);
|
|
3650
|
+
|
|
3651
|
+
const handlePanStart = useCallback((event) => {
|
|
3652
|
+
if (event.button !== 0) return;
|
|
3653
|
+
const targetEl = event.target;
|
|
3654
|
+
if (targetEl?.closest?.(".dag-node")) return;
|
|
3655
|
+
event.preventDefault();
|
|
3656
|
+
const start = { x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y };
|
|
3657
|
+
setIsPanning(true);
|
|
3658
|
+
const onMove = (moveEvent) => {
|
|
3659
|
+
setPan({ x: start.panX + (moveEvent.clientX - start.x), y: start.panY + (moveEvent.clientY - start.y) });
|
|
3660
|
+
};
|
|
3661
|
+
const onUp = () => {
|
|
3662
|
+
setIsPanning(false);
|
|
3663
|
+
window.removeEventListener("pointermove", onMove);
|
|
3664
|
+
window.removeEventListener("pointerup", onUp);
|
|
3665
|
+
};
|
|
3666
|
+
window.addEventListener("pointermove", onMove);
|
|
3667
|
+
window.addEventListener("pointerup", onUp);
|
|
3668
|
+
}, [pan.x, pan.y]);
|
|
3669
|
+
|
|
3670
|
+
const nodeById = useMemo(() => {
|
|
3671
|
+
const map = new Map();
|
|
3672
|
+
for (const node of sortedNodes) map.set(String(node.id), node);
|
|
3673
|
+
return map;
|
|
3323
3674
|
}, [sortedNodes]);
|
|
3324
3675
|
|
|
3676
|
+
const highlightedIds = useMemo(() => {
|
|
3677
|
+
if (!highlightNodeIds || highlightNodeIds === "all") return new Set();
|
|
3678
|
+
return highlightNodeIds instanceof Set
|
|
3679
|
+
? highlightNodeIds
|
|
3680
|
+
: new Set(Array.isArray(highlightNodeIds) ? highlightNodeIds : []);
|
|
3681
|
+
}, [highlightNodeIds]);
|
|
3682
|
+
|
|
3683
|
+
const handleNodeClick = useCallback(async (node, event) => {
|
|
3684
|
+
event?.stopPropagation?.();
|
|
3685
|
+
if (allowWiring && typeof onCreateEdge === "function") {
|
|
3686
|
+
const id = String(node?.id || "");
|
|
3687
|
+
if (!id || wiringBusy) return;
|
|
3688
|
+
if (!wireSourceId) {
|
|
3689
|
+
setWireSourceId(id);
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
if (wireSourceId === id) {
|
|
3693
|
+
setWireSourceId("");
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
const sourceNode = nodeById.get(wireSourceId) || null;
|
|
3697
|
+
const targetNode = nodeById.get(id) || null;
|
|
3698
|
+
if (!sourceNode || !targetNode) {
|
|
3699
|
+
setWireSourceId("");
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
setWiringBusy(true);
|
|
3703
|
+
try {
|
|
3704
|
+
await onCreateEdge({ sourceNode, targetNode });
|
|
3705
|
+
} finally {
|
|
3706
|
+
setWireSourceId("");
|
|
3707
|
+
setWiringBusy(false);
|
|
3708
|
+
}
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
if (node?.taskId) onOpenTask?.(node.taskId);
|
|
3712
|
+
}, [allowWiring, onCreateEdge, onOpenTask, wireSourceId, nodeById, wiringBusy]);
|
|
3713
|
+
|
|
3325
3714
|
if (!sortedNodes.length) {
|
|
3326
3715
|
return html`
|
|
3327
3716
|
<${Paper} variant="outlined" style=${{ padding: "12px", marginBottom: "10px" }}>
|
|
@@ -3332,90 +3721,97 @@ function DagGraphSection({
|
|
|
3332
3721
|
|
|
3333
3722
|
return html`
|
|
3334
3723
|
<div class="tasks-dag-section">
|
|
3335
|
-
<div class="
|
|
3724
|
+
<div class="task-dag-header-row">
|
|
3336
3725
|
<div>
|
|
3337
3726
|
<div style=${{ fontWeight: "700" }}>${title || "Task DAG"}</div>
|
|
3338
|
-
${description
|
|
3727
|
+
${description ? html`<div class="meta-text">${description}</div>` : null}
|
|
3728
|
+
<div class="meta-text">Drag to pan · wheel to zoom · click node to ${allowWiring ? "wire edges" : "open task"}.</div>
|
|
3339
3729
|
</div>
|
|
3340
|
-
<div class="
|
|
3341
|
-
<${
|
|
3342
|
-
<${
|
|
3343
|
-
<${
|
|
3344
|
-
<${
|
|
3345
|
-
|
|
3730
|
+
<div class="task-dag-controls">
|
|
3731
|
+
<${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.max(DAG_MIN_ZOOM, z * 0.9))}>-</${Button}>
|
|
3732
|
+
<${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.min(DAG_MAX_ZOOM, z * 1.1))}>+</${Button}>
|
|
3733
|
+
<${Button} size="small" variant="outlined" onClick=${fitToView}>Fit</${Button}>
|
|
3734
|
+
<${Button} size="small" variant="text" onClick=${() => { setZoom(1); setPan({ x: 24, y: 24 }); }}>Reset</${Button}>
|
|
3735
|
+
<span class="task-dag-zoom-pill">${Math.round(zoom * 100)}%</span>
|
|
3736
|
+
${allowWiring
|
|
3737
|
+
? html`<span class="task-dag-wire-pill">${wireSourceId ? `Source: ${wireSourceId}` : wiringBusy ? "Saving edge…" : "Wiring: click source then target"}</span>`
|
|
3738
|
+
: null}
|
|
3346
3739
|
</div>
|
|
3347
3740
|
</div>
|
|
3348
3741
|
<div class="task-dag-legend">
|
|
3349
3742
|
<span class="task-dag-legend-item"><span class="task-dag-legend-line" style=${{ background: "var(--accent)" }}></span>depends-on</span>
|
|
3350
3743
|
<span class="task-dag-legend-item"><span class="task-dag-legend-line task-dag-legend-line-dashed"></span>sequential</span>
|
|
3351
3744
|
<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
3745
|
</div>
|
|
3354
|
-
<div class
|
|
3355
|
-
<svg class="task-dag-canvas"
|
|
3746
|
+
<div class=${`task-dag-canvas-wrap ${isPanning ? "is-panning" : ""}`} ref=${stageRef} onWheel=${handleWheel} onPointerDown=${handlePanStart}>
|
|
3747
|
+
<svg class="task-dag-canvas" role="img" aria-label="Task dependency graph">
|
|
3356
3748
|
<defs>
|
|
3357
|
-
<marker id
|
|
3749
|
+
<marker id=${`dag-arrow-${graphKey}`} markerWidth="10" markerHeight="8" refX="10" refY="4" orient="auto">
|
|
3358
3750
|
<path d="M0,0 L10,4 L0,8 z" fill="var(--accent)" />
|
|
3359
3751
|
</marker>
|
|
3360
3752
|
</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"
|
|
3753
|
+
<g transform=${`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
|
|
3754
|
+
<rect x="0" y="0" width=${worldBounds.width} height=${worldBounds.height} fill="transparent" />
|
|
3755
|
+
${edges.map(({ source, target, kind }, idx) => {
|
|
3756
|
+
const x1 = source.x + source.width;
|
|
3757
|
+
const y1 = source.y + source.height / 2;
|
|
3758
|
+
const x2 = target.x;
|
|
3759
|
+
const y2 = target.y + target.height / 2;
|
|
3760
|
+
const c1 = x1 + Math.max(40, (x2 - x1) * 0.35);
|
|
3761
|
+
const c2 = x2 - Math.max(30, (x2 - x1) * 0.35);
|
|
3762
|
+
const style = DAG_EDGE_STYLES[kind] || DAG_EDGE_STYLES["depends-on"];
|
|
3763
|
+
return html`
|
|
3764
|
+
<path
|
|
3765
|
+
key=${`edge-${idx}`}
|
|
3766
|
+
d=${`M ${x1} ${y1} C ${c1} ${y1}, ${c2} ${y2}, ${x2} ${y2}`}
|
|
3767
|
+
fill="none"
|
|
3768
|
+
stroke=${style.color}
|
|
3769
|
+
stroke-dasharray=${style.dash || ""}
|
|
3770
|
+
stroke-opacity="0.75"
|
|
3771
|
+
stroke-width="2"
|
|
3772
|
+
marker-end=${`url(#dag-arrow-${graphKey})`}
|
|
3403
3773
|
/>
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3774
|
+
`;
|
|
3775
|
+
})}
|
|
3776
|
+
${sortedNodes.map((node) => {
|
|
3777
|
+
const pos = layout.positions.get(String(node.id));
|
|
3778
|
+
if (!pos) return null;
|
|
3779
|
+
const selected = wireSourceId && String(node.id) === wireSourceId;
|
|
3780
|
+
const highlighted = highlightedIds.has(String(node.id)) || highlightedIds.has(String(node.taskId || ""));
|
|
3781
|
+
return html`
|
|
3782
|
+
<g
|
|
3783
|
+
key=${node.id}
|
|
3784
|
+
class=${`dag-node ${selected ? "dag-node-selected" : ""} ${highlighted ? "dag-node-highlighted" : ""}`}
|
|
3785
|
+
onPointerDown=${(event) => event.stopPropagation()}
|
|
3786
|
+
onClick=${(event) => handleNodeClick(node, event)}
|
|
3787
|
+
style=${{ cursor: allowWiring || node.taskId ? "pointer" : "default" }}
|
|
3788
|
+
>
|
|
3789
|
+
<rect
|
|
3790
|
+
x=${pos.x}
|
|
3791
|
+
y=${pos.y}
|
|
3792
|
+
width=${pos.width}
|
|
3793
|
+
height=${pos.height}
|
|
3794
|
+
rx="14"
|
|
3795
|
+
ry="14"
|
|
3796
|
+
fill="var(--bg-surface)"
|
|
3797
|
+
stroke=${selected ? "var(--accent)" : highlighted ? "var(--color-done)" : "var(--border)"}
|
|
3798
|
+
stroke-width=${selected || highlighted ? "2.2" : "1.5"}
|
|
3799
|
+
/>
|
|
3800
|
+
<text x=${pos.x + 12} y=${pos.y + 24} fill="var(--text-primary)" font-size="13" font-weight="700">
|
|
3801
|
+
${truncate(node.title || "(untitled)", 34)}
|
|
3802
|
+
</text>
|
|
3803
|
+
<text x=${pos.x + 12} y=${pos.y + 44} fill="var(--text-muted)" font-size="11">
|
|
3804
|
+
${truncate(node.taskId || node.id, 38)}
|
|
3805
|
+
</text>
|
|
3806
|
+
<text x=${pos.x + 12} y=${pos.y + 64} fill="var(--accent)" font-size="11">
|
|
3807
|
+
${String(node.status || "todo")}
|
|
3808
|
+
</text>
|
|
3809
|
+
${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>`}
|
|
3810
|
+
${highlighted && html`<text x=${pos.x + pos.width - 12} y=${pos.y + pos.height - 12} text-anchor="end" fill="var(--color-done)" font-size="11" font-weight="700">Ready</text>`}
|
|
3811
|
+
</g>
|
|
3812
|
+
`;
|
|
3813
|
+
})}
|
|
3814
|
+
</g>
|
|
3419
3815
|
</svg>
|
|
3420
3816
|
</div>
|
|
3421
3817
|
</div>
|
|
@@ -3443,8 +3839,14 @@ export function TasksTab() {
|
|
|
3443
3839
|
const [dagSelectedSprint, setDagSelectedSprint] = useState("all");
|
|
3444
3840
|
const [dagSprintGraph, setDagSprintGraph] = useState(EMPTY_DAG_GRAPH);
|
|
3445
3841
|
const [dagGlobalGraph, setDagGlobalGraph] = useState(EMPTY_DAG_GRAPH);
|
|
3446
|
-
const [
|
|
3842
|
+
const [dagEpicGraph, setDagEpicGraph] = useState(EMPTY_DAG_GRAPH);
|
|
3843
|
+
const [dagSources, setDagSources] = useState({ sprints: "", sprintGraph: "", globalGraph: "", epicDeps: "", tasks: "" });
|
|
3447
3844
|
const [dagSprintOrderMode, setDagSprintOrderMode] = useState("parallel");
|
|
3845
|
+
const [dagAllTasks, setDagAllTasks] = useState([]);
|
|
3846
|
+
const [dagEpicDependencies, setDagEpicDependencies] = useState([]);
|
|
3847
|
+
const [dagFocusMode, setDagFocusMode] = useState("all");
|
|
3848
|
+
const [showCreateSprint, setShowCreateSprint] = useState(false);
|
|
3849
|
+
const [createSeed, setCreateSeed] = useState(null);
|
|
3448
3850
|
const [isCompact, setIsCompact] = useState(() => {
|
|
3449
3851
|
try { return globalThis.matchMedia?.("(max-width: 768px)")?.matches ?? false; }
|
|
3450
3852
|
catch { return false; }
|
|
@@ -3512,6 +3914,21 @@ export function TasksTab() {
|
|
|
3512
3914
|
const hasMoreKanbanPages = isKanban && page + 1 < totalPages;
|
|
3513
3915
|
const boardColumnTotals = tasksStatusCounts?.value || { draft: 0, backlog: 0, inProgress: 0, inReview: 0, done: 0 };
|
|
3514
3916
|
const boardTotalTasks = Number(tasksTotal?.value || 0);
|
|
3917
|
+
const dagTaskCatalog = dagAllTasks.length ? dagAllTasks : tasks;
|
|
3918
|
+
const dagPlanningState = useMemo(() => buildDagPlanningState({
|
|
3919
|
+
tasks: dagTaskCatalog,
|
|
3920
|
+
sprintId: dagSelectedSprint,
|
|
3921
|
+
sprintOrderMode: dagSprintOrderMode,
|
|
3922
|
+
sprintOptions: dagSprints,
|
|
3923
|
+
epicDependencies: dagEpicDependencies,
|
|
3924
|
+
}), [dagAllTasks, dagEpicDependencies, dagSelectedSprint, dagSprintOrderMode, dagSprints, tasks]);
|
|
3925
|
+
const dagEpicCatalog = dagPlanningState.epicCatalog;
|
|
3926
|
+
const dagFocusOptions = [
|
|
3927
|
+
{ id: "all", label: "All structure", count: dagPlanningState.counts.all },
|
|
3928
|
+
{ id: "backlog", label: "Backlog & draft", count: dagPlanningState.counts.backlog },
|
|
3929
|
+
{ id: "execution", label: "Running & review", count: dagPlanningState.counts.execution },
|
|
3930
|
+
{ id: "ready", label: "Ready next", count: dagPlanningState.counts.ready },
|
|
3931
|
+
];
|
|
3515
3932
|
|
|
3516
3933
|
const loadMoreKanbanTasks = useCallback(async () => {
|
|
3517
3934
|
if (!isKanban || kanbanLoadingMore || isSearching) return;
|
|
@@ -3542,6 +3959,8 @@ export function TasksTab() {
|
|
|
3542
3959
|
|
|
3543
3960
|
const sprintGraphMeta = await fetchFirstAvailableDagPath(sprintGraphCandidates);
|
|
3544
3961
|
const globalGraphMeta = await fetchFirstAvailableDagPath(globalGraphCandidates);
|
|
3962
|
+
const epicDepsMeta = await fetchFirstAvailableDagPath(DAG_EPIC_DEPENDENCY_ENDPOINT_CANDIDATES);
|
|
3963
|
+
const tasksMeta = await fetchFirstAvailableDagPath(["/api/tasks?limit=1000", "/api/tasks?limit=500"]);
|
|
3545
3964
|
|
|
3546
3965
|
const globalSource =
|
|
3547
3966
|
extractGlobalDagPayload(
|
|
@@ -3556,15 +3975,31 @@ export function TasksTab() {
|
|
|
3556
3975
|
);
|
|
3557
3976
|
const nextGlobalGraph = normalizeDagGraph(globalSource, "DAG of DAGs");
|
|
3558
3977
|
|
|
3978
|
+
const allTasksPayload = extractDagPayload(tasksMeta?.payload);
|
|
3979
|
+
const allTasks = Array.isArray(allTasksPayload?.data)
|
|
3980
|
+
? allTasksPayload.data
|
|
3981
|
+
: Array.isArray(allTasksPayload?.tasks)
|
|
3982
|
+
? allTasksPayload.tasks
|
|
3983
|
+
: Array.isArray(allTasksPayload)
|
|
3984
|
+
? allTasksPayload
|
|
3985
|
+
: tasks;
|
|
3986
|
+
const epicDeps = normalizeEpicDependenciesPayload(epicDepsMeta?.payload);
|
|
3987
|
+
const nextEpicGraph = buildEpicDagGraph(allTasks, epicDeps);
|
|
3988
|
+
setDagAllTasks(allTasks);
|
|
3989
|
+
setDagEpicDependencies(epicDeps);
|
|
3990
|
+
|
|
3559
3991
|
const sprintMetaEntry = sprintOptions.find((entry) => entry.id === resolvedSprint) || null;
|
|
3560
3992
|
setDagSprintOrderMode(toText(sprintMetaEntry?.executionMode || sprintMetaEntry?.taskOrderMode || sprintMetaEntry?.sprintOrderMode || "parallel", "parallel"));
|
|
3561
3993
|
setDagSprints(sprintOptions);
|
|
3562
3994
|
setDagSprintGraph(nextSprintGraph);
|
|
3563
3995
|
setDagGlobalGraph(nextGlobalGraph);
|
|
3996
|
+
setDagEpicGraph(nextEpicGraph);
|
|
3564
3997
|
setDagSources({
|
|
3565
3998
|
sprints: sprintMeta?.path || "",
|
|
3566
3999
|
sprintGraph: sprintGraphMeta?.path || "",
|
|
3567
4000
|
globalGraph: globalGraphMeta?.path || "",
|
|
4001
|
+
epicDeps: epicDepsMeta?.path || "",
|
|
4002
|
+
tasks: tasksMeta?.path || "",
|
|
3568
4003
|
});
|
|
3569
4004
|
|
|
3570
4005
|
if (resolvedSprint !== dagSelectedSprint) {
|
|
@@ -3573,11 +4008,12 @@ export function TasksTab() {
|
|
|
3573
4008
|
|
|
3574
4009
|
const hasAnyGraphData =
|
|
3575
4010
|
nextSprintGraph.nodes.length > 0 ||
|
|
3576
|
-
nextGlobalGraph.nodes.length > 0
|
|
4011
|
+
nextGlobalGraph.nodes.length > 0 ||
|
|
4012
|
+
nextEpicGraph.nodes.length > 0;
|
|
3577
4013
|
if (!hasAnyGraphData) {
|
|
3578
4014
|
throw new Error("No DAG data was returned from DAG endpoints.");
|
|
3579
4015
|
}
|
|
3580
|
-
}, [dagSelectedSprint]);
|
|
4016
|
+
}, [dagSelectedSprint, tasks]);
|
|
3581
4017
|
|
|
3582
4018
|
useEffect(() => {
|
|
3583
4019
|
if (!isDag) return;
|
|
@@ -3793,6 +4229,32 @@ export function TasksTab() {
|
|
|
3793
4229
|
});
|
|
3794
4230
|
}, [visible, listSortCol, listSortDir]);
|
|
3795
4231
|
|
|
4232
|
+
const dagTaskFocusIds = useMemo(() => {
|
|
4233
|
+
if (dagFocusMode === "backlog") return dagPlanningState.backlogTaskIds;
|
|
4234
|
+
if (dagFocusMode === "ready") return dagPlanningState.readyTaskIds;
|
|
4235
|
+
if (dagFocusMode === "execution") return new Set([...dagPlanningState.executionTaskIds, ...dagPlanningState.readyTaskIds]);
|
|
4236
|
+
return "all";
|
|
4237
|
+
}, [dagFocusMode, dagPlanningState]);
|
|
4238
|
+
|
|
4239
|
+
const dagSprintFocusIds = useMemo(() => {
|
|
4240
|
+
if (dagFocusMode === "backlog") return dagPlanningState.sprintIdsByFocus.backlog;
|
|
4241
|
+
if (dagFocusMode === "ready") return dagPlanningState.sprintIdsByFocus.ready;
|
|
4242
|
+
if (dagFocusMode === "execution") return new Set([...dagPlanningState.sprintIdsByFocus.execution, ...dagPlanningState.sprintIdsByFocus.ready]);
|
|
4243
|
+
return "all";
|
|
4244
|
+
}, [dagFocusMode, dagPlanningState]);
|
|
4245
|
+
|
|
4246
|
+
const dagEpicFocusIds = useMemo(() => {
|
|
4247
|
+
if (dagFocusMode === "backlog") return dagPlanningState.epicIdsByFocus.backlog;
|
|
4248
|
+
if (dagFocusMode === "ready") return dagPlanningState.epicIdsByFocus.ready;
|
|
4249
|
+
if (dagFocusMode === "execution") return new Set([...dagPlanningState.epicIdsByFocus.execution, ...dagPlanningState.epicIdsByFocus.ready]);
|
|
4250
|
+
return "all";
|
|
4251
|
+
}, [dagFocusMode, dagPlanningState]);
|
|
4252
|
+
|
|
4253
|
+
const dagSprintGraphView = useMemo(() => filterDagGraphByIds(dagSprintGraph, dagTaskFocusIds, (node) => toText(node?.taskId || node?.id)), [dagSprintGraph, dagTaskFocusIds]);
|
|
4254
|
+
const dagGlobalGraphView = useMemo(() => filterDagGraphByIds(dagGlobalGraph, dagSprintFocusIds, (node) => toText(node?.sprintId || node?.id)), [dagGlobalGraph, dagSprintFocusIds]);
|
|
4255
|
+
const dagEpicGraphView = useMemo(() => filterDagGraphByIds(dagEpicGraph, dagEpicFocusIds, (node) => toText(node?.epicId || node?.id)), [dagEpicGraph, dagEpicFocusIds]);
|
|
4256
|
+
const dagReadyHighlightIds = dagFocusMode === "execution" || dagFocusMode === "ready" ? dagPlanningState.readyTaskIds : null;
|
|
4257
|
+
|
|
3796
4258
|
/* ── Handlers ── */
|
|
3797
4259
|
const handleFilter = async (s) => {
|
|
3798
4260
|
haptic();
|
|
@@ -3879,29 +4341,31 @@ export function TasksTab() {
|
|
|
3879
4341
|
}
|
|
3880
4342
|
}, [loadDagViews]);
|
|
3881
4343
|
|
|
3882
|
-
const handleCreateSprint = useCallback(
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
4344
|
+
const handleCreateSprint = useCallback(() => {
|
|
4345
|
+
haptic("medium");
|
|
4346
|
+
setShowCreateSprint(true);
|
|
4347
|
+
}, []);
|
|
4348
|
+
|
|
4349
|
+
const handleMoveSprint = useCallback(async (sprintId, direction) => {
|
|
4350
|
+
const currentIndex = dagSprints.findIndex((entry) => entry.id === sprintId);
|
|
4351
|
+
const targetIndex = currentIndex + direction;
|
|
4352
|
+
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= dagSprints.length) return;
|
|
4353
|
+
const reordered = [...dagSprints];
|
|
4354
|
+
const [moved] = reordered.splice(currentIndex, 1);
|
|
4355
|
+
reordered.splice(targetIndex, 0, moved);
|
|
3888
4356
|
haptic("medium");
|
|
3889
4357
|
try {
|
|
3890
|
-
await apiFetch(
|
|
3891
|
-
method: "
|
|
3892
|
-
body: JSON.stringify({
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
status: "active",
|
|
3896
|
-
}),
|
|
3897
|
-
});
|
|
3898
|
-
showToast("Sprint created", "success");
|
|
4358
|
+
await Promise.all(reordered.map((entry, index) => apiFetch(`/api/tasks/sprints/${encodeURIComponent(entry.id)}`, {
|
|
4359
|
+
method: "PATCH",
|
|
4360
|
+
body: JSON.stringify({ order: index + 1 }),
|
|
4361
|
+
})));
|
|
4362
|
+
showToast("Sprint order updated", "success");
|
|
3899
4363
|
await loadDagViews();
|
|
3900
|
-
if (id) setDagSelectedSprint(id);
|
|
3901
4364
|
} catch {
|
|
3902
|
-
|
|
4365
|
+
setDagError("Failed to reorder sprints.");
|
|
3903
4366
|
}
|
|
3904
|
-
}, [loadDagViews]);
|
|
4367
|
+
}, [dagSprints, loadDagViews]);
|
|
4368
|
+
|
|
3905
4369
|
const handleDagSprintModeChange = useCallback(async (mode) => {
|
|
3906
4370
|
const nextMode = toText(mode, "parallel").toLowerCase();
|
|
3907
4371
|
if (!dagSelectedSprint || dagSelectedSprint === "all") {
|
|
@@ -3922,6 +4386,43 @@ export function TasksTab() {
|
|
|
3922
4386
|
setDagError("Failed to update sprint execution mode.");
|
|
3923
4387
|
}
|
|
3924
4388
|
}, [dagSelectedSprint, loadDagViews]);
|
|
4389
|
+
const handleCreateDagEdge = useCallback(async ({ sourceNode, targetNode, graphKind }) => {
|
|
4390
|
+
const srcTaskId = toText(sourceNode?.taskId || sourceNode?.id);
|
|
4391
|
+
const dstTaskId = toText(targetNode?.taskId || targetNode?.id);
|
|
4392
|
+
|
|
4393
|
+
if (graphKind === "epic") {
|
|
4394
|
+
const srcEpic = toText(sourceNode?.epicId || sourceNode?.id);
|
|
4395
|
+
const dstEpic = toText(targetNode?.epicId || targetNode?.id);
|
|
4396
|
+
if (!srcEpic || !dstEpic || srcEpic === dstEpic) return;
|
|
4397
|
+
const existing = normalizeDependencyInput(targetNode?.dependencies || []);
|
|
4398
|
+
const dependencies = normalizeDependencyInput([...existing, srcEpic]);
|
|
4399
|
+
await apiFetch("/api/tasks/epic-dependencies", {
|
|
4400
|
+
method: "PUT",
|
|
4401
|
+
body: JSON.stringify({ epicId: dstEpic, dependencies }),
|
|
4402
|
+
});
|
|
4403
|
+
showToast(`Wired epic dependency: ${srcEpic} -> ${dstEpic}`, "success");
|
|
4404
|
+
await loadDagViews();
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
if (!srcTaskId || !dstTaskId || srcTaskId === dstTaskId) return;
|
|
4409
|
+
const existing = normalizeDependencyInput(
|
|
4410
|
+
targetNode?.dependencies ||
|
|
4411
|
+
targetNode?.dependencyTaskIds ||
|
|
4412
|
+
[],
|
|
4413
|
+
);
|
|
4414
|
+
const dependencies = normalizeDependencyInput([...existing, srcTaskId]);
|
|
4415
|
+
await apiFetch("/api/tasks/dependencies", {
|
|
4416
|
+
method: "PUT",
|
|
4417
|
+
body: JSON.stringify({
|
|
4418
|
+
taskId: dstTaskId,
|
|
4419
|
+
dependencies,
|
|
4420
|
+
}),
|
|
4421
|
+
});
|
|
4422
|
+
showToast(`Wired dependency: ${srcTaskId} -> ${dstTaskId}`, "success");
|
|
4423
|
+
await loadDagViews();
|
|
4424
|
+
}, [loadDagViews]);
|
|
4425
|
+
|
|
3925
4426
|
const handleSprintChange = useCallback((nextSprint) => {
|
|
3926
4427
|
const sprintId = toText(nextSprint, "all");
|
|
3927
4428
|
if (sprintId === dagSelectedSprint) return;
|
|
@@ -4539,6 +5040,7 @@ export function TasksTab() {
|
|
|
4539
5040
|
<span class="snapshot-view-tag">${iconText(":link: DAG")}</span>
|
|
4540
5041
|
<span class="pill">Sprint nodes: ${dagSprintGraph.nodes.length}</span>
|
|
4541
5042
|
<span class="pill">Global nodes: ${dagGlobalGraph.nodes.length}</span>
|
|
5043
|
+
<span class="pill">Epic nodes: ${dagEpicGraph.nodes.length}</span>
|
|
4542
5044
|
<span class="pill">Global edges: ${dagGlobalGraph.edges.length}</span>
|
|
4543
5045
|
</div>
|
|
4544
5046
|
`}
|
|
@@ -4583,22 +5085,37 @@ export function TasksTab() {
|
|
|
4583
5085
|
|
|
4584
5086
|
${isDag && html`
|
|
4585
5087
|
<div class="task-dag-wrap" style=${{ display: "grid", gap: "10px", marginTop: "8px" }}>
|
|
4586
|
-
${dagError
|
|
4587
|
-
${dagLoading
|
|
5088
|
+
${dagError ? html`<${Alert} severity="warning">${dagError}</${Alert}>` : null}
|
|
5089
|
+
${dagLoading ? html`<${Alert} severity="info">Loading DAG data…</${Alert}>` : null}
|
|
4588
5090
|
<${DagGraphSection}
|
|
4589
5091
|
title=${dagSprintGraph.title || (dagSelectedSprint === "all" ? "All Sprint DAG" : `Sprint ${dagSelectedSprint} DAG`)}
|
|
4590
5092
|
description=${dagSprintGraph.description || "Task dependency order within the selected sprint."}
|
|
4591
5093
|
graph=${dagSprintGraph}
|
|
5094
|
+
graphKey="sprint"
|
|
4592
5095
|
onOpenTask=${openDetail}
|
|
5096
|
+
onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
|
|
5097
|
+
allowWiring=${true}
|
|
4593
5098
|
emptyMessage="No sprint DAG data available yet."
|
|
4594
|
-
|
|
5099
|
+
><//>
|
|
4595
5100
|
<${DagGraphSection}
|
|
4596
5101
|
title=${dagGlobalGraph.title || "Global DAG of DAGs"}
|
|
4597
5102
|
description=${dagGlobalGraph.description || "Cross-sprint dependency overview."}
|
|
4598
5103
|
graph=${dagGlobalGraph}
|
|
5104
|
+
graphKey="global"
|
|
4599
5105
|
onOpenTask=${openDetail}
|
|
5106
|
+
onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
|
|
5107
|
+
allowWiring=${true}
|
|
4600
5108
|
emptyMessage="No global DAG data available yet."
|
|
4601
|
-
|
|
5109
|
+
><//>
|
|
5110
|
+
<${DagGraphSection}
|
|
5111
|
+
title=${dagEpicGraph.title || "Epic Dependency DAG"}
|
|
5112
|
+
description=${dagEpicGraph.description || "Epics and their run prerequisites."}
|
|
5113
|
+
graph=${dagEpicGraph}
|
|
5114
|
+
graphKey="epic"
|
|
5115
|
+
onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "epic" })}
|
|
5116
|
+
allowWiring=${true}
|
|
5117
|
+
emptyMessage="No epic DAG data available yet."
|
|
5118
|
+
><//>
|
|
4602
5119
|
</div>
|
|
4603
5120
|
`}
|
|
4604
5121
|
|
|
@@ -4725,22 +5242,14 @@ export function TasksTab() {
|
|
|
4725
5242
|
html`
|
|
4726
5243
|
<${CreateTaskModalInline} onClose=${() => setShowCreate(false)} />
|
|
4727
5244
|
`}
|
|
4728
|
-
${detailTask && isActiveStatus(detailTask.status) &&
|
|
5245
|
+
${detailTask && isActiveStatus(detailTask.status) && hasLiveExecutionEvidence(detailTask) &&
|
|
4729
5246
|
html`
|
|
4730
5247
|
<${TaskProgressModal}
|
|
4731
5248
|
task=${detailTask}
|
|
4732
5249
|
onClose=${() => setDetailTask(null)}
|
|
4733
5250
|
/>
|
|
4734
5251
|
`}
|
|
4735
|
-
${detailTask &&
|
|
4736
|
-
html`
|
|
4737
|
-
<${TaskReviewModal}
|
|
4738
|
-
task=${detailTask}
|
|
4739
|
-
onClose=${() => setDetailTask(null)}
|
|
4740
|
-
onStart=${(task) => openStartModal(task)}
|
|
4741
|
-
/>
|
|
4742
|
-
`}
|
|
4743
|
-
${detailTask && !isActiveStatus(detailTask.status) && !isReviewStatus(detailTask.status) &&
|
|
5252
|
+
${detailTask && (!isActiveStatus(detailTask.status) || !hasLiveExecutionEvidence(detailTask)) &&
|
|
4744
5253
|
html`
|
|
4745
5254
|
<${TaskDetailModal}
|
|
4746
5255
|
task=${detailTask}
|
|
@@ -5196,6 +5705,8 @@ function CreateTaskModalInline({ onClose }) {
|
|
|
5196
5705
|
|
|
5197
5706
|
|
|
5198
5707
|
|
|
5708
|
+
|
|
5709
|
+
|
|
5199
5710
|
|
|
5200
5711
|
|
|
5201
5712
|
|