FN-6980: fix inherited workflow details

Fix task workflow details so inherited board defaults render consistently in the live Workflow tab.

- Resolve null task workflow selections through board workflow mappings or the project default for read-only detail surfaces.
- Load workflow graphs and optional steps from the effective workflow while preserving explicit selector state.
- Add coverage for default inheritance, explicit custom workflows, cleared selections, stale workflow ids, and task detail progress fixtures.

Files changed:
 .../app/components/WorkflowResultsTab.tsx          |  60 ++++++++---
 ...skDetailModal.models-progress-workflow.test.tsx |  63 +++++++++++
 .../__tests__/TaskDetailModal.test-helpers.ts      |  23 ++++
 .../__tests__/WorkflowResultsTab.test.tsx          | 118 ++++++++++++++++++++-
 4 files changed, 243 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-6980

Fusion-Task-Lineage: 2ee9ca68-4053-4d69-a4da-e2bfc22cfe43
This commit is contained in:
gsxdsm
2026-06-25 11:03:48 -07:00
parent 4880a0f857
commit 2669bb17e7
4 changed files with 243 additions and 21 deletions

View File

@@ -13,7 +13,7 @@ import remarkGfm from "remark-gfm";
import { ReactFlow, ReactFlowProvider } from "@xyflow/react";
import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult, ResolvedWorkflowOptionalStep } from "@fusion/core";
import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
import { approveTaskWorkflowCli, fetchBoardWorkflows, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
import { WorkflowSelector } from "./WorkflowSelector";
import { phaseBadge } from "./workflow-phase-badge";
import { useAgentLogs } from "../hooks/useAgentLogs";
@@ -139,12 +139,12 @@ function getOutputPreview(output: string): string {
// and the optional-steps dropdown). Imported above.
function getWorkflowName(
selectedWorkflowId: string | null,
workflowId: string | null,
workflows: WorkflowDefinition[],
t: ReturnType<typeof useTranslation>["t"],
): string {
if (!selectedWorkflowId) return t("app:workflow.defaultWorkflow", "Default");
const match = workflows.find((workflow) => workflow.id === selectedWorkflowId);
if (!workflowId) return t("app:workflow.noWorkflowAssigned", "No workflow assigned");
const match = workflows.find((workflow) => workflow.id === workflowId);
return match?.name || t("app:workflow.customWorkflowFallback", "Custom workflow");
}
@@ -323,6 +323,7 @@ export function WorkflowResultsTab({
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
const [optionalWorkflowSteps, setOptionalWorkflowSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
const [workflowDefinitions, setWorkflowDefinitions] = useState<WorkflowDefinition[]>([]);
const [boardWorkflowFallbackId, setBoardWorkflowFallbackId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [resumeError, setResumeError] = useState<string | null>(null);
@@ -387,13 +388,38 @@ export function WorkflowResultsTab({
}, [projectId]);
useEffect(() => {
if (!graphExpanded || !selectedWorkflowId || workflowGraphCache[selectedWorkflowId]) return;
let cancelled = false;
setBoardWorkflowFallbackId(null);
fetchBoardWorkflows(projectId)
.then((payload) => {
if (cancelled) return;
const mappedWorkflowId = payload.taskWorkflowIds?.[taskId] || null;
const defaultWorkflowId = payload.defaultWorkflowId || null;
setBoardWorkflowFallbackId(payload.flagEnabled ? (mappedWorkflowId ?? defaultWorkflowId) : null);
})
.catch(() => {
if (!cancelled) setBoardWorkflowFallbackId(null);
});
return () => {
cancelled = true;
};
}, [taskId, projectId]);
/*
FNXC:TaskWorkflowDetails 2026-06-24-09:50:
A null per-task workflow selection means "inherit the board workflow" when board-workflows supplies a task mapping or project default. Use this effective id for read-only task-detail surfaces while keeping the explicit selection value for WorkflowSelector.
*/
const effectiveWorkflowId = selectedWorkflowId ?? boardWorkflowFallbackId;
const graphCacheKey = effectiveWorkflowId ? `${projectId ?? ""}::${effectiveWorkflowId}` : null;
useEffect(() => {
if (!graphExpanded || !effectiveWorkflowId || !graphCacheKey || workflowGraphCache[graphCacheKey]) return;
let cancelled = false;
setWorkflowGraphLoading(true);
fetchWorkflow(selectedWorkflowId, projectId)
fetchWorkflow(effectiveWorkflowId, projectId)
.then((definition) => {
if (!cancelled) {
setWorkflowGraphCache((prev) => ({ ...prev, [selectedWorkflowId]: definition }));
setWorkflowGraphCache((prev) => ({ ...prev, [graphCacheKey]: definition }));
}
})
.catch(() => {
@@ -405,7 +431,7 @@ export function WorkflowResultsTab({
return () => {
cancelled = true;
};
}, [graphExpanded, selectedWorkflowId, projectId, workflowGraphCache]);
}, [graphExpanded, effectiveWorkflowId, graphCacheKey, projectId, workflowGraphCache]);
// Check if any result has pending status
const hasPendingStep = results.some((r) => r.status === "pending");
@@ -436,11 +462,13 @@ export function WorkflowResultsTab({
};
}, [projectId]);
const effectiveOptionalStepsWorkflowId = selectedWorkflowId || "builtin:coding";
useEffect(() => {
if (!effectiveWorkflowId) {
setOptionalWorkflowSteps([]);
return;
}
let cancelled = false;
fetchWorkflowOptionalSteps(effectiveOptionalStepsWorkflowId, projectId)
fetchWorkflowOptionalSteps(effectiveWorkflowId, projectId)
.then((steps) => {
if (!cancelled) setOptionalWorkflowSteps(steps);
})
@@ -450,7 +478,7 @@ export function WorkflowResultsTab({
return () => {
cancelled = true;
};
}, [effectiveOptionalStepsWorkflowId, projectId]);
}, [effectiveWorkflowId, projectId]);
const selectedWorkflowSteps = enabledWorkflowSteps ?? [];
@@ -574,11 +602,11 @@ export function WorkflowResultsTab({
});
}, [selectedWorkflowSteps, workflowStepLookup, t]);
const workflowName = useMemo(() => getWorkflowName(selectedWorkflowId, workflowDefinitions, t), [selectedWorkflowId, workflowDefinitions, t]);
const workflowName = useMemo(() => getWorkflowName(effectiveWorkflowId, workflowDefinitions, t), [effectiveWorkflowId, workflowDefinitions, t]);
const executionPhase = useMemo(() => getExecutionPhase(task, taskStatus, taskPausedReason, results, t), [task, taskStatus, taskPausedReason, results, t]);
const aggregateResult = useMemo(() => getAggregateWorkflowResult(results, t), [results, t]);
const completedStepCount = useMemo(() => results.filter((result) => ["passed", "skipped", "failed", "advisory_failure"].includes(result.status)).length, [results]);
const graphWorkflow = selectedWorkflowId ? workflowGraphCache[selectedWorkflowId] : undefined;
const graphWorkflow = graphCacheKey ? workflowGraphCache[graphCacheKey] : undefined;
const graphFlow = useMemo(() => (graphWorkflow ? irToFlow(graphWorkflow) : null), [graphWorkflow]);
const effectiveExecutor = useMemo(() => (task ? resolveTaskExecutionModel(task, settings) : undefined), [task, settings]);
const effectiveValidator = useMemo(() => (task ? resolveTaskValidatorModel(task, settings) : undefined), [task, settings]);
@@ -1011,7 +1039,7 @@ export function WorkflowResultsTab({
</button>
{graphExpanded && (
<div className="workflow-disclosure__content">
{!selectedWorkflowId ? (
{!effectiveWorkflowId ? (
<p className="workflow-disclosure__empty" data-testid="workflow-graph-empty">
{t("app:workflow.noWorkflowAssigned", "No workflow assigned")}
</p>
@@ -1050,7 +1078,7 @@ export function WorkflowResultsTab({
<section className="card workflow-management" data-testid="workflow-management-section">
<div className="workflow-management__header">
<h4>{t("app:workflow.workflowName", "Workflow")}</h4>
{canEdit && selectedWorkflowId && onEditWorkflow && (
{canEdit && effectiveWorkflowId && onEditWorkflow && (
<button
type="button"
className="btn btn-sm"

View File

@@ -17,6 +17,7 @@ import {
expectBaseRule,
readDashboardStylesSource,
setupTaskDetailModalHooks,
taskDetailSseSubscriptions,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
@@ -1057,6 +1058,68 @@ describe("TaskDetailModal", () => {
});
});
it("updates workflow results from matching task:updated SSE events while ignoring other tasks", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);
mockFetch.mockResolvedValueOnce([
{
workflowStepId: "WS-INITIAL",
workflowStepName: "Initial Check",
status: "pending",
output: "Initial output",
},
] as import("@fusion/core").WorkflowStepResult[]);
render(
<TaskDetailModal
initialTab="workflow"
task={makeTask({ id: "FN-099", enabledWorkflowSteps: ["WS-INITIAL"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(await screen.findByText("Initial Check", {}, { timeout: 15_000 })).toBeTruthy();
await waitFor(() => {
expect(taskDetailSseSubscriptions.some(({ options }) => typeof options.events?.["task:updated"] === "function")).toBe(true);
});
const workflowSubscription = taskDetailSseSubscriptions.find(({ options }) => typeof options.events?.["task:updated"] === "function");
const emitTaskUpdated = workflowSubscription!.options.events!["task:updated"];
await act(async () => {
emitTaskUpdated(new MessageEvent("task:updated", {
data: JSON.stringify({
id: "FN-OTHER",
workflowStepResults: [
{ workflowStepId: "WS-OTHER", workflowStepName: "Other Task Check", status: "failed", output: "Wrong task" },
],
}),
}));
});
expect(screen.queryByText("Other Task Check")).not.toBeInTheDocument();
expect(screen.getByText("Initial Check")).toBeTruthy();
await act(async () => {
emitTaskUpdated(new MessageEvent("task:updated", {
data: JSON.stringify({
id: "FN-099",
workflowStepResults: [
{ workflowStepId: "WS-LIVE", workflowStepName: "Live QA Check", status: "passed", output: "Updated from SSE" },
],
}),
}));
});
expect(await screen.findByText("Live QA Check")).toBeTruthy();
expect(screen.getByText("Updated from SSE")).toBeTruthy();
expect(screen.queryByText("Initial Check")).not.toBeInTheDocument();
});
it("renders configured workflow steps state when results are empty", async () => {
const { fetchWorkflowResults } = await import("../../api");
const mockFetch = vi.mocked(fetchWorkflowResults);

View File

@@ -9,6 +9,20 @@ import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
import { clearAuthToken } from "../../auth";
const taskDetailSseSubscriptions = vi.hoisted(() => [] as Array<{
url: string;
options: { events?: Record<string, (event: MessageEvent) => void> };
}>);
export { taskDetailSseSubscriptions };
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((url: string, options: { events?: Record<string, (event: MessageEvent) => void> }) => {
taskDetailSseSubscriptions.push({ url, options });
return vi.fn();
}),
}));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
@@ -32,6 +46,14 @@ vi.mock("../../api", async (importOriginal) => {
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
fetchWorkflow: vi.fn().mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: { version: 1, nodes: [], edges: [] } }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }),
approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
@@ -175,6 +197,7 @@ export function setupTaskDetailModalHooks(): void {
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
clearAuthToken();
localStorage.removeItem("fn.authToken");
taskDetailSseSubscriptions.length = 0;
});
afterEach(() => {

View File

@@ -23,6 +23,7 @@ const mockedFetchWorkflowSteps = vi.spyOn(api, "fetchWorkflowSteps");
const mockedFetchTaskWorkflow = vi.spyOn(api, "fetchTaskWorkflow");
const mockedFetchWorkflow = vi.spyOn(api, "fetchWorkflow");
const mockedFetchWorkflows = vi.spyOn(api, "fetchWorkflows");
const mockedFetchBoardWorkflows = vi.spyOn(api, "fetchBoardWorkflows");
const mockedFetchWorkflowOptionalSteps = vi.spyOn(api, "fetchWorkflowOptionalSteps");
const mockedSelectTaskWorkflow = vi.spyOn(api, "selectTaskWorkflow");
const mockedSubmitTaskWorkflowInput = vi.spyOn(api, "submitTaskWorkflowInput");
@@ -67,6 +68,24 @@ describe("WorkflowResultsTab", () => {
},
];
const defaultWorkflow: WorkflowDefinition = {
id: "builtin:coding",
name: "Built-in Coding Workflow",
description: "Default workflow",
ir: {
version: 1,
nodes: [
{ id: "start", kind: "start", config: {} },
{ id: "execute", kind: "prompt", config: { name: "Execute task" } },
{ id: "end", kind: "end", config: {} },
],
edges: [
{ from: "start", to: "execute" },
{ from: "execute", to: "end" },
],
},
} as WorkflowDefinition;
const selectedWorkflow: WorkflowDefinition = {
id: "WF-001",
name: "Custom Delivery Workflow",
@@ -131,9 +150,19 @@ describe("WorkflowResultsTab", () => {
mockedFetchTaskWorkflow.mockReset();
mockedFetchTaskWorkflow.mockResolvedValue({ workflowId: "WF-001" });
mockedFetchWorkflow.mockReset();
mockedFetchWorkflow.mockResolvedValue(selectedWorkflow);
mockedFetchWorkflow.mockImplementation((workflowId) => Promise.resolve(workflowId === "builtin:coding" ? defaultWorkflow : selectedWorkflow));
mockedFetchWorkflows.mockReset();
mockedFetchWorkflows.mockResolvedValue([selectedWorkflow]);
mockedFetchWorkflows.mockResolvedValue([defaultWorkflow, selectedWorkflow]);
mockedFetchBoardWorkflows.mockReset();
mockedFetchBoardWorkflows.mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{ id: "builtin:coding", name: "Built-in Coding Workflow", columns: [] },
{ id: "WF-001", name: "Custom Delivery Workflow", columns: [] },
],
taskWorkflowIds: {},
});
mockedFetchWorkflowOptionalSteps.mockReset();
mockedFetchWorkflowOptionalSteps.mockResolvedValue([
{
@@ -259,15 +288,94 @@ describe("WorkflowResultsTab", () => {
expect(await screen.findByTestId("react-flow-mock")).toBeInTheDocument();
});
it("shows no workflow assigned when none is selected", async () => {
it("resolves a null task workflow selection through the board default and loads its graph", async () => {
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: null });
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-default" />);
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Built-in Coding Workflow"));
expect(screen.queryByText("No workflow assigned")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("builtin:coding", "project-default"));
expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument();
expect(screen.getByTestId("react-flow-mock")).toHaveTextContent("nodes:");
});
it("keeps an explicit custom workflow ahead of the board default", async () => {
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: "WF-001" });
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-custom" />);
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom Delivery Workflow"));
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("WF-001", "project-custom"));
expect(mockedFetchWorkflow).not.toHaveBeenCalledWith("builtin:coding", "project-custom");
expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument();
});
it("returns to the effective default workflow when an explicit selection is cleared", async () => {
mockedSelectTaskWorkflow.mockResolvedValueOnce({ workflowId: null, enabledWorkflowSteps: [] });
render(
<WorkflowResultsTab
taskId="FN-001"
task={baseTask}
settings={mockSettings}
results={mockResults}
canEdit
projectId="project-cleared"
/>,
);
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom Delivery Workflow"));
fireEvent.change(await screen.findByLabelText("Custom workflow"), { target: { value: "" } });
await waitFor(() => expect(mockedSelectTaskWorkflow).toHaveBeenCalledWith("FN-001", null, "project-cleared"));
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Built-in Coding Workflow"));
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("builtin:coding", "project-cleared"));
expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument();
});
it("shows graph unavailable without crashing for an unknown stale workflow id", async () => {
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: "WF-STALE" });
mockedFetchWorkflows.mockResolvedValueOnce([defaultWorkflow, selectedWorkflow]);
mockedFetchWorkflow.mockImplementation((workflowId) => {
if (workflowId === "WF-STALE") return Promise.reject(new Error("missing workflow"));
return Promise.resolve(workflowId === "builtin:coding" ? defaultWorkflow : selectedWorkflow);
});
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-stale" />);
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom workflow"));
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("WF-STALE", "project-stale"));
expect(await screen.findByTestId("workflow-graph-unavailable")).toHaveTextContent("Workflow graph unavailable");
expect(screen.queryByTestId("workflow-graph-preview")).not.toBeInTheDocument();
});
it("shows no workflow assigned and avoids graph fetch when board workflows provide no usable effective id", async () => {
mockedFetchWorkflows.mockResolvedValueOnce([]);
const { fetchTaskWorkflow } = await import("../../api");
vi.mocked(fetchTaskWorkflow).mockResolvedValueOnce({ workflowId: null });
mockedFetchBoardWorkflows.mockResolvedValueOnce({
flagEnabled: false,
defaultWorkflowId: "",
workflows: [],
taskWorkflowIds: {},
});
mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: null });
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />);
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
expect(await screen.findByTestId("workflow-graph-empty")).toHaveTextContent("No workflow assigned");
expect(mockedFetchWorkflow).not.toHaveBeenCalled();
});
it("shows edit workflow affordance only when editable and workflow selected", async () => {