Files
fusion/packages/dashboard/app/hooks/useUnmappedWorkflowRefetch.ts
gsxdsm 6721bdc652 U12 part 7: the List view never self-healed a card's workflow — extract Board's FN-7591 refetch and wire it up (#2530)
## 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**.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:04:58 -07:00

218 lines
11 KiB
TypeScript

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<void>;
/*
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<string | null>(null);
const signatureAttemptsRef = useRef(0);
const unmappedRefetchTimerRef = useRef<ReturnType<typeof setTimeout> | 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<typeof boardWorkflows>,
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<void>).finally === "function") {
void (settled as Promise<void>).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);
};
}, []);
}