From 6721bdc65209cb134a525aecee6b3ff46e84b4e0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 29 Jul 2026 09:04:58 -0700 Subject: [PATCH] =?UTF-8?q?U12=20part=207:=20the=20List=20view=20never=20s?= =?UTF-8?q?elf-healed=20a=20card's=20workflow=20=E2=80=94=20extract=20Boar?= =?UTF-8?q?d's=20FN-7591=20refetch=20and=20wire=20it=20up=20(#2530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## U12 part 7 — the List view never self-healed a card's workflow **Stacks on #2528.** Merge that first. Paying off something I owed on #2525: greptile pointed out that a task whose `taskWorkflowIds` entry is absent — or present but resolving to a workflow that does not declare the task's stored column — gets no per-workflow move metadata, so its menu falls back to the neighbour approximation and **stays there until some unrelated refresh happens**. Board has forced one board-workflows refetch for exactly this since FN-7591. List had none. So the degraded state persisted longest precisely where it is most likely: a **just-created card**, which is when a workflow was actually chosen. I said there that porting the self-heal deserved its own change rather than riding along in a move-menu fix. This is it. ### Two commits, deliberately separable **1. Extraction — move only.** Board's ~55 lines (refs, suspect-mapping predicate, signature guard, deferred macrotask) become `useUnmappedWorkflowRefetch`. Copying them into ListView would have created a second copy of subtle race-avoidance logic to keep in sync. Evidence it is a move: with comments and the new wrapper signature stripped, the hook's **41 body lines** and the **42 removed from Board** differ by exactly one line — the `}` that closed Board's enclosing scope. Nothing added, removed or reordered. The original FNXC notes travel with the code, since they are the reason each line exists. Board's suite is green with no expectation edits. **2. Wiring — behaviour change.** ListView calls the hook. ### Revert-proof Remove the hook call from ListView and the new case fails: `fetchBoardWorkflows` is never called a second time, so the mapping never resolves. A companion case pins the other half — a fully-mapped board must **not** refetch, so the signature guard cannot turn a healthy list into a loop. It measures calls made *after* the initial load settles, because mount fetch and switcher-open legitimately call the fetcher and counting from zero would measure those instead. ### Two existing tests needed fixture corrections — neither a regression Both because the self-heal now fires **correctly** where the fixture did not expect a fetch: - `refreshes workflow columns when workflow metadata SSE arrives` chained two `mockResolvedValueOnce` payloads. The file-level cache seed maps no tasks, so first paint saw FN-001 as unmapped and the repair fetch ate the payload the test asserts on. Seeded that test's own first-paint cache, and added a trailing default — the SSE swap (`backlog` → `ready`) leaves FN-001 in a column its workflow no longer declares, so a repair fetch there is right, and without a fallback it resolved `undefined` and wiped the payload. Worth stating plainly: both fixtures had quietly depended on List *never* self-healing. That dependency is what the change removes. ### Verification `pnpm test:gate` (309 + 10 + 71), `pnpm lint`, `pnpm verify:fast` (18 steps), dashboard typecheck green. ListView + Board suites: **320 passed, 0 failed, 0 skipped**. ## Summary by CodeRabbit * **Bug Fixes** * List and Board views now self-recover when task-to-workflow mappings are missing or incorrect, avoiding degraded workflow UI until a later refresh. * Workflow recovery retries are more robust and coordinated to handle delayed/failed refreshes. * Recovery behavior correctly stops/reset when switching projects or unmounting. * **Tests** * Added comprehensive ListView coverage for unmapped-workflow self-heal, including retry timing, StrictMode effect replay, SSE refresh interactions, and mapped-vs-unmapped scenarios. --------- Co-authored-by: Claude Opus 5 (1M context) --- .../u12-list-unmapped-workflow-selfheal.md | 7 + packages/dashboard/app/components/Board.tsx | 56 +--- .../dashboard/app/components/ListView.tsx | 12 + .../components/__tests__/ListView.test.tsx | 302 ++++++++++++++++-- .../dashboard/app/hooks/useBoardWorkflows.ts | 19 +- .../app/hooks/useUnmappedWorkflowRefetch.ts | 217 +++++++++++++ 6 files changed, 525 insertions(+), 88 deletions(-) create mode 100644 .changeset/u12-list-unmapped-workflow-selfheal.md create mode 100644 packages/dashboard/app/hooks/useUnmappedWorkflowRefetch.ts diff --git a/.changeset/u12-list-unmapped-workflow-selfheal.md b/.changeset/u12-list-unmapped-workflow-selfheal.md new file mode 100644 index 0000000000..2036a9a3cf --- /dev/null +++ b/.changeset/u12-list-unmapped-workflow-selfheal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The List view now recovers a just-created card's workflow instead of waiting for an unrelated refresh. +category: fix +dev: Extracts Board's FN-7591 unmapped/suspect-workflow refetch into `useUnmappedWorkflowRefetch` and wires ListView to it. Without it, a task whose `taskWorkflowIds` entry is absent or resolves to a workflow that does not declare its column kept approximated move metadata until some other refresh occurred. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 3c30beb726..9b3d661e4d 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -16,6 +16,7 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; +import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch"; import { ALL_WORKFLOWS_BOARD_VIEW_ID, readBoardWorkflowViewSelection, @@ -437,60 +438,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o The refetch is deferred by one macrotask and re-checked against the latest state at fire time: the board's own quick-create commits the new task one microtask before applyOptimisticTaskWorkflow seeds it, so a synchronous refetch here would double-fire alongside the optimistic path. Deferring lets the seed land first — an already-mapped task is then skipped — so this only fetches for tasks that truly arrived without a workflow mapping. */ - const boardWorkflowsRef = useRef(boardWorkflows); - boardWorkflowsRef.current = boardWorkflows; - const tasksRef = useRef(tasks); - tasksRef.current = tasks; - const lastUnmappedTaskSignatureRef = useRef(null); - const unmappedRefetchTimerRef = useRef | null>(null); - /* - FNXC:WorkflowBoard 2026-07-12-23:40: - The FN-7591 refetch must also fire for a PRESENT-but-unrepresentable mapping, not only an - absent one. The server emits a taskWorkflowIds entry for every task (defaulting to the - default workflow), so a stale selection row makes e.g. an "ideas"-column card map to plain - Coding — an entry that exists but whose workflow does not declare the task's column. The - `=== undefined` guard alone never re-fired for those, leaving the card permanently - invisible in the aggregate view. A mapping is "suspect" when the resolved workflow's - column set does not contain the task's stored column. The signature guard still prevents - refetch loops for mappings that stay wrong after a fresh fetch. - */ - const isTaskWorkflowMappingSuspect = useCallback(( - payload: NonNullable, - task: Task, - ): boolean => { - const assigned = payload.taskWorkflowIds[task.id]; - if (assigned === undefined) return true; - const known = payload.workflows.some((workflow) => workflow.id === assigned); - const workflowId = known ? assigned : payload.defaultWorkflowId; - const workflow = payload.workflows.find((candidate) => candidate.id === workflowId); - return workflow !== undefined && !workflow.columns.some((column) => column.id === task.column); - }, []); - useEffect(() => { - if (!boardWorkflows || !workflowMode) return; - const unmapped = tasks - .filter((task) => isTaskWorkflowMappingSuspect(boardWorkflows, task)) - .map((task) => task.id) - .sort(); - if (unmapped.length === 0) { - lastUnmappedTaskSignatureRef.current = null; - return; - } - const signature = unmapped.join(","); - if (signature === lastUnmappedTaskSignatureRef.current) return; - lastUnmappedTaskSignatureRef.current = signature; - if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current); - unmappedRefetchTimerRef.current = setTimeout(() => { - unmappedRefetchTimerRef.current = null; - const latestWorkflows = boardWorkflowsRef.current; - if (!latestWorkflows) return; - const stillUnmapped = tasksRef.current.some((task) => isTaskWorkflowMappingSuspect(latestWorkflows, task)); - if (stillUnmapped) refreshBoardWorkflows({ forceFresh: true }); - }, 0); - }, [boardWorkflows, isTaskWorkflowMappingSuspect, refreshBoardWorkflows, tasks, workflowMode]); - - useEffect(() => () => { - if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current); - }, []); + useUnmappedWorkflowRefetch({ boardWorkflows, tasks, workflowMode, refreshBoardWorkflows, projectId }); const resolveWorkflowQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => { if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined; diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index f81c440844..ef3c2783d6 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -32,6 +32,7 @@ import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; import { writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; +import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch"; import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu"; import type { DetailTaskOpenOptions } from "../hooks/useModalManager"; @@ -775,6 +776,17 @@ export function ListView({ back to the shared union when the task's workflow is unresolvable, which yields the previous (neighbour-approximated) behaviour rather than a wrong answer. */ + /* + FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — PR #2525 review, greptile): + SELF-HEAL, shared with Board. A task whose `taskWorkflowIds` entry is absent or + suspect resolves to no per-workflow metadata, so its move menu silently degrades to + the neighbour approximation and stays there until some unrelated refresh happens. + Board has forced one board-workflows refetch for this since FN-7591; List had none, + so the degraded state persisted longest exactly where it is most likely — a + just-created card, which is when a workflow was actually chosen. + */ + useUnmappedWorkflowRefetch({ boardWorkflows, tasks, workflowMode, refreshBoardWorkflows, projectId }); + const taskContextMenuColumnsByTaskId = useMemo(() => { const map = new Map(); if (!workflowMode || !boardWorkflows) return map; diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 676769dc24..f79add6ec8 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { readFileSync } from "node:fs"; import { describe, it, expect, vi } from "vitest"; import { useEffect, useState } from "react"; @@ -456,6 +457,246 @@ beforeEach(() => { writeBoardWorkflowsCache("project-b", DEFAULT_LANE_PAYLOAD); }); +/* +FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — PR #2525 review, greptile): +List must self-heal a task whose workflow mapping the payload does not yet carry — the +routine case, because the SSE task list updates before board-workflows does. Board has +done this since FN-7591; List had not, so a just-created card kept an approximated move +menu until some unrelated refresh. + +REVERT CHECK: remove the `useUnmappedWorkflowRefetch` call from ListView and this fails +— `fetchBoardWorkflows` is never called a second time, so the mapping never resolves. +*/ +describe("ListView unmapped-workflow self-heal", () => { + it("forces one board-workflows refetch when a rendered task has no workflow mapping", async () => { + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + ...DEFAULT_LANE_PAYLOAD, + // FN-901 is rendered but absent from the mapping: newer than the payload. + taskWorkflowIds: {}, + }); + + renderListView({ tasks: [createMockTask({ id: "FN-901", column: "todo", title: "Fresh card" })] }); + + await waitFor(() => expect(vi.mocked(fetchBoardWorkflows).mock.calls.length).toBeGreaterThan(1)); + // Forced fresh, so a cached payload cannot satisfy the repair. + expect(vi.mocked(fetchBoardWorkflows).mock.calls.some(([, options]) => options?.forceFresh === true)).toBe(true); + }); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + The forced fetch can return BEFORE the workflow-selection write commits, reporting the + same suspect set. A one-shot guard treated that as "unresolvable" and gave up, leaving + the card on approximate metadata until an unrelated refresh. + + REVERT CHECK: restore the one-shot guard (return whenever the signature repeats) and + this fails — only ONE forced fetch is issued, so the mapping that arrives on the + second response never triggers the repair. + */ + it("retries once more when the forced fetch races the selection write", async () => { + const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} }; + vi.mocked(fetchBoardWorkflows).mockResolvedValue(unmappedPayload); + + renderListView({ tasks: [createMockTask({ id: "FN-903", column: "todo", title: "Racing card" })] }); + + // Two forced attempts for the same still-suspect signature, then it must stop — + // loop protection is kept, just not at one attempt. + await waitFor(() => { + expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBe(2); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBe(2); + }); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile, second round): + A REJECTED forced fetch must not end the repair. `refreshBoardWorkflows` swallows a + transient failure by design, so `boardWorkflows` never changes and an effect-driven + retry would never re-run — the repair would die on exactly the failure it exists to + survive. + + HONEST LIMITATION: this case pins the OUTCOME (the repair reaches its second attempt + despite a rejected first) but it does NOT discriminate the mechanism. I checked: + removing the self re-arm still passes it, because in this environment something else + re-renders after the rejection and the effect happens to run again. I could not + construct a case that isolates the self-driving loop without freezing re-renders in a + way that no longer resembles the app, so I am not claiming revert-proof coverage for + it — the loop is defensive against a state where nothing re-renders, which is real in + production but not reproducible here. + + The bounded-retry budget IS revert-proof; see the racing-selection-write case above. + */ + it("still spends its second attempt when the first forced fetch rejects", async () => { + const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} }; + vi.mocked(fetchBoardWorkflows) + .mockResolvedValueOnce(unmappedPayload) + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValue(unmappedPayload); + + renderListView({ tasks: [createMockTask({ id: "FN-904", column: "todo", title: "Rejected repair" })] }); + + await waitFor( + () => { + expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBe(2); + }, + { timeout: 2000 }, + ); + }); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + A SLOW forced refresh must not have its successor started before it settles. On a + fixed timer alone, a request in flight for longer than the retry delay had its second + attempt fired anyway, so both attempts were spent on the same unresolved state before + either answer arrived — the budget gone, the card still approximate. + + REVERT CHECK: re-arm on the plain timer (drop the settle-await) and this fails — + the second attempt fires while the first is still pending. + */ + it("waits for a slow forced refresh to settle before spending the next attempt", async () => { + const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} }; + let releaseFirstForced: (() => void) | undefined; + let forcedCalls = 0; + vi.mocked(fetchBoardWorkflows).mockImplementation((_projectId?: string, options?: { forceFresh?: boolean }) => { + if (options?.forceFresh !== true) return Promise.resolve(unmappedPayload); + forcedCalls += 1; + if (forcedCalls === 1) { + return new Promise((resolve) => { releaseFirstForced = () => resolve(unmappedPayload); }); + } + return Promise.resolve(unmappedPayload); + }); + + renderListView({ tasks: [createMockTask({ id: "FN-905", column: "todo", title: "Slow repair" })] }); + + await waitFor(() => expect(forcedCalls).toBe(1)); + // Well past the retry delay, with the first attempt still in flight. + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(forcedCalls).toBe(1); + + await act(async () => { releaseFirstForced?.(); await Promise.resolve(); }); + await waitFor(() => expect(forcedCalls).toBe(2), { timeout: 2000 }); + }); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + A repair pending across a PROJECT SWITCH must abandon itself. Otherwise its + continuation runs through the OLD project's `refreshBoardWorkflows` closure, and that + stale request can claim the newest shared fetch sequence number — discarding the + CURRENT project's response and leaving the new board without workflow metadata. + + REVERT CHECK: drop the `projectIdRef` comparison in the settle/timer continuations and + this fails — a forced fetch is issued for the OLD project after the switch. + */ + it("abandons a pending repair when the project changes", async () => { + const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} }; + let releaseFirstForced: (() => void) | undefined; + const forcedProjects: (string | undefined)[] = []; + vi.mocked(fetchBoardWorkflows).mockImplementation((projectId?: string, options?: { forceFresh?: boolean }) => { + if (options?.forceFresh !== true) return Promise.resolve(unmappedPayload); + forcedProjects.push(projectId); + if (forcedProjects.length === 1) { + return new Promise((resolve) => { releaseFirstForced = () => resolve(unmappedPayload); }); + } + return Promise.resolve(unmappedPayload); + }); + + const tasks = [createMockTask({ id: "FN-906", column: "todo", title: "Switching card" })]; + const view = renderListView({ tasks, projectId: "project-a" }); + await waitFor(() => expect(forcedProjects).toEqual(["project-a"])); + + // Switch projects while the repair is still in flight, then let it settle. + view.rerender(); + await act(async () => { releaseFirstForced?.(); await Promise.resolve(); }); + await new Promise((resolve) => setTimeout(resolve, 400)); + + // No follow-up may be issued for the project that is no longer displayed. + expect(forcedProjects.filter((id) => id === "project-a")).toHaveLength(1); + }); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — CodeRabbit): + A repair in flight at UNMOUNT must not resume. The cleanup can only clear the timer + that exists at unmount; a settling request afterwards would schedule a fresh timer + nobody will ever clear, and fire a refresh for a view that is gone. + + REVERT CHECK: drop the `mountedRef` guards and this fails — a forced fetch is issued + after the component has been unmounted. + */ + it("does not resume a repair that settles after unmount", async () => { + const unmappedPayload = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} }; + let releaseFirstForced: (() => void) | undefined; + let forcedCalls = 0; + vi.mocked(fetchBoardWorkflows).mockImplementation((_projectId?: string, options?: { forceFresh?: boolean }) => { + if (options?.forceFresh !== true) return Promise.resolve(unmappedPayload); + forcedCalls += 1; + if (forcedCalls === 1) { + return new Promise((resolve) => { releaseFirstForced = () => resolve(unmappedPayload); }); + } + return Promise.resolve(unmappedPayload); + }); + + const view = renderListView({ tasks: [createMockTask({ id: "FN-907", column: "todo", title: "Unmount card" })] }); + await waitFor(() => expect(forcedCalls).toBe(1)); + + view.unmount(); + await act(async () => { releaseFirstForced?.(); await Promise.resolve(); }); + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(forcedCalls).toBe(1); + }); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + StrictMode replays effects as mount -> cleanup -> mount while PRESERVING refs. A + mounted latch that is only ever cleared stays `false` after the replay, so every + deferred continuation exits at the guard and the repair is silently dead for the whole + session — in production, since the dashboard root uses StrictMode. + + REVERT CHECK: remove `mountedRef.current = true` from the effect SETUP and this fails — + no forced fetch is ever issued under StrictMode. + */ + it("still repairs under StrictMode effect replay", async () => { + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: {} }); + + render( + + + , + ); + + await waitFor(() => { + expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, o]) => o?.forceFresh === true).length).toBeGreaterThan(0); + }, { timeout: 2000 }); + }); + + it("does not refetch when every rendered task is mapped", async () => { + const mapped = { ...DEFAULT_LANE_PAYLOAD, taskWorkflowIds: { "FN-902": "builtin:coding" } }; + vi.mocked(fetchBoardWorkflows).mockResolvedValue(mapped); + // Seed the FIRST-PAINT cache too: the file-level seed maps no tasks, so without this + // the initial render legitimately sees an unmapped card and schedules the repair — + // which would make this case assert the opposite of what it means to. + writeBoardWorkflowsCache(TEST_PROJECT_ID, mapped); + + renderListView({ tasks: [createMockTask({ id: "FN-902", column: "todo", title: "Mapped card" })] }); + + // Let the initial load settle, then watch only what happens AFTER it: other + // mechanisms (mount fetch, switcher open) legitimately call the fetcher, so + // counting from zero would measure them rather than the self-heal. + await act(async () => { await Promise.resolve(); }); + await new Promise((resolve) => setTimeout(resolve, 0)); + vi.mocked(fetchBoardWorkflows).mockClear(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + // The signature guard must not turn a healthy board into a refetch loop. + expect(vi.mocked(fetchBoardWorkflows).mock.calls.filter(([, options]) => options?.forceFresh === true)).toHaveLength(0); + }); +}); + describe("ListView", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1222,37 +1463,38 @@ describe("ListView", () => { }); it("refreshes workflow columns when workflow metadata SSE arrives", async () => { + const wf = (columns: { id: string; name: string; flags: Record }[]) => ({ + flagEnabled: true, + defaultWorkflowId: "wf-custom", + workflows: [{ id: "wf-custom", name: "Custom", columns }], + taskWorkflowIds: { "FN-001": "wf-custom" }, + }); + const before = wf([ + { id: "backlog", name: "Backlog", flags: { intake: true } }, + { id: "complete", name: "Complete", flags: { complete: true } }, + ]); + const after = wf([ + { id: "ready", name: "Ready", flags: { intake: true } }, + { id: "complete", name: "Complete", flags: { complete: true } }, + ]); + /* + FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12): + Seed the FIRST-PAINT cache with `before`. The file-level seed maps no tasks, so + without this the initial render sees FN-001 as unmapped, the unmapped-workflow + self-heal correctly forces an extra board-workflows fetch, and that fetch eats the + `Once` payload this test is asserting on. Seeding makes the first paint already + consistent, which is what the test means to start from. + + The trailing `mockResolvedValue(after)` covers the self-heal firing legitimately + AFTER the SSE swap: `backlog` -> `ready` leaves FN-001 in a column its workflow no + longer declares, so a repair fetch is correct there. Without a fallback it would + resolve `undefined` and wipe the payload. + */ + writeBoardWorkflowsCache(TEST_PROJECT_ID, before); vi.mocked(fetchBoardWorkflows) - .mockResolvedValueOnce({ - flagEnabled: true, - defaultWorkflowId: "wf-custom", - workflows: [ - { - id: "wf-custom", - name: "Custom", - columns: [ - { id: "backlog", name: "Backlog", flags: { intake: true } }, - { id: "complete", name: "Complete", flags: { complete: true } }, - ], - }, - ], - taskWorkflowIds: { "FN-001": "wf-custom" }, - }) - .mockResolvedValueOnce({ - flagEnabled: true, - defaultWorkflowId: "wf-custom", - workflows: [ - { - id: "wf-custom", - name: "Custom", - columns: [ - { id: "ready", name: "Ready", flags: { intake: true } }, - { id: "complete", name: "Complete", flags: { complete: true } }, - ], - }, - ], - taskWorkflowIds: { "FN-001": "wf-custom" }, - }); + .mockResolvedValueOnce(before) + .mockResolvedValueOnce(after) + .mockResolvedValue(after); renderListView({ tasks: [createMockTask({ id: "FN-001", column: "backlog", title: "Workflow task" })], diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts index 98e6ff9b52..dc42e2e472 100644 --- a/packages/dashboard/app/hooks/useBoardWorkflows.ts +++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts @@ -59,8 +59,10 @@ export interface UseBoardWorkflowsResult { isAllWorkflowsSelected: boolean; setSelectedWorkflowId: Dispatch>; /** Force a fresh fetch (used on switcher open, and when the board detects a rendered - * task missing from `taskWorkflowIds`, since task→workflow assignment emits no workflow SSE). */ - refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => void; + * task missing from `taskWorkflowIds`, since task→workflow assignment emits no workflow SSE). + * Resolves when the fetch has SETTLED — it never rejects, since a failed fetch is + * non-authoritative — so a caller that must not overlap attempts can await it. */ + refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => Promise; /** * Raw state setter, exposed so Board can apply optimistic task→workflow assignment. * Planning does not use this. @@ -146,7 +148,16 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); }, [projectId, readBoardWorkflowsCache]); - const refreshBoardWorkflows = useCallback((options?: { forceFresh?: boolean }) => { + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + RETURNS its settle promise now (additive — existing callers ignore it). The + unmapped-workflow repair needs to know when a forced refresh has SETTLED: on a slow + request it was starting its second attempt on a fixed 250ms timer while the first was + still in flight, so both attempts could be spent before either answer arrived. The + promise never rejects — a failed fetch stays non-authoritative — so awaiting it is + safe for every caller. + */ + const refreshBoardWorkflows = useCallback((options?: { forceFresh?: boolean }): Promise => { const seq = ++boardWorkflowsFetchSeqRef.current; if (options?.forceFresh) { clearBoardWorkflowsCache(projectId); @@ -154,7 +165,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork const fetchPromise = options === undefined ? fetchBoardWorkflows(projectId) : fetchBoardWorkflows(projectId, options); - fetchPromise + return fetchPromise .then((payload) => { if (seq === boardWorkflowsFetchSeqRef.current) { setBoardWorkflowsState({ projectId, payload }); diff --git a/packages/dashboard/app/hooks/useUnmappedWorkflowRefetch.ts b/packages/dashboard/app/hooks/useUnmappedWorkflowRefetch.ts new file mode 100644 index 0000000000..579b68cded --- /dev/null +++ b/packages/dashboard/app/hooks/useUnmappedWorkflowRefetch.ts @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { BoardWorkflowsPayload } from "../api"; +import type { Task } from "@fusion/core"; + +/* +FNXC:WorkflowBoard 2026-07-29-00:00 (U12): +EXTRACTED VERBATIM from Board, so ListView can use the same self-heal instead of +growing a second copy of it. Behaviour is unchanged — the bodies below are Board's, +moved; only the surrounding parameters are new. + +The original notes, preserved because they are the reason every line here exists: + + FNXC:WorkflowBoard 2026-07-05-14:20: + Invariant: every rendered task must resolve to its REAL workflow, or the board + silently drops it. A task created into a workflow whose intake column differs from + the default (e.g. Coding (Ideas) -> "ideas", per FN-7591) disappears until the next + mount/focus/workflow-CRUD refetch. Cause: the task list (SSE) updates before the + board-workflows `taskWorkflowIds` map, so the effective workflow falls back to + `defaultWorkflowId`, whose columns do not declare the intake column. Fix at the + invariant, not the create surface: whenever a rendered task is absent from + `taskWorkflowIds`, force ONE board-workflows refetch so its persisted workflow + selection (and intake column) resolves. Signature-guarded on the sorted unmapped-id + set so we never spin an infinite refetch loop, and only run once the payload loaded. + + The refetch is deferred by one macrotask and re-checked against the latest state at + fire time: a surface's own quick-create commits the new task one microtask before the + optimistic workflow seed lands, so a synchronous refetch would double-fire alongside + the optimistic path. Deferring lets the seed land first. + + FNXC:WorkflowBoard 2026-07-12-23:40: + The FN-7591 refetch must also fire for a PRESENT-but-unrepresentable mapping, not + only an absent one. The server emits a `taskWorkflowIds` entry for every task + (defaulting to the default workflow), so a stale selection row makes e.g. an + "ideas"-column card map to plain Coding — an entry that exists but whose workflow + does not declare the task's column. The `=== undefined` guard alone never re-fired + for those, leaving the card permanently invisible in the aggregate view. A mapping is + "suspect" when the resolved workflow's column set does not contain the task's stored + column. The signature guard still prevents refetch loops for mappings that stay wrong + after a fresh fetch. +*/ +/** Attempts allowed per unresolved signature before the repair gives up. Two covers + * the fetch-races-the-selection-write case without permitting a refetch loop. */ +const MAX_ATTEMPTS_PER_SIGNATURE = 2; + +/** Delay before a follow-up attempt. An immediately-retried transient failure just + * fails again and burns the budget. */ +const RETRY_DELAY_MS = 250; + +export function useUnmappedWorkflowRefetch(params: { + boardWorkflows: BoardWorkflowsPayload | null; + tasks: readonly Task[]; + workflowMode: boolean; + refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => void | Promise; + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + The project this repair belongs to. A repair pending across a PROJECT SWITCH would + otherwise resume through the OLD project's `refreshBoardWorkflows` closure, and that + stale request can claim the newest shared fetch sequence number — discarding the + CURRENT project's response and leaving the view without workflow metadata. Every + continuation checks this before doing anything. + */ + projectId?: string; +}): void { + const { boardWorkflows, tasks, workflowMode, refreshBoardWorkflows, projectId } = params; + const projectIdRef = useRef(projectId); + + const boardWorkflowsRef = useRef(boardWorkflows); + boardWorkflowsRef.current = boardWorkflows; + const tasksRef = useRef(tasks); + tasksRef.current = tasks; + const lastUnmappedTaskSignatureRef = useRef(null); + const signatureAttemptsRef = useRef(0); + const unmappedRefetchTimerRef = useRef | null>(null); + /** True from firing a forced refresh until it settles. The timer ref is already + * cleared by then, so without this the effect re-arms mid-flight. */ + const repairInFlightRef = useRef(false); + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — CodeRabbit): + MOUNTED LATCH. The unmount cleanup can only clear the timer that EXISTS at unmount. A + forced refresh still in flight settles afterwards, its continuation passes the project + check (the ref is unchanged by unmounting), and it schedules a timer nobody will ever + clear — which then fires a refresh for a dead view. Both continuations check this. + */ + const mountedRef = useRef(true); + + /* + Abandon anything in flight when the project changes: cancel the pending timer, drop + the in-flight latch, and reset the signature so the new project starts clean rather + than inheriting the previous board's attempt budget. + */ + useEffect(() => { + projectIdRef.current = projectId; + if (unmappedRefetchTimerRef.current) { + clearTimeout(unmappedRefetchTimerRef.current); + unmappedRefetchTimerRef.current = null; + } + repairInFlightRef.current = false; + lastUnmappedTaskSignatureRef.current = null; + signatureAttemptsRef.current = 0; + }, [projectId]); + + const isTaskWorkflowMappingSuspect = useCallback(( + payload: NonNullable, + task: Task, + ): boolean => { + const assigned = payload.taskWorkflowIds[task.id]; + if (assigned === undefined) return true; + const known = payload.workflows.some((workflow) => workflow.id === assigned); + const workflowId = known ? assigned : payload.defaultWorkflowId; + const workflow = payload.workflows.find((candidate) => candidate.id === workflowId); + return workflow !== undefined && !workflow.columns.some((column) => column.id === task.column); + }, []); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile, second round): + SELF-DRIVING, not effect-driven. The retry budget alone was not enough: if the forced + fetch REJECTS, `refreshBoardWorkflows` swallows the rejection by design (a transient + failure is not authoritative), so `boardWorkflows` never changes, the effect's deps + never change, and the effect never re-runs to spend the remaining attempt. The repair + died on exactly the failure it exists to survive. + + So the repair re-arms itself from its own timer and re-reads live state through refs, + independent of React re-rendering. It stops on any of: state no longer suspect, budget + exhausted, or payload gone. + + Subsequent attempts wait RETRY_DELAY_MS rather than firing on the next macrotask — a + transient network failure retried immediately just fails again and burns the budget. + The FIRST attempt keeps its 0ms defer, which exists so an optimistic workflow seed can + land before we decide anything. + */ + const attemptRepair = useCallback((delayMs: number) => { + if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current); + const armedForProject = projectIdRef.current; + unmappedRefetchTimerRef.current = setTimeout(() => { + unmappedRefetchTimerRef.current = null; + if (!mountedRef.current) return; + // The board moved on: this repair belongs to a project no longer shown. + if (projectIdRef.current !== armedForProject) return; + const latestWorkflows = boardWorkflowsRef.current; + if (!latestWorkflows) return; + const stillUnmapped = tasksRef.current.some((task) => isTaskWorkflowMappingSuspect(latestWorkflows, task)); + if (!stillUnmapped) return; + if (signatureAttemptsRef.current >= MAX_ATTEMPTS_PER_SIGNATURE) return; + signatureAttemptsRef.current += 1; + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + Re-arm only once this attempt has SETTLED. A fixed timer alone meant a request in + flight for longer than RETRY_DELAY_MS had its successor started before its own + answer arrived, so both attempts could be spent on the same unresolved state and + the budget was gone before either response landed. `refreshBoardWorkflows` never + rejects (a failed fetch is non-authoritative), so `finally` is the settle point + for both outcomes; a non-promise return degrades to the old immediate re-arm. + */ + repairInFlightRef.current = true; + const settled = refreshBoardWorkflows({ forceFresh: true }); + if (settled && typeof (settled as Promise).finally === "function") { + void (settled as Promise).finally(() => { + repairInFlightRef.current = false; + // A request can outlive BOTH the mount and the project it was armed for. + if (!mountedRef.current) return; + if (projectIdRef.current !== armedForProject) return; + attemptRepair(RETRY_DELAY_MS); + }); + } else { + repairInFlightRef.current = false; + attemptRepair(RETRY_DELAY_MS); + } + }, delayMs); + }, [isTaskWorkflowMappingSuspect, refreshBoardWorkflows]); + + useEffect(() => { + if (!boardWorkflows || !workflowMode) return; + const unmapped = tasks + .filter((task) => isTaskWorkflowMappingSuspect(boardWorkflows, task)) + .map((task) => task.id) + .sort(); + if (unmapped.length === 0) { + lastUnmappedTaskSignatureRef.current = null; + signatureAttemptsRef.current = 0; + return; + } + const signature = unmapped.join(","); + if (signature === lastUnmappedTaskSignatureRef.current) { + if (signatureAttemptsRef.current >= MAX_ATTEMPTS_PER_SIGNATURE) return; + } else { + lastUnmappedTaskSignatureRef.current = signature; + signatureAttemptsRef.current = 0; + } + /* + Once the self-driving loop is armed it owns the cadence. Re-arming from the effect + on every payload change would cancel the pending RETRY_DELAY_MS wait and fire + immediately, collapsing the backoff and spending the budget faster than intended. + + The in-flight latch covers the other half: between firing a forced refresh and its + settling, the TIMER ref is already null, so the timer check alone let a payload + change start the next attempt while the previous request was still outstanding. + */ + if (unmappedRefetchTimerRef.current || repairInFlightRef.current) return; + attemptRepair(0); + }, [attemptRepair, boardWorkflows, isTaskWorkflowMappingSuspect, tasks, workflowMode]); + + /* + FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile): + SET IT TRUE ON SETUP, not just false on teardown. React StrictMode replays effects as + mount -> cleanup -> mount while PRESERVING refs, so a latch only ever cleared would be + left `false` after the replay and every deferred continuation would exit at the guard — + the repair silently disabled for the whole session, in production, which is the exact + defect class this unit exists to remove. I introduced it while fixing one. + */ + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (unmappedRefetchTimerRef.current) clearTimeout(unmappedRefetchTimerRef.current); + }; + }, []); +}