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); + }; + }, []); +}