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; taskPausedReason?: string;
settings?: Settings; settings?: Settings;
onEditWorkflow?: () => void; onEditWorkflow?: () => void;
/** U5 (R20): called after a workflow switch re-homed the card to a new column /** U5 (R20): called after a workflow switch affects board placement
* (reconciliation present and not preserved) so the board can refresh before * (any reconciliation result) so the board can refresh before the SSE
* the SSE catch-up arrives. */ * catch-up arrives; lane membership is keyed by workflow id, not column. */
onWorkflowReconciled?: () => void; onWorkflowReconciled?: () => void;
} }
@@ -363,9 +363,11 @@ export function WorkflowResultsTab({
const res = await selectTaskWorkflow(taskId, workflowId, projectId); const res = await selectTaskWorkflow(taskId, workflowId, projectId);
setSelectedWorkflowId(res.workflowId); setSelectedWorkflowId(res.workflowId);
onWorkflowStepsChange?.(res.enabledWorkflowSteps); 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. FNXC:CustomWorkflows 2026-06-17-07:21:
if (res.reconciliation && !res.reconciliation.preserved) { 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?.(); onWorkflowReconciled?.();
} }
}, },

View File

@@ -1205,5 +1205,55 @@ describe("Board", () => {
}); });
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2)); 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); 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 () => { it("shows a new-workflow action next to the workflow selector", async () => {
const onCreateWorkflow = vi.fn(); const onCreateWorkflow = vi.fn();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({ 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 { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { WorkflowResultsTab } from "../WorkflowResultsTab"; import { WorkflowResultsTab } from "../WorkflowResultsTab";
import { fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchWorkflowOptionalSteps } from "../../api"; import * as api from "../../api";
import { useAgentLogs } from "../../hooks/useAgentLogs"; import { useAgentLogs } from "../../hooks/useAgentLogs";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
import type { AgentLogEntry, Settings, Task, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core"; 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", () => ({ vi.mock("@xyflow/react", () => ({
ReactFlow: ({ nodes = [], edges = [] }: { nodes?: unknown[]; edges?: unknown[] }) => ( ReactFlow: ({ nodes = [], edges = [] }: { nodes?: unknown[]; edges?: unknown[] }) => (
<div data-testid="react-flow-mock">nodes:{nodes.length};edges:{edges.length}</div> <div data-testid="react-flow-mock">nodes:{nodes.length};edges:{edges.length}</div>
@@ -30,10 +19,14 @@ vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(), useAgentLogs: vi.fn(),
})); }));
const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps); const mockedFetchWorkflowSteps = vi.spyOn(api, "fetchWorkflowSteps");
const mockedFetchWorkflow = vi.mocked(fetchWorkflow); const mockedFetchTaskWorkflow = vi.spyOn(api, "fetchTaskWorkflow");
const mockedFetchWorkflows = vi.mocked(fetchWorkflows); const mockedFetchWorkflow = vi.spyOn(api, "fetchWorkflow");
const mockedFetchWorkflowOptionalSteps = vi.mocked(fetchWorkflowOptionalSteps); 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); const mockedUseAgentLogs = vi.mocked(useAgentLogs);
describe("WorkflowResultsTab", () => { describe("WorkflowResultsTab", () => {
@@ -128,9 +121,15 @@ describe("WorkflowResultsTab", () => {
planningModel: "gemini-2.5-flash", planningModel: "gemini-2.5-flash",
} as Settings; } as Settings;
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => { beforeEach(() => {
mockedFetchWorkflowSteps.mockReset(); mockedFetchWorkflowSteps.mockReset();
mockedFetchWorkflowSteps.mockResolvedValue(mockWorkflowSteps); mockedFetchWorkflowSteps.mockResolvedValue(mockWorkflowSteps);
mockedFetchTaskWorkflow.mockReset();
mockedFetchTaskWorkflow.mockResolvedValue({ workflowId: "WF-001" });
mockedFetchWorkflow.mockReset(); mockedFetchWorkflow.mockReset();
mockedFetchWorkflow.mockResolvedValue(selectedWorkflow); mockedFetchWorkflow.mockResolvedValue(selectedWorkflow);
mockedFetchWorkflows.mockReset(); mockedFetchWorkflows.mockReset();
@@ -140,12 +139,18 @@ describe("WorkflowResultsTab", () => {
{ {
templateId: "browser-verification", templateId: "browser-verification",
name: "Browser Verification", name: "Browser Verification",
description: "Verify web application functionality using browser automation", description: "Verify browser flows",
icon: "globe", icon: "globe",
phase: "pre-merge", phase: "pre-merge",
defaultOn: false, 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.mockReset();
mockedUseAgentLogs.mockReturnValue({ mockedUseAgentLogs.mockReturnValue({
entries: [], entries: [],
@@ -298,6 +303,37 @@ describe("WorkflowResultsTab", () => {
expect(onEditWorkflow).toHaveBeenCalledTimes(1); 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 () => { it("shows effective model settings and default fallbacks", async () => {
const { rerender } = render( const { rerender } = render(
<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />, <WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />,

View File

@@ -1,6 +1,6 @@
// @vitest-environment node // @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 express from "express";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -10,6 +10,11 @@ import type { WorkflowIr } from "@fusion/core";
import { registerWorkflowRoutes } from "../routes/register-workflow-routes.js"; import { registerWorkflowRoutes } from "../routes/register-workflow-routes.js";
import { ApiError, sendErrorResponse } from "../api-error.js"; import { ApiError, sendErrorResponse } from "../api-error.js";
import { request } from "../test-request.js"; import { request } from "../test-request.js";
import { emitWorkflowSseEvent } from "../sse.js";
vi.mock("../sse.js", () => ({
emitWorkflowSseEvent: vi.fn(),
}));
function linearIr(): WorkflowIr { function linearIr(): WorkflowIr {
return { return {
@@ -63,7 +68,7 @@ describe("workflow routes (U4)", () => {
const router = express.Router(); const router = express.Router();
registerWorkflowRoutes({ registerWorkflowRoutes({
router, router,
getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }), getProjectContext: async () => ({ store, engine: undefined, projectId: "proj-workflow-routes" }),
rethrowAsApiError: (err: unknown) => { rethrowAsApiError: (err: unknown) => {
throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err)); 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(); store.close();
rmSync(rootDir, { recursive: true, force: true }); rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true }); rmSync(globalDir, { recursive: true, force: true });
vi.mocked(emitWorkflowSseEvent).mockClear();
}); });
const post = (path: string, body: unknown) => const post = (path: string, body: unknown) =>
@@ -468,6 +474,72 @@ describe("workflow routes (U4)", () => {
expect(recon?.toColumn).toBe("intake"); expect(recon?.toColumn).toBe("intake");
expect((await store.getTask(t.id)).column).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) ─────────────────────────────────────── // ── Workflow setting VALUES (U6, R5) ───────────────────────────────────────

View File

@@ -507,7 +507,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
// Body: { workflowId: string | null } // Body: { workflowId: string | null }
router.put("/tasks/:taskId/workflow", async (req, res) => { router.put("/tasks/:taskId/workflow", async (req, res) => {
try { try {
const { store } = await getProjectContext(req); const { store, projectId } = await getProjectContext(req);
const workflowId = (req.body ?? {}).workflowId; const workflowId = (req.body ?? {}).workflowId;
// Only an explicit null clears the selection. An omitted field // Only an explicit null clears the selection. An omitted field
// (e.g. a malformed `{}` body) must fail validation rather than // (e.g. a malformed `{}` body) must fail validation rather than
@@ -517,6 +517,11 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
} }
if (workflowId === null) { if (workflowId === null) {
await store.clearTaskWorkflowSelection(req.params.taskId); 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: [] }); res.json({ workflowId: null, enabledWorkflowSteps: [] });
return; return;
} }
@@ -542,6 +547,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
} }
throw selectErr; throw selectErr;
} }
emitWorkflowSseEvent("workflow:updated", { taskId: req.params.taskId, workflowId }, projectId);
res.json({ workflowId, enabledWorkflowSteps, ...(reconciliation ? { reconciliation } : {}) }); res.json({ workflowId, enabledWorkflowSteps, ...(reconciliation ? { reconciliation } : {}) });
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) throw err; if (err instanceof ApiError) throw err;