feat(FN-3897): add blocker fan-out hook, badge, and detail modal section
Implemented the blocker fan-out feature across the dashboard: added a `useBlockerFanout` hook to track blocking relationships, rendered a fan-out badge on task cards, and added a blocking section to the task detail modal, with tests covering the hook and responsive modal behavior. Fusion-Task-Id: FN-3897
This commit is contained in:
5
.changeset/fn-3897-blocker-fanout.md
Normal file
5
.changeset/fn-3897-blocker-fanout.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Show downstream blocker fan-out count on the board so high-impact blockers are visible at a glance.
|
||||
@@ -381,6 +381,18 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
|
||||
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call).
|
||||
- In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs.
|
||||
|
||||
### Identifying high-impact blockers
|
||||
|
||||
Use the `Blocks N` badge on task cards to spot blockers with high downstream impact:
|
||||
|
||||
- `Blocks N` counts active downstream dependents in `triage`, `todo`, `in-progress`, or `in-review`.
|
||||
- The badge tooltip shows total active dependents plus how many are currently waiting in `todo`.
|
||||
- Open a task to the **Blocking** section in Task Detail to view each downstream dependent and click through quickly.
|
||||
- `(stale)` markers mean the dependent is blocked through `blockedBy` and matches stale conditions that `clearStaleBlockedBy` self-healing should clear automatically.
|
||||
- Stale `dependencies[]` links are shown for awareness but are not auto-cleared by `clearStaleBlockedBy`.
|
||||
|
||||
Recommended workflow: when a blocker has high fan-out, prioritize unblocking first (reassign, split, or resolve immediately) before lower-impact tasks.
|
||||
|
||||
### Logs → Agent Log view
|
||||
|
||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
|
||||
import { fetchWorkflowSteps, type ModelInfo } from "../api";
|
||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
@@ -76,6 +77,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
||||
const blockerFanoutMap = useBlockerFanout(tasks);
|
||||
// Normalized search-active signal: trimmed and non-empty
|
||||
const isSearchActive = searchQuery.trim() !== "";
|
||||
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
||||
@@ -222,6 +224,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
|
||||
import type { ModelInfo } from "../api";
|
||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
|
||||
const PAGINATED_COLUMN_THRESHOLD = 100;
|
||||
const VISIBLE_TASKS_INITIAL = 50;
|
||||
@@ -66,9 +67,11 @@ interface ColumnProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -488,6 +491,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -514,6 +518,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onMoveTask={onMoveTask}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
/>
|
||||
))}
|
||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||
|
||||
@@ -411,7 +411,29 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card-scope-badge[data-tooltip]:hover::after {
|
||||
.card-fanout-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-info);
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card-fanout-count {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.card-fanout-badge--stale {
|
||||
color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
padding-inline: calc(var(--space-xs) / 2);
|
||||
}
|
||||
|
||||
.card-scope-badge[data-tooltip]:hover::after,
|
||||
.card-fanout-badge[data-tooltip]:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TaskCard.css";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap } from "lucide-react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
|
||||
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||
@@ -17,6 +17,7 @@ import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseT
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
|
||||
// ── Mission title caching ───────────────────────────────────────────────────
|
||||
|
||||
@@ -277,6 +278,8 @@ interface TaskCardProps {
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
|
||||
disableDrag?: boolean;
|
||||
/** Downstream fan-out entry for this task, computed at board-level. */
|
||||
fanout?: BlockerFanoutEntry;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
@@ -401,6 +404,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onMoveTask === next.onMoveTask &&
|
||||
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
|
||||
previous.disableDrag === next.disableDrag &&
|
||||
previous.fanout?.totalCount === next.fanout?.totalCount &&
|
||||
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
|
||||
areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) &&
|
||||
areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) &&
|
||||
previousTask.id === nextTask.id &&
|
||||
previousTask.title === nextTask.title &&
|
||||
previousTask.description === nextTask.description &&
|
||||
@@ -465,6 +472,7 @@ function TaskCardComponent({
|
||||
lastFetchTimeMs,
|
||||
workflowStepNameLookup,
|
||||
disableDrag,
|
||||
fanout,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
@@ -1609,7 +1617,7 @@ function TaskCardComponent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy || (fanout && fanout.totalCount > 0)) && (
|
||||
<div className="card-meta">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="card-dep-list">
|
||||
@@ -1630,6 +1638,18 @@ function TaskCardComponent({
|
||||
<Layers size={12} style={{ verticalAlign: "middle" }} /> {task.blockedBy}
|
||||
</span>
|
||||
)}
|
||||
{fanout && fanout.totalCount > 0 && (
|
||||
<span
|
||||
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}`}
|
||||
data-tooltip={`Blocking ${fanout.totalCount} task(s); ${fanout.activeTodoCount} waiting in todo`}
|
||||
>
|
||||
<GitBranch size={12} style={{ verticalAlign: "middle" }} />
|
||||
<span>
|
||||
Blocks <span className="card-fanout-count">{fanout.totalCount}</span>
|
||||
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -522,6 +522,11 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-blocking-item--stale {
|
||||
color: var(--color-warning);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-actions-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { subscribeSse } from "../sse-bus";
|
||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -1579,6 +1580,21 @@ export function TaskDetailContent({
|
||||
return bNum - aNum;
|
||||
});
|
||||
|
||||
const blockerFanoutMap = useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
|
||||
const blockingEntry = blockerFanoutMap.get(task.id);
|
||||
const blockingDependents = useMemo(() => {
|
||||
if (!blockingEntry) return [] as Array<{ id: string; label: string; stale: boolean }>;
|
||||
const staleSet = new Set(blockingEntry.staleBlockedByDependentIds);
|
||||
return blockingEntry.dependentIds.map((dependentId) => {
|
||||
const dependentTask = tasks.find((candidate) => candidate.id === dependentId);
|
||||
return {
|
||||
id: dependentId,
|
||||
label: dependentTask?.title || dependentTask?.description || dependentId,
|
||||
stale: staleSet.has(dependentId),
|
||||
};
|
||||
});
|
||||
}, [blockingEntry, tasks]);
|
||||
|
||||
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
|
||||
const detailProviders = useMemo(() => {
|
||||
const providers: string[] = [];
|
||||
@@ -2538,6 +2554,43 @@ export function TaskDetailContent({
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-deps detail-blocking">
|
||||
<h4>Blocking</h4>
|
||||
{blockingDependents.length > 0 ? (
|
||||
<ul className="detail-dep-list">
|
||||
{blockingDependents.map((dependent) => (
|
||||
<li key={dependent.id} className="detail-dep-item">
|
||||
<span
|
||||
className="detail-dep-link"
|
||||
onClick={() => handleDepClick(dependent.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleDepClick(dependent.id);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
title={`Click to view ${dependent.id}`}
|
||||
>
|
||||
<span className="detail-dep-id">{dependent.id}</span>
|
||||
<span className="detail-dep-label">{truncate(dependent.label, 40)}</span>
|
||||
</span>
|
||||
{dependent.stale && (
|
||||
<span
|
||||
className="detail-blocking-item--stale"
|
||||
title="Stale blockedBy edge: self-healing clearStaleBlockedBy should clear this automatically"
|
||||
>
|
||||
(stale)
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="detail-empty-inline">(no downstream tasks blocked)</div>
|
||||
)}
|
||||
</div>
|
||||
{/* PR Section - only for in-review tasks */}
|
||||
{task.column === "in-review" && (
|
||||
<div className="detail-section detail-pr-section">
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { ClipboardList, GitBranch } from "lucide-react";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
|
||||
interface WorktreeGroupProps {
|
||||
label: string;
|
||||
@@ -26,6 +27,8 @@ interface WorktreeGroupProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Lookup of workflow step IDs to display names, fetched once at board level. */
|
||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||
/** Precomputed blocker fanout keyed by blocker task ID. */
|
||||
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
|
||||
}
|
||||
|
||||
function WorktreeGroupComponent({
|
||||
@@ -43,6 +46,7 @@ function WorktreeGroupComponent({
|
||||
onOpenMission,
|
||||
lastFetchTimeMs,
|
||||
workflowStepNameLookup,
|
||||
blockerFanoutMap,
|
||||
}: WorktreeGroupProps) {
|
||||
return (
|
||||
<div className="worktree-group">
|
||||
@@ -53,7 +57,7 @@ function WorktreeGroupComponent({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} />
|
||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -71,6 +75,7 @@ function WorktreeGroupComponent({
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
fanout={blockerFanoutMap?.get(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Task } from "@fusion/core";
|
||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||
vi.mock("lucide-react", () => ({
|
||||
Link: () => null,
|
||||
GitBranch: () => null,
|
||||
Clock: () => null,
|
||||
Pencil: () => null,
|
||||
Layers: () => null,
|
||||
@@ -183,6 +184,65 @@ describe("TaskCard", () => {
|
||||
expect(screen.queryByText("paused by agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render fan-out badge when fanout is missing or zero", () => {
|
||||
const { container, rerender } = render(
|
||||
<TaskCard task={makeTask({ column: "todo" })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".card-fanout-badge")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "todo" })}
|
||||
fanout={{ totalCount: 0, activeTodoCount: 0, dependentIds: [], staleBlockedByDependentIds: [] }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".card-fanout-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders fan-out badge with downstream count and tooltip", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-progress" })}
|
||||
fanout={{ totalCount: 7, activeTodoCount: 4, dependentIds: ["FN-002"], staleBlockedByDependentIds: [] }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge.textContent).toContain("Blocks 7");
|
||||
expect(badge.getAttribute("data-tooltip")).toBe("Blocking 7 task(s); 4 waiting in todo");
|
||||
});
|
||||
|
||||
it("applies stale fan-out modifier when stale blockedBy dependents exist", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "in-progress" })}
|
||||
fanout={{ totalCount: 3, activeTodoCount: 1, dependentIds: ["FN-003"], staleBlockedByDependentIds: ["FN-003"] }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-fanout-badge") as HTMLElement;
|
||||
expect(badge.className).toContain("card-fanout-badge--stale");
|
||||
expect(badge.textContent).toContain("(1 stale)");
|
||||
});
|
||||
|
||||
it("shows plain paused label when pausedByAgentId is not set", () => {
|
||||
render(
|
||||
<TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("paused")).toBeDefined();
|
||||
expect(screen.queryByText("paused by agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides default working branch and default base branch metadata", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
|
||||
@@ -725,5 +725,32 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocking section", () => {
|
||||
it("renders downstream dependents and stale annotations", () => {
|
||||
const tasks = [
|
||||
makeTask({ id: "FN-099", title: "Blocker", column: "done" as Column }),
|
||||
makeTask({ id: "FN-100", title: "Todo dependent", column: "todo" as Column, dependencies: ["FN-099"] }),
|
||||
makeTask({ id: "FN-101", title: "Stale blockedBy dependent", column: "todo" as Column, blockedBy: "FN-099" }),
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={tasks[0]}
|
||||
tasks={tasks}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Blocking")).toBeTruthy();
|
||||
expect(container.textContent).toContain("FN-100");
|
||||
expect(container.textContent).toContain("FN-101");
|
||||
expect(container.querySelector(".detail-blocking-item--stale")?.textContent).toBe("(stale)");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
138
packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts
Normal file
138
packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { computeBlockerFanoutMap, MAX_AUTO_MERGE_RETRIES } from "../useBlockerFanout";
|
||||
|
||||
function createTask(id: string, column: Task["column"], overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id,
|
||||
description: `Task ${id}`,
|
||||
column,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeBlockerFanoutMap", () => {
|
||||
it("returns an empty map for an empty task list", () => {
|
||||
expect(computeBlockerFanoutMap([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns an empty map when no downstream dependencies exist", () => {
|
||||
const tasks = [createTask("FN-1", "todo"), createTask("FN-2", "done")];
|
||||
expect(computeBlockerFanoutMap(tasks).size).toBe(0);
|
||||
});
|
||||
|
||||
it("tracks a single dependent via dependencies[]", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "in-progress"),
|
||||
createTask("FN-2", "todo", { dependencies: ["FN-1"] }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
|
||||
totalCount: 1,
|
||||
activeTodoCount: 1,
|
||||
dependentIds: ["FN-2"],
|
||||
staleBlockedByDependentIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks mixed dependencies[] and blockedBy edges", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "in-progress"),
|
||||
createTask("FN-2", "todo", { dependencies: ["FN-1"] }),
|
||||
createTask("FN-3", "in-review", { blockedBy: "FN-1" }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
|
||||
totalCount: 2,
|
||||
activeTodoCount: 1,
|
||||
dependentIds: ["FN-2", "FN-3"],
|
||||
staleBlockedByDependentIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes done/archived dependents from totalCount but keeps dependentIds", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "in-progress"),
|
||||
createTask("FN-2", "done", { dependencies: ["FN-1"] }),
|
||||
createTask("FN-3", "archived", { blockedBy: "FN-1" }),
|
||||
createTask("FN-4", "todo", { dependencies: ["FN-1"] }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
|
||||
totalCount: 1,
|
||||
activeTodoCount: 1,
|
||||
dependentIds: ["FN-2", "FN-3", "FN-4"],
|
||||
staleBlockedByDependentIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks stale blockedBy dependents only for blockedBy edges, not dependencies[]", () => {
|
||||
const tasks = [
|
||||
createTask("FN-2", "todo", { dependencies: ["MISSING"] }),
|
||||
createTask("FN-3", "todo", { blockedBy: "MISSING" }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("MISSING")).toEqual({
|
||||
totalCount: 2,
|
||||
activeTodoCount: 2,
|
||||
dependentIds: ["FN-2", "FN-3"],
|
||||
staleBlockedByDependentIds: ["FN-3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker is done", () => {
|
||||
const tasks = [createTask("B", "done"), createTask("D", "todo", { blockedBy: "B" })];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker is archived", () => {
|
||||
const tasks = [createTask("B", "archived"), createTask("D", "todo", { blockedBy: "B" })];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker is in-review and paused", () => {
|
||||
const tasks = [createTask("B", "in-review", { paused: true }), createTask("D", "todo", { blockedBy: "B" })];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("marks blockedBy edges stale when blocker failed in-review at max retries", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-review", { status: "failed", mergeRetries: MAX_AUTO_MERGE_RETRIES }),
|
||||
createTask("D", "todo", { blockedBy: "B" }),
|
||||
];
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
|
||||
});
|
||||
|
||||
it("FN-3897 regression: reports active and todo downstream counts for high fan-out blockers", () => {
|
||||
const tasks = [
|
||||
createTask("B", "in-progress"),
|
||||
createTask("D1", "todo", { dependencies: ["B"] }),
|
||||
createTask("D2", "todo", { blockedBy: "B" }),
|
||||
createTask("D3", "in-review", { dependencies: ["B"] }),
|
||||
createTask("D4", "done", { dependencies: ["B"] }),
|
||||
];
|
||||
|
||||
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
|
||||
totalCount: 3,
|
||||
activeTodoCount: 2,
|
||||
dependentIds: ["D1", "D2", "D3", "D4"],
|
||||
staleBlockedByDependentIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MAX_AUTO_MERGE_RETRIES aligned with engine self-healing source", () => {
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8");
|
||||
const match = source.match(/const MAX_AUTO_MERGE_RETRIES = (\d+);/);
|
||||
expect(match?.[1]).toBe(String(MAX_AUTO_MERGE_RETRIES));
|
||||
});
|
||||
});
|
||||
102
packages/dashboard/app/hooks/useBlockerFanout.ts
Normal file
102
packages/dashboard/app/hooks/useBlockerFanout.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
export interface BlockerFanoutEntry {
|
||||
totalCount: number;
|
||||
activeTodoCount: number;
|
||||
dependentIds: string[];
|
||||
staleBlockedByDependentIds: string[];
|
||||
}
|
||||
|
||||
// Keep in sync with packages/engine/src/self-healing.ts
|
||||
export const MAX_AUTO_MERGE_RETRIES = 3;
|
||||
|
||||
const ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
|
||||
|
||||
function isStaleBlockedByBlocker(blocker: Task | undefined): boolean {
|
||||
if (!blocker) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (blocker.column === "done" || blocker.column === "archived") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (blocker.column === "in-review" && blocker.paused === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
blocker.column === "in-review" &&
|
||||
blocker.status === "failed" &&
|
||||
(blocker.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
interface MutableEntry {
|
||||
dependentIds: string[];
|
||||
blockedByDependentIds: string[];
|
||||
activeCount: number;
|
||||
activeTodoCount: number;
|
||||
}
|
||||
|
||||
export function computeBlockerFanoutMap(tasks: Task[]): Map<string, BlockerFanoutEntry> {
|
||||
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
||||
const fanout = new Map<string, MutableEntry>();
|
||||
|
||||
const ensureEntry = (blockerId: string): MutableEntry => {
|
||||
let entry = fanout.get(blockerId);
|
||||
if (!entry) {
|
||||
entry = { dependentIds: [], blockedByDependentIds: [], activeCount: 0, activeTodoCount: 0 };
|
||||
fanout.set(blockerId, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
for (const task of tasks) {
|
||||
const active = ACTIVE_COLUMNS.has(task.column);
|
||||
const isTodo = task.column === "todo";
|
||||
|
||||
const dependencyIds = task.dependencies ?? [];
|
||||
for (const depId of dependencyIds) {
|
||||
if (!depId) continue;
|
||||
const entry = ensureEntry(depId);
|
||||
entry.dependentIds.push(task.id);
|
||||
if (active) entry.activeCount += 1;
|
||||
if (isTodo) entry.activeTodoCount += 1;
|
||||
}
|
||||
|
||||
if (task.blockedBy) {
|
||||
const entry = ensureEntry(task.blockedBy);
|
||||
entry.dependentIds.push(task.id);
|
||||
entry.blockedByDependentIds.push(task.id);
|
||||
if (active) entry.activeCount += 1;
|
||||
if (isTodo) entry.activeTodoCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Map<string, BlockerFanoutEntry>();
|
||||
for (const [blockerId, entry] of fanout) {
|
||||
const blocker = taskById.get(blockerId);
|
||||
const staleBlockedByDependentIds = isStaleBlockedByBlocker(blocker)
|
||||
? [...entry.blockedByDependentIds]
|
||||
: [];
|
||||
|
||||
result.set(blockerId, {
|
||||
totalCount: entry.activeCount,
|
||||
activeTodoCount: entry.activeTodoCount,
|
||||
dependentIds: entry.dependentIds,
|
||||
staleBlockedByDependentIds,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useBlockerFanout(tasks: Task[]): Map<string, BlockerFanoutEntry> {
|
||||
return useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
|
||||
}
|
||||
Reference in New Issue
Block a user