FN-6572: refresh boards after workflow changes

Workflow changes now notify open task views to re-home cards across workflow lanes promptly.

- Emit workflow update SSE events when selecting or clearing a task workflow.
- Refresh task detail board data for any workflow reconciliation result, including preserved columns.
- Cover board, list, route, and workflow results behavior with regression tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/FN-6572-workflow-switch-lane-move.md    |  5 ++
 .../app/components/WorkflowResultsTab.tsx          | 14 ++--
 .../app/components/__tests__/Board.test.tsx        | 50 ++++++++++++++
 .../app/components/__tests__/ListView.test.tsx     | 62 ++++++++++++++++++
 .../__tests__/WorkflowResultsTab.test.tsx          | 72 +++++++++++++++-----
 .../src/__tests__/workflow-routes.test.ts          | 76 +++++++++++++++++++++-
 .../src/routes/register-workflow-routes.ts         |  8 ++-
 7 files changed, 260 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-6572
Fusion-Task-Lineage: c2a9d5a8-6aba-4bce-991f-4a36b941a56f
This commit is contained in:
gsxdsm
2026-06-17 07:55:53 -07:00
parent f16a57a466
commit 265d9ec4d3
7 changed files with 260 additions and 27 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix task workflow selection so successful workflow changes and clears notify dashboard clients to refresh board workflow lanes.

View File

@@ -54,9 +54,9 @@ interface WorkflowResultsTabProps {
taskPausedReason?: string;
settings?: Settings;
onEditWorkflow?: () => void;
/** U5 (R20): called after a workflow switch re-homed the card to a new column
* (reconciliation present and not preserved) so the board can refresh before
* the SSE catch-up arrives. */
/** U5 (R20): called after a workflow switch affects board placement
* (any reconciliation result) so the board can refresh before the SSE
* catch-up arrives; lane membership is keyed by workflow id, not column. */
onWorkflowReconciled?: () => void;
}
@@ -363,9 +363,11 @@ export function WorkflowResultsTab({
const res = await selectTaskWorkflow(taskId, workflowId, projectId);
setSelectedWorkflowId(res.workflowId);
onWorkflowStepsChange?.(res.enabledWorkflowSteps);
// U5 (R20): the switch re-homed the card to a new column — refresh the
// board now rather than waiting for the SSE catch-up.
if (res.reconciliation && !res.reconciliation.preserved) {
/*
FNXC:CustomWorkflows 2026-06-17-07:21:
A workflow switch can move the card to a different board lane even when reconciliation preserves the column, because lane membership is keyed by workflow id rather than column id. Refresh the task detail for any reconciliation result so the detail modal pushes the board update before SSE catch-up.
*/
if (res.reconciliation) {
onWorkflowReconciled?.();
}
},

View File

@@ -1205,5 +1205,55 @@ describe("Board", () => {
});
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2));
});
it("re-homes a preserved-column task to the new workflow after workflow invalidation", async () => {
const preservedWorkflow = {
id: "wf-preserved",
name: "Preserved Flow",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
fetchBoardWorkflowsMock
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [DEFAULT_WORKFLOW, preservedWorkflow],
taskWorkflowIds: { "FN-1": "builtin:coding" },
})
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [DEFAULT_WORKFLOW, preservedWorkflow],
taskWorkflowIds: { "FN-1": "wf-preserved" },
});
renderBoard({ projectId: "proj-1", tasks: [{
id: "FN-1",
title: "Preserved switcher",
description: "d",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as Task] });
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]));
expect(selector.value).toBe("builtin:coding");
await act(async () => {
sseHandlers["workflow:updated"]?.();
});
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual([]));
fireEvent.change(selector, { target: { value: "wf-preserved" } });
await waitFor(() => expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]));
});
});
});

View File

@@ -639,6 +639,68 @@ describe("ListView", () => {
expect(screen.queryAllByText("Backlog")).toHaveLength(0);
});
it("re-homes a preserved-column task to the new workflow after workflow invalidation", async () => {
const preservedWorkflow = {
id: "wf-preserved",
name: "Preserved Flow",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
vi.mocked(fetchBoardWorkflows)
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
preservedWorkflow,
],
taskWorkflowIds: { "FN-001": "builtin:coding" },
})
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
preservedWorkflow,
],
taskWorkflowIds: { "FN-001": "wf-preserved" },
});
renderListView({
tasks: [createMockTask({ id: "FN-001", column: "todo", title: "Preserved workflow task" })],
});
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
await waitFor(() => expect(screen.getByText("Preserved workflow task")).toBeInTheDocument());
expect(selector.value).toBe("builtin:coding");
await act(async () => {
listViewSseHandlers["workflow:updated"]?.();
});
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByText("Preserved workflow task")).not.toBeInTheDocument());
fireEvent.change(selector, { target: { value: "wf-preserved" } });
await waitFor(() => expect(screen.getByText("Preserved workflow task")).toBeInTheDocument());
});
it("shows a new-workflow action next to the workflow selector", async () => {
const onCreateWorkflow = vi.fn();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({

View File

@@ -1,22 +1,11 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { WorkflowResultsTab } from "../WorkflowResultsTab";
import { fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchWorkflowOptionalSteps } from "../../api";
import * as api from "../../api";
import { useAgentLogs } from "../../hooks/useAgentLogs";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
import type { AgentLogEntry, Settings, Task, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn(),
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: "WF-001" }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: "WF-001", enabledWorkflowSteps: [] }),
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchWorkflow: vi.fn(),
fetchWorkflowOptionalSteps: vi.fn(),
submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }),
approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }),
}));
vi.mock("@xyflow/react", () => ({
ReactFlow: ({ nodes = [], edges = [] }: { nodes?: unknown[]; edges?: unknown[] }) => (
<div data-testid="react-flow-mock">nodes:{nodes.length};edges:{edges.length}</div>
@@ -30,10 +19,14 @@ vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(),
}));
const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps);
const mockedFetchWorkflow = vi.mocked(fetchWorkflow);
const mockedFetchWorkflows = vi.mocked(fetchWorkflows);
const mockedFetchWorkflowOptionalSteps = vi.mocked(fetchWorkflowOptionalSteps);
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 mockedFetchWorkflowOptionalSteps = vi.spyOn(api, "fetchWorkflowOptionalSteps");
const mockedSelectTaskWorkflow = vi.spyOn(api, "selectTaskWorkflow");
const mockedSubmitTaskWorkflowInput = vi.spyOn(api, "submitTaskWorkflowInput");
const mockedApproveTaskWorkflowCli = vi.spyOn(api, "approveTaskWorkflowCli");
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
describe("WorkflowResultsTab", () => {
@@ -128,9 +121,15 @@ describe("WorkflowResultsTab", () => {
planningModel: "gemini-2.5-flash",
} as Settings;
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
mockedFetchWorkflowSteps.mockReset();
mockedFetchWorkflowSteps.mockResolvedValue(mockWorkflowSteps);
mockedFetchTaskWorkflow.mockReset();
mockedFetchTaskWorkflow.mockResolvedValue({ workflowId: "WF-001" });
mockedFetchWorkflow.mockReset();
mockedFetchWorkflow.mockResolvedValue(selectedWorkflow);
mockedFetchWorkflows.mockReset();
@@ -140,12 +139,18 @@ describe("WorkflowResultsTab", () => {
{
templateId: "browser-verification",
name: "Browser Verification",
description: "Verify web application functionality using browser automation",
description: "Verify browser flows",
icon: "globe",
phase: "pre-merge",
defaultOn: false,
},
]);
mockedSelectTaskWorkflow.mockReset();
mockedSelectTaskWorkflow.mockResolvedValue({ workflowId: "WF-001", enabledWorkflowSteps: [] });
mockedSubmitTaskWorkflowInput.mockReset();
mockedSubmitTaskWorkflowInput.mockResolvedValue({ ok: true });
mockedApproveTaskWorkflowCli.mockReset();
mockedApproveTaskWorkflowCli.mockResolvedValue({ approved: "ok" });
mockedUseAgentLogs.mockReset();
mockedUseAgentLogs.mockReturnValue({
entries: [],
@@ -298,6 +303,37 @@ describe("WorkflowResultsTab", () => {
expect(onEditWorkflow).toHaveBeenCalledTimes(1);
});
it("calls onWorkflowReconciled for preserved-column workflow switches", async () => {
const onWorkflowReconciled = vi.fn();
const onWorkflowStepsChange = vi.fn();
const destinationWorkflow = { ...selectedWorkflow, id: "WF-002", name: "Preserved Column Workflow" };
mockedFetchWorkflows.mockResolvedValueOnce([selectedWorkflow, destinationWorkflow]);
mockedSelectTaskWorkflow.mockResolvedValueOnce({
workflowId: "WF-002",
enabledWorkflowSteps: ["WS-101"],
reconciliation: { preserved: true, fromColumn: "todo", toColumn: "todo" },
});
render(
<WorkflowResultsTab
taskId="FN-001"
task={baseTask}
settings={mockSettings}
results={mockResults}
canEdit
onWorkflowStepsChange={onWorkflowStepsChange}
onWorkflowReconciled={onWorkflowReconciled}
/>,
);
const selector = await screen.findByLabelText("Custom workflow");
fireEvent.change(selector, { target: { value: "WF-002" } });
await waitFor(() => expect(mockedSelectTaskWorkflow).toHaveBeenCalledWith("FN-001", "WF-002", undefined));
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-101"]);
expect(onWorkflowReconciled).toHaveBeenCalledTimes(1);
});
it("shows effective model settings and default fallbacks", async () => {
const { rerender } = render(
<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />,

View File

@@ -1,6 +1,6 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -10,6 +10,11 @@ import type { WorkflowIr } from "@fusion/core";
import { registerWorkflowRoutes } from "../routes/register-workflow-routes.js";
import { ApiError, sendErrorResponse } from "../api-error.js";
import { request } from "../test-request.js";
import { emitWorkflowSseEvent } from "../sse.js";
vi.mock("../sse.js", () => ({
emitWorkflowSseEvent: vi.fn(),
}));
function linearIr(): WorkflowIr {
return {
@@ -63,7 +68,7 @@ describe("workflow routes (U4)", () => {
const router = express.Router();
registerWorkflowRoutes({
router,
getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }),
getProjectContext: async () => ({ store, engine: undefined, projectId: "proj-workflow-routes" }),
rethrowAsApiError: (err: unknown) => {
throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err));
},
@@ -79,6 +84,7 @@ describe("workflow routes (U4)", () => {
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
vi.mocked(emitWorkflowSseEvent).mockClear();
});
const post = (path: string, body: unknown) =>
@@ -468,6 +474,72 @@ describe("workflow routes (U4)", () => {
expect(recon?.toColumn).toBe("intake");
expect((await store.getTask(t.id)).column).toBe("intake");
});
it("PUT selection emits workflow invalidation SSE after a re-home", async () => {
const wf = await post("/api/workflows", { name: "emit-rehome", ir: customV2("emit-rehome", ["intake", "doing", "done"]) });
const wfId = (wf.body as { id: string }).id;
const t = await store.createTask({ description: "switcher" });
await store.moveTask(t.id, "todo", { moveSource: "user" });
vi.mocked(emitWorkflowSseEvent).mockClear();
const res = await put(`/api/tasks/${t.id}/workflow`, { workflowId: wfId });
expect(res.status).toBe(200);
expect(emitWorkflowSseEvent).toHaveBeenCalledTimes(1);
expect(emitWorkflowSseEvent).toHaveBeenCalledWith(
"workflow:updated",
{ taskId: t.id, workflowId: wfId },
"proj-workflow-routes",
);
});
it("PUT selection emits workflow invalidation SSE when the current column is preserved", async () => {
const wf = await post("/api/workflows", { name: "emit-preserved", ir: customV2("emit-preserved", ["intake", "todo", "done"]) });
const wfId = (wf.body as { id: string }).id;
const t = await store.createTask({ description: "preserved switcher" });
await store.moveTask(t.id, "todo", { moveSource: "user" });
vi.mocked(emitWorkflowSseEvent).mockClear();
const res = await put(`/api/tasks/${t.id}/workflow`, { workflowId: wfId });
expect(res.status).toBe(200);
expect((res.body as { reconciliation?: { preserved: boolean } }).reconciliation?.preserved).toBe(true);
expect(emitWorkflowSseEvent).toHaveBeenCalledTimes(1);
expect(emitWorkflowSseEvent).toHaveBeenCalledWith(
"workflow:updated",
{ taskId: t.id, workflowId: wfId },
"proj-workflow-routes",
);
});
it("PUT clear emits workflow invalidation SSE with a null workflow id", async () => {
const wf = await post("/api/workflows", { name: "emit-clear", ir: customV2("emit-clear", ["intake", "doing", "done"]) });
const wfId = (wf.body as { id: string }).id;
const t = await store.createTask({ description: "clear switcher" });
await store.selectTaskWorkflowAndReconcile(t.id, wfId);
vi.mocked(emitWorkflowSseEvent).mockClear();
const res = await put(`/api/tasks/${t.id}/workflow`, { workflowId: null });
expect(res.status).toBe(200);
expect(emitWorkflowSseEvent).toHaveBeenCalledTimes(1);
expect(emitWorkflowSseEvent).toHaveBeenCalledWith(
"workflow:updated",
{ taskId: t.id, workflowId: null },
"proj-workflow-routes",
);
});
it("PUT selection validation errors do not emit workflow invalidation SSE", async () => {
const t = await store.createTask({ description: "invalid switcher" });
const missing = await put(`/api/tasks/${t.id}/workflow`, {});
const invalid = await put(`/api/tasks/${t.id}/workflow`, { workflowId: 42 });
expect(missing.status).toBe(400);
expect(invalid.status).toBe(400);
expect(emitWorkflowSseEvent).not.toHaveBeenCalled();
});
});
// ── Workflow setting VALUES (U6, R5) ───────────────────────────────────────

View File

@@ -507,7 +507,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
// Body: { workflowId: string | null }
router.put("/tasks/:taskId/workflow", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const { store, projectId } = await getProjectContext(req);
const workflowId = (req.body ?? {}).workflowId;
// Only an explicit null clears the selection. An omitted field
// (e.g. a malformed `{}` body) must fail validation rather than
@@ -517,6 +517,11 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
if (workflowId === null) {
await store.clearTaskWorkflowSelection(req.params.taskId);
/*
FNXC:CustomWorkflows 2026-06-17-07:21:
A task-workflow selection or clear changes board lane membership even when the task column is unchanged, because workflow boards group cards by the board-workflows `taskWorkflowIds` mapping. Emit the existing workflow update invalidation after successful mutations so open Board and ListView surfaces refetch that mapping and re-home the card immediately.
*/
emitWorkflowSseEvent("workflow:updated", { taskId: req.params.taskId, workflowId: null }, projectId);
res.json({ workflowId: null, enabledWorkflowSteps: [] });
return;
}
@@ -542,6 +547,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
throw selectErr;
}
emitWorkflowSseEvent("workflow:updated", { taskId: req.params.taskId, workflowId }, projectId);
res.json({ workflowId, enabledWorkflowSteps, ...(reconciliation ? { reconciliation } : {}) });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;