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",