diff --git a/.changeset/FN-6572-workflow-switch-lane-move.md b/.changeset/FN-6572-workflow-switch-lane-move.md new file mode 100644 index 0000000000..d64b1bb51f --- /dev/null +++ b/.changeset/FN-6572-workflow-switch-lane-move.md @@ -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. diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index 66d215c592..1194b691da 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -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?.(); } }, diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 12ff40d3d6..c18e70f2a2 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -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"])); + }); }); }); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 2496d56bda..9258c32aac 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -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({ diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 9a074c6aaf..d9a551e62c 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -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[] }) => (
nodes:{nodes.length};edges:{edges.length}
@@ -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( + , + ); + + 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( , diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts index 4edc531121..b0bfa9c816 100644 --- a/packages/dashboard/src/__tests__/workflow-routes.test.ts +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -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) ─────────────────────────────────────── diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index 871447bfd6..eb0704793c 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -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;