U10: dashboard renders workflow-resolved columns (6 legacy-vocabulary defects, incl. a silently-disabled open-PR guard) (#2492)

Phase D / **U10** of the workflow-owned-lifecycle program (**R8**). This
unit **blocks U11** (merge Todo into Planning) — the board must render
IR-resolved columns before the column shape can change.

## What was wrong

Six dashboard surfaces answered a column question from the legacy
`COLUMNS` / `VALID_TRANSITIONS` vocabulary rather than the card's own
workflow IR. Each is a defect today, and each is a way U11 would ship
visibly broken.

| # | Surface | Defect |
|---|---|---|
| 1 | Board — All workflows | Appended **every** legacy column id to the
lane union with synthesised flags → a phantom lane for a column no
workflow declares, labelled with the raw id, ordered by the enum index
with an alphabetical tie-break that scrambled a custom workflow's
declared order |
| 2 | ListView | `if (groups[column])` **silently dropped** a row whose
stored column the workflow no longer declares — no lane, no row, no
error |
| 3 | Move menu | A card stranded in an undeclared column got an **empty
move list** — the one surface that could rescue it offered nothing |
| 4 | Task Detail | Header badge rendered the raw stored id;
title/description editing gated on the literal `{triage, todo}` — a
renamed planning lane lost Edit with nothing on screen to explain it |
| 5 | `board-workflows` | The built-in lifecycle label map was an
**override**, not a fallback, so it replaced a name a built-in
deliberately chose |
| 6 | `POST /tasks/:id/move` | The open-PR backward guard used
`COLUMNS.indexOf(...)` → **-1 on any renamed board**, and the guard
treats a negative index as "allow" |

**#6 is the one worth reading twice.** The guard did not start rejecting
the wrong things — it stopped existing. On a renamed board an operator
could drag a card backward out of review with an open GitHub PR,
orphaning it, and nothing failed. This is precisely the "a converted
guard silently stops firing" row in the plan's risk table, reached
through a rename rather than a conversion.

The re-engage copy of that same guard is **deliberately left on the
legacy enum**, with a comment saying why: it is gated on literal
`in-review` / `in-progress` end to end, so converting only its indices
would make it *weaker* (a workflow declaring `in-review` but not
`in-progress` would score -1 and disable it). U5 owns that lane.

## Evidence

**Every fix has a test that fails when the fix is reverted.** With the
six production files stashed and the tests kept, **10 of the 28 tests
fail**:

- Board aggregate: phantom lane present (2)
- ListView: stranded card dropped, desktop **and** mobile (2)
- Move menu: empty move list for a stranded card (1)
- Task Detail: badge shows `staging`, Edit missing in a renamed intake
**and** hold lane (3)
- `board-workflows`: `builtin:lead-generation`'s `triage` renders as
"Planning" (1)
- Move route: backward move between renamed columns **allowed** with an
open PR (1)

The other 18 are regression pins on behaviour that must not change
(default-workflow lane order and labels, legacy `in-review →
in-progress` block, legacy editable columns, forward moves, terminal
PRs).

**Measured, not estimated.** The label-map clobber was quantified
against the built-in IRs actually in tree: **4 column names replaced — 3
case-only variants ("In progress" → "In Progress"), 1 genuine semantic
rename.** Only the rename is a user-visible defect; the fix preserves
the case normalisation rather than churning the default board.

## Surface enumeration (AGENTS.md)

Desktop **and** mobile — the breakpoint is `(max-width: 768px),
(max-height: 480px)`, so landscape phones exceed 768 wide and match on
height. Column states: empty, populated, duplicate id across two
workflows, and a column no workflow declares. Views: single-workflow
lane, All-workflows aggregate, list, move menu, task detail, move route.

## Regression check

Full dashboard suite, both sides of the change:

| | Test Files | Tests |
|---|---|---|
| Before | 41 failed / 1068 | **296 failed** / 21195 |
| After | 42 failed / 1071 | **297 failed** / 21223 |

`+28` total is exactly the tests this change adds. The single failure
delta is `register-model-routes-kimi-k3-supplemental`, which **fails
identically on this branch's base when run in isolation** — shard-order
dependent, unrelated to columns. **Zero regressions attributable to
U10.** The ~296 pre-existing dashboard failures are inherited from main
and are flagged to the coordinator, not touched here.

`pnpm test:gate`, `pnpm lint`, both dashboard typechecks
(`tsconfig.json` and `tsconfig.app.json`), `pnpm smoke:boot`, and `pnpm
check:changesets` are green.

## Not in this unit

- `Board`'s legacy single-lane `COLUMNS.map` fallback still exists.
`MainContent` passes `workflowColumnsEnabled` unconditionally, so it is
unreachable in-app, but proving that is a deletion argument and this is
not a deletion unit — flagging rather than removing.
- `ListView`'s `LEGACY_LIST_COLUMNS` fallback, same reasoning.
- `flagEnabled` on the wire (U2 noted U10 retires it once no client
reads it) — four clients still branch on it; retiring it is a
client-shape change that belongs with U11's shape work.
- `GET /api/tasks?column=` still validates against `COLUMNS`, rejecting
a workflow-declared custom column as a list filter. Server-side filter
surface, not a rendering decision.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-27 21:48:08 -07:00
committed by GitHub
parent fbe7eb5c5a
commit a271f1868f
11 changed files with 1033 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Board, list, task detail, and move menus now render each card's own workflow columns.
category: fix
dev: U10 of the workflow-owned-lifecycle program (R8). Removes the legacy `COLUMNS` injection from Board's All-workflows lane union (it drew a phantom lane for every legacy column no workflow declared, labelled with the raw id, ordered by the legacy enum rather than the IR); ListView no longer silently drops a row whose stored column its workflow does not declare (display-only re-home to the intake lane, matching Board's existing safety nets); `getWorkflowMoveTargets` offers the workflow's recovery lane instead of an empty move list for a card stranded in an undeclared column; Task Detail's column badge and title/description edit gate resolve from the card's column traits with the legacy id set as fallback; `board-workflows`' built-in lifecycle label map became a fallback rather than an override (it was rendering `builtin:lead-generation`'s "Lead intake" as "Planning"); and the open-PR backward-move guard on `POST /tasks/:id/move` orders columns by the task's workflow instead of `COLUMNS.indexOf`, which returned -1 on any renamed board and disabled the guard entirely.

View File

@@ -749,6 +749,13 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
FNXC:WorkflowBoard 2026-06-29-23:54: FNXC:WorkflowBoard 2026-06-29-23:54:
Aggregate Board rendering separates active columns from archived columns after the deterministic union is built. This preserves the existing collapsed archived-column behavior while the main All workflows lane set stays limited to non-hidden, non-archived destinations. Aggregate Board rendering separates active columns from archived columns after the deterministic union is built. This preserves the existing collapsed archived-column behavior while the main All workflows lane set stays limited to non-hidden, non-archived destinations.
FNXC:WorkflowResolvedColumns 2026-07-27-14:35 (U10 / R8):
The union is now built ONLY from the workflows the payload declares. It previously appended every id of the legacy `COLUMNS` enum with synthesised trait flags, which drew a phantom lane for any lifecycle column no workflow declares — the visible failure a removed column (U11 merging Todo into Planning) would ship. Those injected lanes also carried the raw column id as their label, so a lane named "in-progress" appeared beside properly named workflow lanes.
Ordering follows the workflows' own declaration order (default workflow first, then the remaining workflows in payload order) instead of the legacy enum's index. For the built-in workflows the two are identical — their IR declares columns in exactly the legacy order — so the default board is unchanged; for a renamed or reordered workflow the lanes now follow the IR rather than collapsing to an alphabetical tie-break.
Cards resting in a column NO workflow declares are still rendered: `aggregateTasksByColumn` re-homes them for display into the aggregate quick-create intake lane (see its safety net below). Dropping the injected lanes therefore removes phantom lanes without stranding a single card.
*/ */
const aggregateBoardColumns = useMemo<AggregateBoardColumn[]>(() => { const aggregateBoardColumns = useMemo<AggregateBoardColumn[]>(() => {
const byId = new Map<string, AggregateBoardColumn>(); const byId = new Map<string, AggregateBoardColumn>();
@@ -770,23 +777,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
} }
} }
} }
for (const column of COLUMNS) { return [...byId.values()];
if (!byId.has(column)) {
byId.set(column, {
id: column,
name: column,
flags: { archived: column === "archived", complete: column === "done", intake: column === "triage", countsTowardWip: column === "in-progress", mergeBlocker: column === "in-review" },
sourceWorkflowIds: [],
});
}
}
const order = new Map(COLUMNS.map((column, index) => [column, index]));
return [...byId.values()].sort((a, b) => {
const aOrder = order.get(a.id as ColumnType) ?? (a.flags.archived ? 10_000 : 1_000);
const bOrder = order.get(b.id as ColumnType) ?? (b.flags.archived ? 10_000 : 1_000);
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name);
});
}, [boardWorkflows]); }, [boardWorkflows]);
const aggregateQuickCreateTarget = useMemo<AggregateQuickCreateTarget | null>(() => { const aggregateQuickCreateTarget = useMemo<AggregateQuickCreateTarget | null>(() => {

View File

@@ -694,6 +694,51 @@ export function ListView({
return [...columnsById.values()]; return [...columnsById.values()];
}, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]); }, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
/**
* FNXC:WorkflowResolvedColumns 2026-07-27-14:45 (U10 / R8):
* Display-only landing lane for a row whose stored column the resolved workflow does not
* declare. Prefers the intake lane (where an operator expects unplaced work), then the first
* non-complete/non-archived lane, then the first lane at all.
*/
const pickFallbackColumnId = useCallback((columns: readonly BoardWorkflowColumn[]): ColumnId | undefined => {
const placeable = columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
return placeable.find((column) => column.flags.intake)?.id
?? placeable.find((column) => !column.flags.complete)?.id
?? placeable[0]?.id
?? columns[0]?.id;
}, []);
/*
FNXC:WorkflowResolvedColumns 2026-07-27-18:40 (U10 / R8 — greptile P1 on PR #2492):
Per-WORKFLOW landing lanes. In the All-workflows list, `listColumns` is a cross-workflow union
ordered default-workflow-first, so one global fallback filed every stranded row under the DEFAULT
workflow's intake — a card from another workflow rendered under a lifecycle it does not belong to.
Resolve the landing lane from the card's own workflow; the global fallback below is only the last
resort for a card whose workflow cannot be resolved at all.
*/
const fallbackColumnIdByWorkflowId = useMemo(() => {
const map = new Map<string, ColumnId>();
for (const workflow of boardWorkflows?.workflows ?? []) {
const fallback = pickFallbackColumnId(workflow.columns);
if (fallback !== undefined) map.set(workflow.id, fallback);
}
return map;
}, [boardWorkflows, pickFallbackColumnId]);
const listFallbackColumnId = useMemo<ColumnId | undefined>(
() => pickFallbackColumnId(listColumns),
[listColumns, pickFallbackColumnId],
);
/** The workflow a rendered card belongs to, resolved the same way the lane filter resolves it. */
const resolveTaskWorkflowId = useCallback((taskId: string): string | undefined => {
if (!boardWorkflows) return undefined;
const raw = boardWorkflows.taskWorkflowIds[taskId];
return raw && boardWorkflows.workflows.some((workflow) => workflow.id === raw)
? raw
: boardWorkflows.defaultWorkflowId;
}, [boardWorkflows]);
const columnNameById = useMemo(() => { const columnNameById = useMemo(() => {
const map = new Map<ColumnId, string>(); const map = new Map<ColumnId, string>();
for (const column of listColumns) { for (const column of listColumns) {
@@ -919,9 +964,28 @@ export function ListView({
const groups: Record<string, Task[]> = {}; const groups: Record<string, Task[]> = {};
for (const column of listColumns) groups[column.id] = []; for (const column of listColumns) groups[column.id] = [];
/*
FNXC:WorkflowResolvedColumns 2026-07-27-14:45 (U10 / R8):
A row whose stored column the resolved workflow no longer declares must NOT vanish. The
previous `if (groups[column])` guard silently dropped it — no lane, no row, no error — which
is exactly what a removed column (U11 merging Todo into Planning) or a workflow edited to
drop a lane produces for cards already resting there. Re-home it for DISPLAY into the
workflow's intake/first visible lane, mirroring the safety nets Board already carries for its
selected-workflow and aggregate groupings. Display-only: the task's stored column is untouched,
so the move menu and any engine rebound still see the real column.
*/
columnFiltered.forEach((task) => { columnFiltered.forEach((task) => {
const column = workflowMode ? task.column : (isColumn(task.column) ? task.column : DEFAULT_COLUMN); const column = workflowMode ? task.column : (isColumn(task.column) ? task.column : DEFAULT_COLUMN);
if (groups[column]) groups[column].push(task); if (groups[column] !== undefined) {
groups[column].push(task);
return;
}
const ownWorkflowId = workflowMode ? resolveTaskWorkflowId(task.id) : undefined;
const ownFallback = ownWorkflowId ? fallbackColumnIdByWorkflowId.get(ownWorkflowId) : undefined;
const columnId = (ownFallback !== undefined && groups[ownFallback] !== undefined)
? ownFallback
: listFallbackColumnId;
if (columnId !== undefined && groups[columnId] !== undefined) groups[columnId].push(task);
}); });
for (const column of listColumns) { for (const column of listColumns) {
@@ -951,7 +1015,7 @@ export function ListView({
}); });
} }
return groups; return groups;
}, [tasks, searchQuery, selectedWorkflowTaskIds, listColumns, workflowMode, hideDoneTasks, selectedColumn, staleOnlyFilter, stalePausedReviewOnlyFilter, sortField, sortDirection]); }, [tasks, searchQuery, selectedWorkflowTaskIds, listColumns, workflowMode, hideDoneTasks, selectedColumn, staleOnlyFilter, stalePausedReviewOnlyFilter, sortField, sortDirection, fallbackColumnIdByWorkflowId, listFallbackColumnId, resolveTaskWorkflowId]);
// Calculate total filtered count from groups // Calculate total filtered count from groups
const filteredCount = useMemo(() => { const filteredCount = useMemo(() => {

View File

@@ -46,6 +46,9 @@ export interface TaskContextMenuColumnFlags {
intake?: boolean; intake?: boolean;
mergeBlocker?: boolean; mergeBlocker?: boolean;
humanReview?: boolean; humanReview?: boolean;
/* FNXC:WorkflowResolvedColumns 2026-07-27-15:30 (U10 / R8): surfaced so column-trait consumers
can tell an implementation lane from a pre-implementation one without naming `in-progress`. */
countsTowardWip?: boolean;
} }
export interface TaskContextMenuColumnMetadata { export interface TaskContextMenuColumnMetadata {
@@ -160,7 +163,24 @@ function getWorkflowMoveTargets(task: Task | TaskDetail, columns: readonly TaskC
} }
const currentIndex = visibleColumns.findIndex((column) => column.id === task.column); const currentIndex = visibleColumns.findIndex((column) => column.id === task.column);
if (currentIndex < 0) return []; /*
FNXC:WorkflowResolvedColumns 2026-07-27-14:55 (U10 / R8):
A card resting in a column its workflow no longer declares used to get an EMPTY move list —
the one surface that could rescue it offered nothing, so the card was stranded until an engine
sweep re-homed it. Offer the workflow's own recovery lane instead (intake, else hold, else the
first live lane), which is the same target `resolveReboundTarget` picks engine-side. Undeclared
columns are produced by a workflow edit that drops a lane and by U11's Todo→Planning merge for
rows still stored in `todo`.
*/
if (currentIndex < 0) {
const liveColumns = visibleColumns.filter(
(column) => column.flags?.complete !== true && column.flags?.archived !== true,
);
const recoveryColumn = liveColumns.find((column) => column.flags?.intake === true)
?? liveColumns.find((column) => column.flags?.hold === true)
?? liveColumns[0];
return recoveryColumn ? [recoveryColumn.id] : [];
}
const targets: ColumnId[] = []; const targets: ColumnId[] = [];
const previous = visibleColumns[currentIndex - 1]?.id; const previous = visibleColumns[currentIndex - 1]?.id;
const next = visibleColumns[currentIndex + 1]?.id; const next = visibleColumns[currentIndex + 1]?.id;

View File

@@ -639,6 +639,24 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt
// #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids // #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids
// (non-members correctly resolve to false → not editable). // (non-members correctly resolve to false → not editable).
const EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo"]); const EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo"]);
/*
FNXC:WorkflowResolvedColumns 2026-07-27-15:30 (U10 / R8):
Title/description editing belongs to PRE-IMPLEMENTATION lanes — the card has no session, no
worktree, and no plan being executed against the text. That was encoded as the legacy id pair
{triage, todo}, so a workflow that renames its planning lane (or U11's Todo→Planning merge)
silently lost the Edit affordance with nothing on screen to explain it. Resolve it from the
card's own column traits instead, and keep the legacy id set as the fallback for the window
before the board-workflows payload resolves and for a column the workflow does not declare —
where the traits are unknown rather than known-false.
*/
function isTaskFieldEditableColumn(column: ColumnId, flags?: TaskContextMenuColumnFlags): boolean {
if (!flags) return EDITABLE_COLUMNS.has(column);
if (flags.complete || flags.archived || flags.countsTowardWip || flags.mergeBlocker || flags.humanReview) {
return false;
}
return flags.intake === true || flags.hold === true;
}
const GITHUB_TRACKING_EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo", "in-progress", "in-review", "ideas"]); const GITHUB_TRACKING_EDITABLE_COLUMNS: Set<ColumnId> = new Set<ColumnId>(["triage", "todo", "in-progress", "in-review", "ideas"]);
const CODING_IDEAS_WORKFLOW_ID = "builtin:coding-ideas"; const CODING_IDEAS_WORKFLOW_ID = "builtin:coding-ideas";
@@ -1678,7 +1696,9 @@ export function TaskDetailContent({
// Note: TaskForm handles auto-focus internally via isActive prop // Note: TaskForm handles auto-focus internally via isActive prop
// Check if task can be edited // Check if task can be edited
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving; const canEdit = isTaskFieldEditableColumn(task.column, workflowMoveMetadata?.currentColumnFlags) && !isSaving;
/** The card's column name as its own workflow declares it; `undefined` when unresolved. */
const workflowColumnDisplayName = workflowMoveMetadata?.moveColumns.find((column) => column.id === task.column)?.label;
const canEditGithubTracking = canTaskEditGithubTracking(task.column, taskWorkflowBadge?.id) && !isSaving; const canEditGithubTracking = canTaskEditGithubTracking(task.column, taskWorkflowBadge?.id) && !isSaving;
const githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true); const githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true);
const githubTrackedIssue = workingTask.githubTracking?.issue; const githubTrackedIssue = workingTask.githubTracking?.issue;
@@ -4075,8 +4095,17 @@ export function TaskDetailContent({
<div className="modal-header"> <div className="modal-header">
<div className="detail-title-row"> <div className="detail-title-row">
<span className="detail-id" id="task-detail-modal-title">{task.id}</span> <span className="detail-id" id="task-detail-modal-title">{task.id}</span>
{/*
FNXC:WorkflowResolvedColumns 2026-07-27-15:35 (U10 / R8):
The badge names the card's column in the card's OWN workflow vocabulary. `columnLabel`
is the shared lifecycle translator keyed on legacy ids, so a workflow-declared column
it does not know fell through to the raw stored id ("staging") beside properly named
lanes elsewhere in the UI. Prefer the workflow's declared column name; keep
`columnLabel` for the column a workflow does not declare and for the window before the
board-workflows payload resolves.
*/}
<span className={`detail-column-badge badge-${task.column}`}> <span className={`detail-column-badge badge-${task.column}`}>
{columnLabel(task.column)} {workflowColumnDisplayName ?? columnLabel(task.column)}
</span> </span>
</div> </div>
<div className="modal-header-actions"> <div className="modal-header-actions">

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
import { fetchBoardWorkflows } from "../../api";
import type { Column } from "@fusion/core";
/*
FNXC:WorkflowResolvedColumns 2026-07-27-15:20 (U10 / R8):
Task Detail is the surface an operator opens when a card looks wrong, so it must describe the
card in the card's OWN workflow vocabulary. Two decisions were keyed on legacy column ids and
therefore silently wrong for a renamed lane: the header column badge (rendered the raw stored id)
and the title/description edit affordance (`EDITABLE_COLUMNS = {triage, todo}` — a renamed
planning lane could not be edited at all, with no error to explain why).
*/
setupTaskDetailModalHooks();
const RENAMED_WORKFLOW = {
id: "wf-renamed",
name: "Renamed Flow",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "staging", name: "Ready to build", flags: { hold: true } },
{ id: "building", name: "Building", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
};
function mockRenamedWorkflow(taskId: string) {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: RENAMED_WORKFLOW.id,
workflows: [RENAMED_WORKFLOW],
taskWorkflowIds: { [taskId]: RENAMED_WORKFLOW.id },
} as never);
}
function renderDetail(column: string) {
return render(
<TaskDetailModal
task={makeTask({ id: "FN-099", column: column as Column, title: "Renamed lane card" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
}
describe("TaskDetailModal — workflow-resolved columns", () => {
it("labels the header badge with the workflow's column name, not the stored id", async () => {
mockRenamedWorkflow("FN-099");
renderDetail("staging");
// TaskDetailModal renders through a floating-window portal, so query the document.
await waitFor(() => {
expect(document.querySelector(".detail-column-badge")?.textContent).toBe("Ready to build");
});
});
it("still labels the badge for a column the workflow does not declare", async () => {
mockRenamedWorkflow("FN-099");
renderDetail("todo");
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalled());
// No workflow column to name it: fall back to the shared lifecycle label rather than blank.
expect(document.querySelector(".detail-column-badge")?.textContent?.trim()).toBe("Todo");
});
it("allows editing a card resting in a renamed intake column", async () => {
mockRenamedWorkflow("FN-099");
renderDetail("backlog");
expect(await screen.findByRole("button", { name: "Edit task" })).toBeTruthy();
});
it("allows editing a card resting in a renamed hold column", async () => {
mockRenamedWorkflow("FN-099");
renderDetail("staging");
expect(await screen.findByRole("button", { name: "Edit task" })).toBeTruthy();
});
it("does not offer editing in a renamed implementation column", async () => {
mockRenamedWorkflow("FN-099");
renderDetail("building");
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Edit task" })).toBeNull();
});
it("does not offer editing in a renamed complete column", async () => {
mockRenamedWorkflow("FN-099");
renderDetail("shipped");
await waitFor(() => expect(fetchBoardWorkflows).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: "Edit task" })).toBeNull();
});
it("keeps the legacy editable columns editable when no workflow metadata resolves", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "",
workflows: [],
taskWorkflowIds: {},
} as never);
renderDetail("todo");
expect(await screen.findByRole("button", { name: "Edit task" })).toBeTruthy();
});
});

View File

@@ -0,0 +1,468 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { Board } from "../Board";
import { ListView } from "../ListView";
import { getTaskMoveTransitions, type TaskContextMenuColumnMetadata } from "../TaskContextMenu";
import type { BoardWorkflowsPayload } from "../../api";
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
/*
FNXC:WorkflowResolvedColumns 2026-07-27-14:10 (U10 / R8):
Board, List, and the move menu must render the columns the CARD'S OWN WORKFLOW declares —
with the workflow's names and the workflow's order. The two failure modes this file pins are
the ones that survive a green suite:
- a RENAMED column rendering under its legacy label or in legacy position, and
- a REMOVED column stranding its cards (dropped from the render entirely) while a phantom
lane for that removed column is still drawn from the legacy `COLUMNS` enum.
Both are asserted on desktop AND mobile, and across empty / populated / duplicate-id column
states, per the AGENTS.md Surface Enumeration rule (this unit changes UI affordances).
*/
const fetchBoardWorkflowsMock = vi.fn();
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn(() => new Promise(() => {})),
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
promoteTask: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn(() => new Promise(() => {})),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn(() => new Promise(() => {})),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchSettings: vi.fn().mockResolvedValue({}),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
refreshPrStatus: vi.fn().mockResolvedValue({}),
rebuildTaskSpec: vi.fn().mockResolvedValue({}),
updateTask: vi.fn(),
api: vi.fn().mockResolvedValue({ sessions: [] }),
setProjectBoardSelectedWorkflow: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => {}) }));
vi.mock("../Column", () => ({
Column: ({ column, tasks, columnDisplayName }: { column: string; tasks: Task[]; columnDisplayName?: string }) => (
<section
data-testid={`column-${column}`}
data-column-label={columnDisplayName ?? column}
data-task-ids={JSON.stringify(tasks.map((task) => task.id))}
/>
),
}));
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div data-testid="quick-entry" /> }));
vi.mock("../TaskDetailModal", () => ({ TaskDetailContent: () => <div data-testid="task-detail-content" /> }));
vi.mock("../CustomModelDropdown", () => ({ CustomModelDropdown: () => <div /> }));
const PROJECT_ID = "project-u10";
/**
* A workflow that both RENAMES (`todo` → `staging`, "Ready to build") and REMOVES
* nothing yet — used as the rename surface.
*/
const RENAMED_WORKFLOW = {
id: "wf-renamed",
name: "Renamed Flow",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "staging", name: "Ready to build", flags: { hold: true } },
{ id: "building", name: "Building", flags: { countsTowardWip: true } },
{ id: "signoff", name: "Sign-off", flags: { mergeBlocker: true, humanReview: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
};
/** A workflow that REMOVED the hold column entirely (the U11 shape). */
const REMOVED_HOLD_WORKFLOW = {
id: "wf-no-todo",
name: "No Todo Flow",
columns: [
{ id: "triage", name: "Planning", flags: { intake: true, hold: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "in-review", name: "In review", flags: { mergeBlocker: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
function mkTask(overrides: Partial<Task> & { id: string }): Task {
return {
title: overrides.id,
description: "Task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
status: "pending",
paused: false,
log: [],
createdAt: "2026-07-27T00:00:00.000Z",
updatedAt: "2026-07-27T00:00:00.000Z",
...overrides,
} as Task;
}
function payload(
workflows: BoardWorkflowsPayload["workflows"],
taskWorkflowIds: Record<string, string>,
): BoardWorkflowsPayload {
return {
flagEnabled: true,
defaultWorkflowId: workflows[0]!.id,
workflows,
taskWorkflowIds,
};
}
function renderBoard(tasks: Task[]) {
return render(
<Board
tasks={tasks}
projectId={PROJECT_ID}
maxConcurrent={2}
showWorktreeGrouping={false}
onMoveTask={vi.fn(async () => tasks[0]!)}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
onNewTask={vi.fn()}
autoMerge
onToggleAutoMerge={vi.fn()}
planAutoApproveEnabled={false}
onTogglePlanAutoApprove={vi.fn()}
workflowColumnsEnabled
settingsLoaded
/>,
);
}
function renderList(tasks: Task[]) {
return render(
<ListView
tasks={tasks}
projectId={PROJECT_ID}
onMoveTask={vi.fn(async () => tasks[0]!)}
onRetryTask={vi.fn(async () => tasks[0]!)}
onDeleteTask={vi.fn(async () => tasks[0]!)}
onMergeTask={vi.fn()}
onResetTask={vi.fn(async () => tasks[0]!)}
onDuplicateTask={vi.fn(async () => tasks[0]!)}
onOpenDetail={vi.fn()}
addToast={vi.fn()}
globalPaused={false}
onNewTask={vi.fn()}
workflowColumnsEnabled
settingsLoaded
/>,
);
}
function selectAllWorkflowsView() {
window.localStorage.setItem(`kb:${PROJECT_ID}:kb-dashboard-board-workflow-selection`, ALL_WORKFLOWS_BOARD_VIEW_ID);
}
/**
* Mobile is a required surface here: the breakpoint is `(max-width: 768px), (max-height: 480px)`,
* so a landscape phone matches on HEIGHT while exceeding 768px wide.
*/
function mockViewport(mobile: boolean) {
Object.defineProperty(window, "innerWidth", { value: mobile ? 375 : 1280, configurable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: (query: string) => ({
matches: mobile && (query.includes("max-width: 768px") || query.includes("max-height: 480px")),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
});
}
function renderedColumnIds(): string[] {
return Array.from(document.querySelectorAll("[data-testid^='column-']")).map(
(node) => node.getAttribute("data-testid")!.replace(/^column-/, ""),
);
}
/**
* The list groups rows under `.list-section-header` rows, so a row's section is the nearest
* preceding header. Asserting the header text (rather than merely "the row exists") is what
* catches a row landing in the WRONG lane instead of being dropped.
*/
function listSectionOfRow(taskId: string): string | null {
const rows = Array.from(document.querySelectorAll("tr, .list-row, .list-section-header"));
const rowIndex = rows.findIndex((node) => node.getAttribute("data-id") === taskId);
if (rowIndex < 0) return null;
for (let index = rowIndex - 1; index >= 0; index -= 1) {
if (rows[index]!.className.includes("list-section-header")) return rows[index]!.textContent ?? "";
}
return null;
}
function taskIdsInColumn(columnId: string): string[] {
const node = document.querySelector(`[data-testid='column-${columnId}']`);
if (!node) return [];
return JSON.parse(node.getAttribute("data-task-ids") ?? "[]") as string[];
}
describe("U10 — surfaces render workflow-resolved columns", () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
fetchBoardWorkflowsMock.mockReset();
mockViewport(false);
});
describe("Board — single-workflow lane", () => {
it("renders a renamed workflow's own column ids, labels, and order", async () => {
fetchBoardWorkflowsMock.mockResolvedValue(
payload([RENAMED_WORKFLOW], { "FN-1": RENAMED_WORKFLOW.id }),
);
renderBoard([mkTask({ id: "FN-1", column: "staging" as Task["column"] })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
expect(renderedColumnIds()).toEqual(["backlog", "staging", "building", "signoff", "shipped"]);
expect(
document.querySelector("[data-testid='column-staging']")?.getAttribute("data-column-label"),
).toBe("Ready to build");
expect(taskIdsInColumn("staging")).toEqual(["FN-1"]);
});
it("does not render a lane for a column the workflow removed", async () => {
fetchBoardWorkflowsMock.mockResolvedValue(
payload([REMOVED_HOLD_WORKFLOW], { "FN-2": REMOVED_HOLD_WORKFLOW.id }),
);
renderBoard([mkTask({ id: "FN-2", column: "triage" })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
expect(renderedColumnIds()).not.toContain("todo");
});
it("keeps a card stored in a removed column visible in the workflow's hold lane", async () => {
fetchBoardWorkflowsMock.mockResolvedValue(
payload([REMOVED_HOLD_WORKFLOW], { "FN-3": REMOVED_HOLD_WORKFLOW.id }),
);
renderBoard([mkTask({ id: "FN-3", column: "todo" })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
const allRendered = renderedColumnIds().flatMap((id) => taskIdsInColumn(id));
expect(allRendered).toContain("FN-3");
});
});
describe("Board — All workflows aggregate lane", () => {
it("draws no phantom lane for a legacy column that no workflow declares", async () => {
selectAllWorkflowsView();
fetchBoardWorkflowsMock.mockResolvedValue(
payload([RENAMED_WORKFLOW], { "FN-4": RENAMED_WORKFLOW.id }),
);
renderBoard([mkTask({ id: "FN-4", column: "backlog" as Task["column"] })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
const ids = renderedColumnIds();
expect(ids).not.toContain("todo");
expect(ids).not.toContain("in-progress");
expect(ids).not.toContain("in-review");
expect(ids).not.toContain("archived");
expect(ids).toEqual(["backlog", "staging", "building", "signoff", "shipped"]);
});
it("labels aggregate lanes with the workflow's column names, never the raw id", async () => {
selectAllWorkflowsView();
fetchBoardWorkflowsMock.mockResolvedValue(
payload([RENAMED_WORKFLOW], { "FN-5": RENAMED_WORKFLOW.id }),
);
renderBoard([mkTask({ id: "FN-5", column: "signoff" as Task["column"] })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
for (const column of RENAMED_WORKFLOW.columns) {
expect(
document.querySelector(`[data-testid='column-${column.id}']`)?.getAttribute("data-column-label"),
).toBe(column.name);
}
});
it("still renders a fallback lane for a stored column no workflow declares", async () => {
selectAllWorkflowsView();
fetchBoardWorkflowsMock.mockResolvedValue(
payload([REMOVED_HOLD_WORKFLOW], { "FN-6": REMOVED_HOLD_WORKFLOW.id }),
);
renderBoard([mkTask({ id: "FN-6", column: "todo" })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
const allRendered = renderedColumnIds().flatMap((id) => taskIdsInColumn(id));
expect(allRendered).toContain("FN-6");
});
it("keeps a duplicate column id declared by two workflows as one lane", async () => {
selectAllWorkflowsView();
const second = {
id: "wf-second",
name: "Second",
columns: [
{ id: "backlog", name: "Backlog (other)", flags: { intake: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
};
fetchBoardWorkflowsMock.mockResolvedValue(
payload([RENAMED_WORKFLOW, second], { "FN-7": second.id }),
);
renderBoard([mkTask({ id: "FN-7", column: "backlog" as Task["column"] })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
const ids = renderedColumnIds();
expect(ids.filter((id) => id === "backlog")).toHaveLength(1);
// The default (first) workflow owns the shared label.
expect(
document.querySelector("[data-testid='column-backlog']")?.getAttribute("data-column-label"),
).toBe("Backlog");
});
it("renders an empty board with the workflow's lanes and no legacy lanes", async () => {
selectAllWorkflowsView();
fetchBoardWorkflowsMock.mockResolvedValue(payload([RENAMED_WORKFLOW], {}));
renderBoard([]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
expect(renderedColumnIds()).toEqual(["backlog", "staging", "building", "signoff", "shipped"]);
});
});
describe("Board — mobile breakpoint", () => {
it("renders the same resolved column set on mobile", async () => {
mockViewport(true);
fetchBoardWorkflowsMock.mockResolvedValue(
payload([RENAMED_WORKFLOW], { "FN-8": RENAMED_WORKFLOW.id }),
);
renderBoard([mkTask({ id: "FN-8", column: "staging" as Task["column"] })]);
await waitFor(() => expect(renderedColumnIds().length).toBeGreaterThan(0));
expect(renderedColumnIds()).toEqual(["backlog", "staging", "building", "signoff", "shipped"]);
expect(taskIdsInColumn("staging")).toEqual(["FN-8"]);
});
});
describe("ListView", () => {
it("groups rows under the workflow's renamed column headings", async () => {
fetchBoardWorkflowsMock.mockResolvedValue(
payload([RENAMED_WORKFLOW], { "FN-9": RENAMED_WORKFLOW.id }),
);
renderList([mkTask({ id: "FN-9", title: "Renamed row", column: "staging" as Task["column"] })]);
await screen.findByText("Renamed row");
expect(
screen.getAllByRole("row").some((row) => row.textContent?.includes("Ready to build")),
).toBe(true);
});
it("does not drop a card whose stored column the workflow no longer declares", async () => {
fetchBoardWorkflowsMock.mockResolvedValue(
payload([REMOVED_HOLD_WORKFLOW], { "FN-10": REMOVED_HOLD_WORKFLOW.id }),
);
renderList([mkTask({ id: "FN-10", title: "Stranded row", column: "todo" })]);
// Wait for the workflow payload to be applied before asserting.
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalled());
expect(await screen.findByText("Stranded row")).toBeTruthy();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-27-18:35 (U10 / R8 — greptile P1 on PR #2492):
In the All-workflows list the column set is a cross-workflow union with the DEFAULT workflow
first, so a single global fallback lane sends a stranded card from workflow B into workflow A's
intake — visible, but filed under another workflow's lifecycle. The landing lane must come from
the card's OWN workflow.
*/
it("re-homes a stranded card into its own workflow's intake, not the default workflow's", async () => {
selectAllWorkflowsView();
const alpha = {
id: "wf-alpha",
name: "Alpha",
columns: [
{ id: "alpha-intake", name: "Alpha Intake", flags: { intake: true } },
{ id: "alpha-done", name: "Alpha Done", flags: { complete: true } },
],
};
const beta = {
id: "wf-beta",
name: "Beta",
columns: [
{ id: "beta-intake", name: "Beta Intake", flags: { intake: true } },
{ id: "beta-done", name: "Beta Done", flags: { complete: true } },
],
};
fetchBoardWorkflowsMock.mockResolvedValue(payload([alpha, beta], { "FN-15": beta.id }));
renderList([mkTask({ id: "FN-15", title: "Beta stranded row", column: "beta-gone" as Task["column"] })]);
await screen.findByText("Beta stranded row");
await waitFor(() => expect(listSectionOfRow("FN-15")).toContain("Beta Intake"));
expect(listSectionOfRow("FN-15")).not.toContain("Alpha Intake");
});
it("does not drop a stranded card on mobile either", async () => {
mockViewport(true);
fetchBoardWorkflowsMock.mockResolvedValue(
payload([REMOVED_HOLD_WORKFLOW], { "FN-11": REMOVED_HOLD_WORKFLOW.id }),
);
renderList([mkTask({ id: "FN-11", title: "Stranded mobile row", column: "todo" })]);
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalled());
expect(await screen.findByText("Stranded mobile row")).toBeTruthy();
});
});
describe("Move menu (TaskContextMenu)", () => {
const t = ((_key: string, fallback: string, vars?: Record<string, string>) =>
vars?.column ? fallback.replace("{{column}}", vars.column) : fallback) as never;
const columnLabel = (column: string) => column;
const renamedMoveColumns: TaskContextMenuColumnMetadata[] = RENAMED_WORKFLOW.columns.map((column) => ({
id: column.id,
label: column.name,
flags: column.flags,
}));
const removedHoldMoveColumns: TaskContextMenuColumnMetadata[] = REMOVED_HOLD_WORKFLOW.columns.map((column) => ({
id: column.id,
label: column.name,
flags: column.flags,
}));
it("offers exactly the workflow's neighbouring columns, by their own labels", () => {
const transitions = getTaskMoveTransitions(
mkTask({ id: "FN-12", column: "staging" as Task["column"] }),
t,
columnLabel,
renamedMoveColumns,
);
expect(transitions.map((transition) => transition.column)).toEqual(["backlog", "building"]);
expect(transitions.map((transition) => transition.label)).toEqual([
"Move to Backlog",
"Move to Building",
]);
});
it("never offers a column the workflow does not declare", () => {
const transitions = getTaskMoveTransitions(
mkTask({ id: "FN-13", column: "in-review" }),
t,
columnLabel,
removedHoldMoveColumns,
);
expect(transitions.map((transition) => transition.column)).not.toContain("todo");
});
it("gives a card stranded in an undeclared column a way out", () => {
const transitions = getTaskMoveTransitions(
mkTask({ id: "FN-14", column: "todo" }),
t,
columnLabel,
removedHoldMoveColumns,
);
expect(transitions.length).toBeGreaterThan(0);
expect(transitions.map((transition) => transition.column)).toContain("triage");
});
});
});

View File

@@ -57,3 +57,52 @@ describe("buildBoardWorkflowsPayload column descriptions", () => {
expect(workflow?.columns[1]).not.toHaveProperty("description"); expect(workflow?.columns[1]).not.toHaveProperty("description");
}); });
}); });
/*
FNXC:WorkflowResolvedColumns 2026-07-27-16:40 (U10 / R8):
`BUILTIN_WORKFLOW_COLUMN_LABELS` canonicalises lifecycle column labels for BUILT-IN workflows.
It was applied unconditionally, so it also overwrote a built-in that DELIBERATELY renames a
lifecycle column — `builtin:lead-generation` names `triage` "Lead intake" and the board rendered
it as "Planning". Measured against the built-in IRs in tree: 4 column names were being replaced,
3 by case-only variants ("In progress" -> "In Progress") and 1 by a genuine semantic rename.
The canonical map must therefore be a FALLBACK for a column whose IR name adds nothing (blank,
the raw id, or the same words in different case) — never an override of a name the IR chose.
This is also the mechanism that would clobber U11's Todo->Planning rename.
*/
describe("buildBoardWorkflowsPayload built-in column labels", () => {
function builtinStore(workflowId: string) {
return {
getSettings: vi.fn(),
getTaskWorkflowSelection: vi.fn(() => ({ workflowId })),
getWorkflowDefinition: vi.fn(async () => undefined),
listWorkflowDefinitions: vi.fn(async () => []),
};
}
it("keeps a built-in's deliberately renamed lifecycle column name", async () => {
const payload = await buildBoardWorkflowsPayload(
builtinStore("builtin:lead-generation") as never,
["FN-LEAD"],
);
const workflow = payload.workflows.find(({ id }) => id === "builtin:lead-generation");
expect(workflow?.columns.find((column) => column.id === "triage")?.name).toBe("Lead intake");
});
it("still canonicalises the default coding workflow's lifecycle labels", async () => {
const payload = await buildBoardWorkflowsPayload(
builtinStore("builtin:coding") as never,
["FN-CODE"],
);
const workflow = payload.workflows.find(({ id }) => id === "builtin:coding");
const named = Object.fromEntries((workflow?.columns ?? []).map((column) => [column.id, column.name]));
expect(named).toMatchObject({
triage: "Planning",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
});
});
});

View File

@@ -0,0 +1,182 @@
// @vitest-environment node
/*
FNXC:WorkflowResolvedColumns 2026-07-27-16:05 (U10 / R8):
The open-PR backward-move guard compared positions with `COLUMNS.indexOf(...)`, the legacy enum.
A workflow that RENAMES its review/implementation lanes yields -1 for both, and
`isBackwardMoveBlockedByOpenPr` returns false the moment either index is negative — so the guard
did not reject the move, it stopped existing, with a green suite and an orphaned GitHub PR as the
only evidence. This is the "a converted guard silently stops firing" failure mode the program's
risk table names, reached here through a rename rather than a conversion.
The guard must order columns by the TASK'S OWN workflow, falling back to the legacy enum only
when no workflow IR resolves.
*/
import { describe, it, expect, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import express from "express";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
/** A workflow with the default lifecycle SHAPE but renamed column ids. */
const RENAMED_IR = {
version: "v2",
id: "wf-renamed",
name: "Renamed Flow",
columns: [
{ id: "backlog", name: "Backlog", traits: [{ trait: "intake" }] },
{ id: "staging", name: "Staging", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "signoff", name: "Sign-off", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
],
nodes: [{ id: "start", kind: "start", column: "backlog" }],
edges: [],
};
/**
* The U11 shape: `todo` is gone, the remaining ids are the legacy ones. Rows already stored in
* `todo` outlive the column, so the workflow's own ordering cannot place them.
*/
const REMOVED_HOLD_IR = {
version: "v2",
id: "wf-no-todo",
name: "No Todo Flow",
columns: [
{ id: "triage", name: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] },
{ id: "in-progress", name: "In progress", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "in-review", name: "In review", traits: [{ trait: "merge-blocker" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [{ id: "start", kind: "start", column: "triage" }],
edges: [],
};
function buildStore(options: {
taskColumn: string;
workflowId?: string;
prState?: string | null;
}): { store: TaskStore; moveTask: ReturnType<typeof vi.fn> } {
const moveTask = vi.fn(async (_id: string, column: string) => ({
id: "FN-001",
column,
dependencies: [],
steps: [],
currentStep: 0,
}));
const store = {
getRootDir: vi.fn(() => process.cwd()),
getProjectScopedPluginMcpServers: vi.fn(async () => []),
getTask: vi.fn(async () => ({ id: "FN-001", column: options.taskColumn, dependencies: [], steps: [], currentStep: 0 })),
getSettings: vi.fn(async () => ({})),
getTaskWorkflowSelection: vi.fn(() => (options.workflowId ? { workflowId: options.workflowId } : undefined)),
getWorkflowDefinition: vi.fn(async (id: string) => {
if (id === "wf-renamed") return { id, name: "Renamed Flow", kind: "workflow", ir: RENAMED_IR };
if (id === "wf-no-todo") return { id, name: "No Todo Flow", kind: "workflow", ir: REMOVED_HOLD_IR };
return null;
}),
getActivePrEntityBySource: vi.fn(async () =>
options.prState ? { id: "PR-1", state: options.prState, sourceType: "task", sourceId: "FN-001" } : null,
),
moveTask,
} as unknown as TaskStore;
return { store, moveTask };
}
async function postMove(store: TaskStore, column: string) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return REQUEST(app, "POST", "/api/tasks/FN-001/move", JSON.stringify({ column }), {
"content-type": "application/json",
});
}
describe("task move route — open-PR backward guard uses the task's own column order", () => {
it("blocks a backward move between RENAMED columns while a PR is open", async () => {
const { store, moveTask } = buildStore({
taskColumn: "signoff",
workflowId: "wf-renamed",
prState: "open",
});
const res = await postMove(store, "building");
expect(res.status).toBe(409);
expect(moveTask).not.toHaveBeenCalled();
});
it("allows a FORWARD move between renamed columns with a PR open", async () => {
const { store, moveTask } = buildStore({
taskColumn: "building",
workflowId: "wf-renamed",
prState: "open",
});
const res = await postMove(store, "signoff");
expect(res.status).toBe(200);
expect(moveTask).toHaveBeenCalledTimes(1);
});
it("allows a backward move between renamed columns once no PR is active", async () => {
const { store, moveTask } = buildStore({
taskColumn: "signoff",
workflowId: "wf-renamed",
prState: null,
});
const res = await postMove(store, "building");
expect(res.status).toBe(200);
expect(moveTask).toHaveBeenCalledTimes(1);
});
/*
FNXC:WorkflowResolvedColumns 2026-07-27-18:05 (U10 / R8 — greptile P1 on PR #2492):
The first cut of this fix used the workflow ordering UNCONDITIONALLY, which reopened the same
hole from the other side: a task still STORED in a column the workflow removed or renamed scores
-1 for its source, and a negative index means "allow". So the very rows U11 leaves behind — cards
sitting in `todo` after Todo is merged into Planning — could be dragged backward past an open PR.
The workflow is authoritative only when it can place BOTH endpoints; otherwise fall back to the
legacy enum, which still places every legacy id.
*/
it("blocks a backward move when the task's SOURCE column is one the workflow removed", async () => {
const { store, moveTask } = buildStore({
// A row left behind in `todo` after the workflow dropped that column (the U11 shape).
// The workflow's own ordering cannot place it, so the legacy enum must still order it.
taskColumn: "todo",
workflowId: "wf-no-todo",
prState: "open",
});
const res = await postMove(store, "triage");
expect(res.status).toBe(409);
expect(moveTask).not.toHaveBeenCalled();
});
it("allows a forward move out of a removed source column", async () => {
const { store, moveTask } = buildStore({
taskColumn: "todo",
workflowId: "wf-no-todo",
prState: "open",
});
const res = await postMove(store, "in-progress");
expect(res.status).toBe(200);
expect(moveTask).toHaveBeenCalledTimes(1);
});
it("still blocks the legacy in-review → in-progress backward move (default workflow)", async () => {
const { store, moveTask } = buildStore({ taskColumn: "in-review", prState: "open" });
const res = await postMove(store, "in-progress");
expect(res.status).toBe(409);
expect(moveTask).not.toHaveBeenCalled();
});
});

View File

@@ -96,9 +96,24 @@ function toV2(ir: WorkflowIr): WorkflowIrV2 | undefined {
return ir.version === "v2" ? ir : undefined; return ir.version === "v2" ? ir : undefined;
} }
/*
* FNXC:WorkflowResolvedColumns 2026-07-27-16:45 (U10 / R8):
* The canonical map is a FALLBACK, not an override. Applied unconditionally it replaced the name
* a built-in workflow deliberately chose — `builtin:lead-generation` names `triage` "Lead intake"
* and the board rendered "Planning" — and it is the same mechanism that would clobber a renamed
* built-in column (U11's Todo -> Planning). Canonicalise only when the IR's own name adds nothing:
* blank, the raw column id, or the same words in different case (the "In progress"/"In Progress"
* variants that motivated the map). Anything else is an authored name and wins.
*/
function displayColumnName(id: string, name: string, canonicalizeLifecycle: boolean): string { function displayColumnName(id: string, name: string, canonicalizeLifecycle: boolean): string {
if (!canonicalizeLifecycle) return name; if (!canonicalizeLifecycle) return name;
return BUILTIN_WORKFLOW_COLUMN_LABELS[id] ?? name; const canonical = BUILTIN_WORKFLOW_COLUMN_LABELS[id];
if (!canonical) return name;
const trimmed = name?.trim() ?? "";
const isUninformative = trimmed === ""
|| trimmed.toLowerCase() === id.toLowerCase()
|| trimmed.toLowerCase() === canonical.toLowerCase();
return isUninformative ? canonical : trimmed;
} }
function describeColumns(ir: WorkflowIr, canonicalizeLifecycle = false): BoardWorkflowColumn[] { function describeColumns(ir: WorkflowIr, canonicalizeLifecycle = false): BoardWorkflowColumn[] {

View File

@@ -27,6 +27,7 @@ import type {
RunAuditEvent, RunAuditEvent,
ArtifactType, ArtifactType,
PrInfo, PrInfo,
WorkflowIr,
} from "@fusion/core"; } from "@fusion/core";
import { import {
COLUMNS, COLUMNS,
@@ -169,6 +170,53 @@ async function resolveIntakeColumnForTask(store: TaskStore, taskId: string): Pro
} }
} }
/*
FNXC:WorkflowResolvedColumns 2026-07-27-16:15 (U10 / R8):
Lifecycle POSITION — "is this move backward?" — resolved through `COLUMNS.indexOf(...)`, the
legacy enum. A workflow that renames its lanes returns -1 for both endpoints, and
`isBackwardMoveBlockedByOpenPr` treats a negative index as "cannot tell → allow", so the open-PR
guard silently stopped existing on every custom board rather than rejecting anything. A guard that
never fires does not fail a test.
`ir.columns` is ordered and that order IS the lifecycle order (see the graph entry contract), so
the workflow is the authority. The legacy enum stays as the fallback for an unresolvable or v1
(column-less) IR, which keeps `builtin:coding` — whose column order equals the enum — unchanged.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-27-18:20 (U10 / R8 — greptile P1 on PR #2492):
The workflow is authoritative ONLY when it can place BOTH endpoints. Using its ordering
unconditionally reopened the same hole from the other side: a row still stored in a column the
workflow removed or renamed scores -1, and a negative index means "allow" — so exactly the rows
U11 leaves behind in `todo` could be dragged backward past an open PR.
Two orderings, never mixed. Mixing them would misjudge a workflow that REORDERS legacy ids (its
own order says forward while the enum says backward), so the enum is a fallback for the whole
comparison, not a per-column patch.
Residual, deliberately not papered over: when the source is undeclared AND the target is a
workflow-only id, neither ordering places both and the guard cannot fire. That is NOT a
regression — the previous `COLUMNS.indexOf` scored the custom target -1 and was equally absent.
Closing it needs the guard restated in terms of column TRAITS rather than position, which belongs
with the merge lane's conversion (U9), not with a rendering unit.
*/
function resolveMoveOrderIndices(
ir: WorkflowIr | undefined,
fromColumn: string,
toColumn: string,
): { fromIndex: number; toIndex: number } {
const declared = (ir as { columns?: Array<{ id: string }> } | undefined)?.columns;
if (Array.isArray(declared) && declared.length > 0) {
const order = new Map(declared.map((column, index) => [column.id, index]));
const fromIndex = order.get(fromColumn) ?? -1;
const toIndex = order.get(toColumn) ?? -1;
if (fromIndex >= 0 && toIndex >= 0) return { fromIndex, toIndex };
}
return {
fromIndex: COLUMNS.indexOf(fromColumn as Column),
toIndex: COLUMNS.indexOf(toColumn as Column),
};
}
async function resolveWipColumnForTask(store: TaskStore, taskId: string): Promise<string> { async function resolveWipColumnForTask(store: TaskStore, taskId: string): Promise<string> {
try { try {
const ir = await resolveWorkflowIrForTask(store, taskId); const ir = await resolveWorkflowIrForTask(store, taskId);
@@ -748,6 +796,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
(task.branchContext?.groupId (task.branchContext?.groupId
? await scopedStore.getActivePrEntityBySource?.("branch-group", task.branchContext.groupId) ? await scopedStore.getActivePrEntityBySource?.("branch-group", task.branchContext.groupId)
: null); : null);
/*
FNXC:WorkflowResolvedColumns 2026-07-27-16:20 (U10 / R8):
Deliberately still on the legacy enum, unlike the move route's copy of this guard. This whole
function is gated on the literal `task.column !== "in-review"` above and re-engages to the
literal `"in-progress"`, so both endpoints are legacy ids by construction and the enum resolves
them correctly. Swapping in the task's workflow order here would make the guard WEAKER, not
stronger: a workflow declaring `in-review` but not `in-progress` would score -1 for the target
and disable the guard entirely. Convert this site when its surrounding literals are converted
(U5 owns the re-engage lane), not before.
*/
if ( if (
isBackwardMoveBlockedByOpenPr({ isBackwardMoveBlockedByOpenPr({
fromIndex: COLUMNS.indexOf(task.column as Column), fromIndex: COLUMNS.indexOf(task.column as Column),
@@ -1763,13 +1821,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
(guardTask.branchContext?.groupId (guardTask.branchContext?.groupId
? await scopedStore.getActivePrEntityBySource?.("branch-group", guardTask.branchContext.groupId) ? await scopedStore.getActivePrEntityBySource?.("branch-group", guardTask.branchContext.groupId)
: null); : null);
if ( // FNXC:WorkflowResolvedColumns 2026-07-27-16:15 (U10 / R8): position comes from the
isBackwardMoveBlockedByOpenPr({ // task's own workflow column order (already resolved above as `moveTargetIr`), falling
fromIndex: COLUMNS.indexOf(guardTask.column as Column), // back to the legacy enum when the workflow cannot place both endpoints.
toIndex: COLUMNS.indexOf(moveTarget), const { fromIndex, toIndex } = resolveMoveOrderIndices(moveTargetIr, guardTask.column, moveTarget);
activePrEntity, if (isBackwardMoveBlockedByOpenPr({ fromIndex, toIndex, activePrEntity })) {
})
) {
throw new ApiError(409, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE, { throw new ApiError(409, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE, {
code: "pr-open-blocks-move-back", code: "pr-open-blocks-move-back",
messageKey: "board.rejection.prOpenBlocksMoveBack", messageKey: "board.rejection.prOpenBlocksMoveBack",