From a271f1868f6d70f0a31e3541f70813165a07de23 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 27 Jul 2026 21:48:08 -0700 Subject: [PATCH] U10: dashboard renders workflow-resolved columns (6 legacy-vocabulary defects, incl. a silently-disabled open-PR guard) (#2492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/u10-dashboard-resolved-columns.md | 7 + packages/dashboard/app/components/Board.tsx | 25 +- .../dashboard/app/components/ListView.tsx | 68 ++- .../app/components/TaskContextMenu.tsx | 22 +- .../app/components/TaskDetailModal.tsx | 33 +- .../TaskDetailModal.resolved-columns.test.tsx | 122 +++++ .../workflow-resolved-columns.test.tsx | 468 ++++++++++++++++++ .../src/__tests__/board-workflows.test.ts | 49 ++ ...kflow-routes.resolved-column-order.test.ts | 182 +++++++ .../dashboard/src/routes/board-workflows.ts | 17 +- .../routes/register-task-workflow-routes.ts | 70 ++- 11 files changed, 1033 insertions(+), 30 deletions(-) create mode 100644 .changeset/u10-dashboard-resolved-columns.md create mode 100644 packages/dashboard/app/components/__tests__/TaskDetailModal.resolved-columns.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx create mode 100644 packages/dashboard/src/routes/__tests__/register-task-workflow-routes.resolved-column-order.test.ts diff --git a/.changeset/u10-dashboard-resolved-columns.md b/.changeset/u10-dashboard-resolved-columns.md new file mode 100644 index 0000000000..1d634221b0 --- /dev/null +++ b/.changeset/u10-dashboard-resolved-columns.md @@ -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. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 3c2bca1522..a6d9d3239a 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -749,6 +749,13 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o 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. + + 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(() => { const byId = new Map(); @@ -770,23 +777,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o } } } - for (const column of COLUMNS) { - 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); - }); + return [...byId.values()]; }, [boardWorkflows]); const aggregateQuickCreateTarget = useMemo(() => { diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 9e178adc9a..8cb9484968 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -694,6 +694,51 @@ export function ListView({ return [...columnsById.values()]; }, [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(); + 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( + () => 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 map = new Map(); for (const column of listColumns) { @@ -919,9 +964,28 @@ export function ListView({ const groups: Record = {}; 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) => { 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) { @@ -951,7 +1015,7 @@ export function ListView({ }); } 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 const filteredCount = useMemo(() => { diff --git a/packages/dashboard/app/components/TaskContextMenu.tsx b/packages/dashboard/app/components/TaskContextMenu.tsx index 0cd86d61a9..50b979b920 100644 --- a/packages/dashboard/app/components/TaskContextMenu.tsx +++ b/packages/dashboard/app/components/TaskContextMenu.tsx @@ -46,6 +46,9 @@ export interface TaskContextMenuColumnFlags { intake?: boolean; mergeBlocker?: 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 { @@ -160,7 +163,24 @@ function getWorkflowMoveTargets(task: Task | TaskDetail, columns: readonly TaskC } 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 previous = visibleColumns[currentIndex - 1]?.id; const next = visibleColumns[currentIndex + 1]?.id; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index bd8aa67f87..f2217669c7 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -639,6 +639,24 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt // #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids // (non-members correctly resolve to false → not editable). const EDITABLE_COLUMNS: Set = new Set(["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 = new Set(["triage", "todo", "in-progress", "in-review", "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 // 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 githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true); const githubTrackedIssue = workingTask.githubTracking?.issue; @@ -4075,8 +4095,17 @@ export function TaskDetailContent({
{task.id} + {/* + 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. + */} - {columnLabel(task.column)} + {workflowColumnDisplayName ?? columnLabel(task.column)}
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.resolved-columns.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.resolved-columns.test.tsx new file mode 100644 index 0000000000..f05bce2e06 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.resolved-columns.test.tsx @@ -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( + , + ); +} + +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(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx b/packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx new file mode 100644 index 0000000000..54d3139d71 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx @@ -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 }) => ( +
task.id))} + /> + ), +})); + +vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () =>
})); +vi.mock("../TaskDetailModal", () => ({ TaskDetailContent: () =>
})); +vi.mock("../CustomModelDropdown", () => ({ CustomModelDropdown: () =>
})); + +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 & { 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, +): BoardWorkflowsPayload { + return { + flagEnabled: true, + defaultWorkflowId: workflows[0]!.id, + workflows, + taskWorkflowIds, + }; +} + +function renderBoard(tasks: Task[]) { + return render( + 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( + 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) => + 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"); + }); + }); +}); diff --git a/packages/dashboard/src/__tests__/board-workflows.test.ts b/packages/dashboard/src/__tests__/board-workflows.test.ts index 913fd948bd..e551db291d 100644 --- a/packages/dashboard/src/__tests__/board-workflows.test.ts +++ b/packages/dashboard/src/__tests__/board-workflows.test.ts @@ -57,3 +57,52 @@ describe("buildBoardWorkflowsPayload column descriptions", () => { 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", + }); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.resolved-column-order.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.resolved-column-order.test.ts new file mode 100644 index 0000000000..c6ea844ba0 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.resolved-column-order.test.ts @@ -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 } { + 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(); + }); +}); diff --git a/packages/dashboard/src/routes/board-workflows.ts b/packages/dashboard/src/routes/board-workflows.ts index 44ad7545d4..e6a503b962 100644 --- a/packages/dashboard/src/routes/board-workflows.ts +++ b/packages/dashboard/src/routes/board-workflows.ts @@ -96,9 +96,24 @@ function toV2(ir: WorkflowIr): WorkflowIrV2 | 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 { 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[] { diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 19228f5861..6bcdb14702 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -27,6 +27,7 @@ import type { RunAuditEvent, ArtifactType, PrInfo, + WorkflowIr, } from "@fusion/core"; import { 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 { try { const ir = await resolveWorkflowIrForTask(store, taskId); @@ -748,6 +796,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork (task.branchContext?.groupId ? await scopedStore.getActivePrEntityBySource?.("branch-group", task.branchContext.groupId) : 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 ( isBackwardMoveBlockedByOpenPr({ fromIndex: COLUMNS.indexOf(task.column as Column), @@ -1763,13 +1821,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork (guardTask.branchContext?.groupId ? await scopedStore.getActivePrEntityBySource?.("branch-group", guardTask.branchContext.groupId) : null); - if ( - isBackwardMoveBlockedByOpenPr({ - fromIndex: COLUMNS.indexOf(guardTask.column as Column), - toIndex: COLUMNS.indexOf(moveTarget), - activePrEntity, - }) - ) { + // FNXC:WorkflowResolvedColumns 2026-07-27-16:15 (U10 / R8): position comes from the + // task's own workflow column order (already resolved above as `moveTargetIr`), falling + // back to the legacy enum when the workflow cannot place both endpoints. + const { fromIndex, toIndex } = resolveMoveOrderIndices(moveTargetIr, guardTask.column, moveTarget); + if (isBackwardMoveBlockedByOpenPr({ fromIndex, toIndex, activePrEntity })) { throw new ApiError(409, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE, { code: "pr-open-blocks-move-back", messageKey: "board.rejection.prOpenBlocksMoveBack",