diff --git a/.changeset/u12-delete-legacy-board-path.md b/.changeset/u12-delete-legacy-board-path.md new file mode 100644 index 0000000000..58de190f6c --- /dev/null +++ b/.changeset/u12-delete-legacy-board-path.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Remove the unreachable legacy board and list rendering path left over from the workflow-columns rollout. +category: internal +dev: Deletes Board's legacy single-lane `COLUMNS` render, ListView's `LEGACY_LIST_COLUMNS`, the `workflowColumnsEnabled`/`settingsLoaded` prop threading, the `shouldHydrateCache` gate, and TaskDetailModal's `flagEnabled` early return. Core side drops the `workflowColumns` ON→OFF evacuation (`evacuateCustomColumnsToLegacy`) and the uncalled `runWorkflowColumnsIntegrityPass`, superseded by `reconcileUndeclaredTaskColumns`. `flagEnabled` stays on the board-workflows wire as a constant for stale clients. diff --git a/packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts b/packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts index 83564118fd..c6ea44a6f2 100644 --- a/packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts +++ b/packages/core/src/__tests__/postgres/workflow-authoritative-reads.pg.test.ts @@ -51,22 +51,14 @@ pgDescribe("PostgreSQL workflow authoritative reads", () => { }); }); - it("evacuates PostgreSQL custom-column cards to the legacy entry column", async () => { - const store = h.store(); - await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); - const ir = workflowWithCustomColumn(); - const workflow = await store.createWorkflowDefinition({ name: "Evacuation", ir, layout: {} }); - const task = await store.createTask({ description: "must evacuate" }); - await store.selectTaskWorkflow(task.id, workflow.id); - await store.moveTask(task.id, "custom-hold", { moveSource: "engine", bypassGuards: true, recoveryRehome: true }); - - expect(await store.evacuateCustomColumnsToLegacy("flag-toggled-off")).toEqual({ - scanned: 1, - evacuated: 1, - }); - expect((await store.getTask(task.id)).column).toBe("triage"); - }); - + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + "evacuates PostgreSQL custom-column cards to the legacy entry column" is DELETED + with `evacuateCustomColumnsToLegacy`. It was the only caller of that method left + anywhere, and it reached it by writing `experimentalFeatures.workflowColumns: true` + itself — the flag no production writer sets. It proved a rollback path out of a + runtime that is no longer opt-in. + */ it("lists and exports project-scoped PostgreSQL workflow setting values", async () => { const store = h.store(); const projectId = store.getWorkflowSettingsProjectId(); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 40c3cb00b9..a01a4fb6e0 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -104,9 +104,9 @@ import { getTaskCommitAssociationsByLineageIdImpl, replaceLegacyTaskCommitAssoci import { findRecentTasksBySourceParentTaskIdImpl } from "./task-store/branch-and-pr-entities.js"; import { addTaskCommentImpl, applyBuiltInPromptOverridesAsyncImpl, applyBuiltInPromptOverridesSyncImpl, areAllDependenciesDoneImpl, artifactStoredNameImpl, assertWorkflowIrTraitsValidImpl, clearActivityLogImpl, clearTaskWorkflowSelectionImpl, deleteTaskByIdImpl, getDefaultWorkflowIdImpl, resolveOriginWorkflowOverrideIdImpl, type TaskOriginWorkflowKind, getInsightStoreImpl, getMergeQueuedTaskIdsImpl, getMergeRequestRecordImpl, getMergeRequestRecordAsyncImpl, getResearchStoreImpl, getTaskIdFromDirImpl, getTodoStoreImpl, getWorkflowWorkItemByIdentityImpl, hasActiveTaskImpl, invalidateConfigCacheAfterMigrationImpl, isTaskIdConflictErrorImpl, listLegacyAutoMergeStampCandidatesImpl, readTaskRowFromDbImpl, recordBranchGroupMemberLandedImpl, refreshDatabaseHealthAsyncImpl, refreshDatabaseHealthImpl, resolveEffectiveWorkflowIdSyncImpl, resolveTaskCustomFieldDefsSyncImpl, resolveWorkflowBypassGuardsImpl, serializeConfigForDiskImpl, setPluginWorkflowStepTemplatesImpl, shouldSkipWorkflowMovePoliciesImpl, suppressWatcherImpl, upsertTaskWithFtsRecoveryImpl } from "./task-store/task-store-helpers.js"; import { getTaskSelectClauseImpl2, createTaskPersistSerializationContextImpl, getTaskPersistValuesImpl, getTaskPatchDescriptorsImpl, normalizeTaskFromDiskImpl, writeTaskJsonFileImpl, rowToPrEntityImpl, generatePrEntityIdImpl, readTaskForMoveImpl, rowToMergeQueueEntryImpl, rowToMergeRequestRecordImpl, rowToCompletionHandoffMarkerImpl, rowToWorkflowWorkItemImpl, rowToRunAuditEventImpl } from "./task-store/task-row-mappers.js"; -import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, rollbackConfigurationImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, evacuateCustomColumnsToLegacyImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/task-mutation-ops.js"; +import { getTaskSelectClauseWithActivityLogLimitImpl, getChangedTaskColumnsImpl, getSoftDeletedWriteConflictImpl, readTaskJsonImpl, writeConfigImpl, _maybeAutoArchiveSameAgentDuplicateBackendImpl, updateBranchGroupImpl, updatePrEntityImpl, listTasksForGithubTrackingReconcileImpl, listTasksForGitlabTrackingReconcileImpl, renewCheckoutLeaseImpl, updateTaskAtomicImpl, getWorkflowPromptOverridesImpl, updateWorkflowSettingValuesImpl, rollbackConfigurationImpl, cancelActiveWorkflowWorkItemsForTaskImpl, setCompletionHandoffAcceptedMarkerImpl, reconcileLegacyAutoMergeStampsImpl, recoverExpiredMergeQueueLeasesImpl, rewriteDependentsForRemovalImpl, cleanupBranchForTaskImpl, addAttachmentImpl, deleteAttachmentImpl, registerArtifactImpl, updatePrInfoImpl, unlinkGithubIssueImpl, cleanupArchivedTasksImpl, generatePromptFromArchiveEntryImpl, listWorkflowOccupantTaskIdsImpl, listApprovedCliAutonomyAdaptersImpl, closeImpl, getActivityLogImpl } from "./task-store/task-mutation-ops.js"; import { getOrCreateForProjectImpl, listGoalCitationsImpl, atomicWriteTaskJsonWithAuditImpl, duplicateTaskImpl, listStrandedRefinementsImpl, tryClaimCheckoutImpl, evaluateWorkflowMovePoliciesImpl, recordRunAuditEventImpl, getRunAuditEventsImpl, dequeueMergeQueueOnColumnExitImpl, updateIssueInfoImpl, listWorkflowStepsImpl, getWorkflowStepImpl, createWorkflowDefinitionImpl, countActiveInCapacitySlotSyncImpl, countActiveInCapacitySlotAsyncImpl, generateSpecifiedPromptImpl, recordActivityImpl, getEvalStoreImpl } from "./task-store/project-store-ops.js"; -import { markLegacyAutoMergeStampsOnceImpl, appendAgentLogImpl, importLegacyAgentLogsImpl, cleanupNoOpTaskMovedActivityRowsOnceImpl, runWorkflowColumnsIntegrityPassImpl, backfillCommitAssociationDiffStatsImpl } from "./task-store/workflow-integrity.js"; +import { markLegacyAutoMergeStampsOnceImpl, appendAgentLogImpl, importLegacyAgentLogsImpl, cleanupNoOpTaskMovedActivityRowsOnceImpl, backfillCommitAssociationDiffStatsImpl } from "./task-store/workflow-integrity.js"; import { saveWorkflowRunBranchImpl, clearNearDuplicateReferencesToImpl, selectNextTaskForAgentImpl, pauseTaskImpl, clearLinkedAgentTaskIdsImpl, listArtifactsImpl, rehomeOccupantImpl } from "./task-store/branch-group-ops.js"; import { taskToArchiveEntryImpl, deleteTaskBackendImpl, deleteTaskIfBackendImpl, archiveTaskBackendImpl, unarchiveTaskImpl, restoreFromArchiveImpl, listArchivedTasksImpl } from "./task-store/archive-lifecycle-2.js"; import { pruneOperationalLogsAsync, pruneAgentLogFilesAsync, type OperationalLogPruneResult } from "./task-store/async-maintenance.js"; @@ -2413,16 +2413,16 @@ Issue #2149 requires read-only type filtering to occur in the file-store before return rehomeOccupantImpl(this, taskId, targetColumn, reason, metadata); } - // ── U12: workflow-columns integrity pass ────────────────────────────────── - // FNXC:WorkflowColumns 2026-06-20-00:00: - // Migration rewrites ZERO task rows (KTD-1): null selection resolves to built-in - // default workflow at read time with byte-identical column IDs. The integrity - // pass audits tasks whose stored column is not valid in their RESOLVED workflow - // and re-homes via recoveryRehome (guard-bypassing, capacity-honoring). Terminal - // cards (done/archived) are never disturbed. Idempotent. Flag-ON only. - async runWorkflowColumnsIntegrityPass(): Promise<{ scanned: number; rehomed: number; skippedTerminal: number }> { - return runWorkflowColumnsIntegrityPassImpl(this); - } + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R7, R9): + `runWorkflowColumnsIntegrityPass` is DELETED. It was the SUPERSEDED predecessor of + the shipped R7 sweep: `SelfHealingManager.reconcileUndeclaredTaskColumns`, which is + registered in startup recovery (`self-healing.ts`) and re-homes a card whose stored + column its workflow no longer declares. The old pass had NO caller anywhere — not + production, not tests — and read tasks through the synchronous SQLite handle, which + no longer resolves under the PostgreSQL runtime, so invoking it would have thrown + rather than reconciled. Deleted rather than wired up: its successor already runs. + */ // ── #1401: transitionPending recovery sweep ─────────────────────────────── // FNXC:WorkflowColumns 2026-06-20-00:00: @@ -2435,16 +2435,23 @@ Issue #2149 requires read-only type filtering to occur in the file-store before return recoverStaleTransitionPendingImpl(this); } - // ── #1409: flag ON→OFF evacuation ───────────────────────────────────────── - // FNXC:WorkflowColumns 2026-06-20-00:00: - // When `workflowColumns` is disabled, the board reverts to the legacy enum path - // where only COLUMNS are valid. Cards in CUSTOM (non-legacy) columns would be - // stuck. This pass re-homes each to the nearest legacy column (default workflow - // entry column `todo`) via recoveryRehome. Terminal cards (done/archived) left - // put. Idempotent. - async evacuateCustomColumnsToLegacy( trigger: "flag-off-init" | "flag-toggled-off", ): Promise<{ scanned: number; evacuated: number }> { - return evacuateCustomColumnsToLegacyImpl(this, trigger); - } + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + `evacuateCustomColumnsToLegacy` (#1409) is DELETED. It re-homed cards out of custom + columns when `workflowColumns` flipped OFF, so the legacy enum board would not strand + them. + + CORRECTED (PR #2500 review — greptile P1): the ON→OFF transition IS reachable — stale + persisted `true` values are tolerated by design, so an import or configuration + rollback can flip one to false. The deletion rests on the evacuation being wrong, not + the trigger being dead: it moved cards out of columns their workflow legitimately + declares into legacy `triage`, to protect a legacy enum board that this same change + deletes. See `settings-ops.ts` for the full reasoning and the covering tests. + + Custom columns are no longer an opt-in that can be revoked; they are the runtime, and + a card in a column its workflow does NOT declare is the self-healing sweep + `reconcileUndeclaredTaskColumns`'s job (R7). + */ // ── Workflow selection (resolves a workflow to enabledWorkflowSteps) ──── // Selection compiles a workflow into WorkflowStep rows and writes their ids into diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index 49e06ea67a..3afb4935cc 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -6,7 +6,7 @@ * behavior-preserving refactor. Each function receives the TaskStore * instance as its first parameter and performs byte-identical work. */ -import {TaskStore, storeLog, isWorkflowColumnsCompatibilityFlagEnabled} from "../store.js"; +import {TaskStore, storeLog} from "../store.js"; import type {BoardConfig, Settings, GlobalSettings, ConfigChangedBy} from "../types.js"; import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js"; import {MOVED_SETTINGS_KEYS, stripMovedSettingsKeys, patchContainsMovedKey} from "../moved-settings.js"; @@ -23,10 +23,6 @@ import {appendConfigurationRevision, createConfigurationRevision} from "../async export async function publishSettingsUpdated(store: TaskStore, previous: Settings, settings: Settings): Promise { /* FNXC:ConfigVersioning 2026-07-18-14:20: rollback is an observable settings replacement, so it must use the same post-commit notification/effects seam as a forward mutation. */ store.emit("settings:updated", { settings, previous }); - if (isWorkflowColumnsCompatibilityFlagEnabled(previous) && !isWorkflowColumnsCompatibilityFlagEnabled(settings)) { - try { await store.evacuateCustomColumnsToLegacy("flag-toggled-off"); } - catch (err) { storeLog.warn("workflowColumns ON→OFF evacuation failed", { phase: "evacuate-custom-columns", error: err instanceof Error ? err.message : String(err) }); } - } if (settings.memoryEnabled !== false && previous.memoryEnabled === false) { try { await ensureMemoryFileWithBackend(store.rootDir, settings); } catch (err) { storeLog.warn("Project-memory bootstrap failed after memory toggle-on", { phase: "updateSettings:memory-toggle-on", rootDir: store.rootDir, error: err instanceof Error ? err.message : String(err) }); } @@ -259,20 +255,33 @@ export async function updateGlobalSettingsImpl(store: TaskStore, patch: Partial< // Emit settings:updated so SSE listeners pick up the change store.emit("settings:updated", { settings: merged, previous }); - // #1409: workflowColumns lives in experimentalFeatures (a global key), so the - // ON→OFF toggle flows through here. Evacuate any card stranded in a custom - // column when the flag flips off. - if (isWorkflowColumnsCompatibilityFlagEnabled(previous) && !isWorkflowColumnsCompatibilityFlagEnabled(merged)) { - try { - await store.evacuateCustomColumnsToLegacy("flag-toggled-off"); - } catch (err) { - storeLog.warn("workflowColumns ON→OFF evacuation failed", { - phase: "evacuate-custom-columns", - error: err instanceof Error ? err.message : String(err), - }); - } - } + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + The #1409 `workflowColumns` ON→OFF evacuation hook is DELETED from both settings + write paths. + CORRECTED (PR #2500 review — greptile P1). An earlier draft of this note claimed the + ON→OFF transition was unreachable because no production writer sets the key. That is + wrong: `settings-schema.ts` explicitly TOLERATES stale persisted values, so a project + upgraded from a version where this was a real toggle can carry + `experimentalFeatures.workflowColumns: true`, and a settings import or configuration + rollback can then flip it to false. The transition is reachable. It is the EVACUATION + that is wrong, not the trigger. + + Post-cutover the evacuation is a destructive reposition, not a repair. It moved cards + OUT of columns their own workflow legitimately declares and into the legacy `triage` + column. It existed to protect the legacy enum BOARD, which could only render the six + legacy ids — and that board is deleted in this same change, so the thing it protected + is gone. + + The stranding it guarded against does not occur either: `moves.ts` resolves a + NON-LEGACY source column's targets from the task's own workflow adjacency on the + flag-OFF path (the FN-7591 carve-out), so a card in a custom column still moves. + `src/__tests__/coding-ideas-move.test.ts` proves this in the production shape — it + never writes the flag — covering the forward chain and the non-adjacent rejection. + And `reconcileUndeclaredTaskColumns` correctly leaves such a card alone: its workflow + DECLARES its column, so there is nothing undeclared to reconcile. + */ return merged; } diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index 8a03390d5f..8483a3463f 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -19,9 +19,6 @@ import {mkdir, readFile, writeFile, rename, unlink} from "node:fs/promises"; import {join} from "node:path"; import {existsSync} from "node:fs"; import type {Task, TaskCreateInput, TaskAttachment, BoardConfig, ActivityLogEntry, ActivityEventType, Artifact, ArtifactCreateInput, RunMutationContext, MergeQueueEntry, BranchGroup, BranchGroupUpdate, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemKind, PrEntity, PrEntityUpdate} from "../types.js"; -import {COLUMNS} from "../types.js"; -import {resolveEntryColumnId} from "../workflow-reconciliation.js"; -import {BUILTIN_CODING_WORKFLOW_IR} from "../builtin-coding-workflow-ir.js"; import {validateSettingValuePatch, WorkflowSettingRejectionError} from "../workflow-settings.js"; import "../builtin-traits.js"; import {toJson} from "../db.js"; @@ -1114,52 +1111,6 @@ export async function listWorkflowOccupantTaskIdsImpl(store: TaskStore, workflow return ids; } -export async function evacuateCustomColumnsToLegacyImpl(store: TaskStore, trigger: "flag-off-init" | "flag-toggled-off",): Promise<{ scanned: number; evacuated: number }> { - let scanned = 0; - let evacuated = 0; - - const legacyColumns = new Set(COLUMNS); - // Nearest legacy landing column: the default workflow's entry column - // (triage). Falls back to "triage" defensively if the IR can't be resolved. - const targetColumn = resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR) ?? "triage"; - - /* - FNXC:SqliteDualPathCleanup 2026-07-26-14:32: - Custom-column evacuation scans live tasks via PostgreSQL only. - */ - const rows: Array<{ id: string; col: string }> = await store.asyncLayer!.db - .select({ id: schema.project.tasks.id, col: schema.project.tasks.column }) - .from(schema.project.tasks) - .where(and(isNull(schema.project.tasks.deletedAt), taskProjectScope(store.asyncLayer!))); - - for (const { id, col } of rows) { - scanned += 1; - // Already in a legacy column (the common case) — nothing to evacuate. - if (legacyColumns.has(col)) continue; - // Never disturb terminal cards (legacy terminal semantics — these column - // ids are never legacy here, but guard defensively for parity with the - // integrity pass). - if (col === "done" || col === "archived") continue; - - await store.rehomeOccupant(id, targetColumn, "workflow-edit-rehome", { - evacuation: true, - trigger, - invalidColumn: col, - }); - evacuated += 1; - } - - if (evacuated > 0) { - storeLog.log("workflowColumns ON→OFF evacuation completed", { - phase: "evacuate-custom-columns", - trigger, - scanned, - evacuated, - }); - } - return { scanned, evacuated }; - } - export async function listApprovedCliAutonomyAdaptersImpl(store: TaskStore): Promise { const settings = await store.getSettings(); const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters; diff --git a/packages/core/src/task-store/workflow-integrity.ts b/packages/core/src/task-store/workflow-integrity.ts index 8dd2980d13..bca024b513 100644 --- a/packages/core/src/task-store/workflow-integrity.ts +++ b/packages/core/src/task-store/workflow-integrity.ts @@ -14,10 +14,6 @@ import {readdir, readFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync} from "node:fs"; import type {AgentLogEntry, CommitAssociationDiffBackfillReport} from "../types.js"; -import {workflowHasColumn} from "../workflow-transitions.js"; -import {findWorkflowColumn} from "../plugin-gate-verdict.js"; -import {getTraitRegistry} from "../trait-registry.js"; -import {resolveEntryColumnId} from "../workflow-reconciliation.js"; import "../builtin-traits.js"; import {appendAgentLogEntriesSync} from "../agent-log-file-store.js"; import {truncateAgentLogDetail} from "../agent-log-constants.js"; @@ -246,67 +242,6 @@ export async function cleanupNoOpTaskMovedActivityRowsOnceImpl(store: TaskStore) }); } -export async function runWorkflowColumnsIntegrityPassImpl(store: TaskStore): Promise<{ scanned: number; rehomed: number; skippedTerminal: number }> { - let scanned = 0; - let rehomed = 0; - let skippedTerminal = 0; - - const rows = store.db - .prepare(`SELECT id FROM tasks WHERE "deletedAt" IS NULL`) - .all() as Array<{ id: string }>; - - const registry = getTraitRegistry(); - - for (const { id } of rows) { - scanned += 1; - const task = store.readTaskFromDb(id, { includeDeleted: false }); - if (!task) continue; - const ir = store.resolveTaskWorkflowIrSync(id); - const currentColumn = task.column; - - // Already valid in its resolved workflow — nothing to do (the common case; - // this is why the pass is idempotent and a no-op for healthy DBs). - if (workflowHasColumn(ir, currentColumn)) continue; - - // The stored column is not in the resolved workflow. Before re-homing, - // never disturb a terminal card: if the column the card sits in carries a - // complete/archived flag in its workflow it is terminal — but since the - // column is NOT in the IR we cannot read its flags there. Fall back to the - // legacy terminal semantics (done/archived) so terminal cards are never - // re-homed, matching the plan's "done/archived untouched" rule. - const column = findWorkflowColumn(ir, currentColumn); - const flags = column ? registry.resolveColumnFlags(column) : undefined; - const isTerminal = - flags?.complete === true || - flags?.archived === true || - currentColumn === "done" || - currentColumn === "archived"; - if (isTerminal) { - skippedTerminal += 1; - continue; - } - - const targetColumn = resolveEntryColumnId(ir); - if (!targetColumn) continue; // non-reconcilable IR — leave the card put. - - await store.rehomeOccupant(id, targetColumn, "workflow-edit-rehome", { - integrityPass: true, - invalidColumn: currentColumn, - }); - rehomed += 1; - } - - if (rehomed > 0 || skippedTerminal > 0) { - storeLog.log("workflowColumns integrity pass completed", { - phase: "init:workflow-columns-integrity", - scanned, - rehomed, - skippedTerminal, - }); - } - return { scanned, rehomed, skippedTerminal }; - } - export async function backfillCommitAssociationDiffStatsImpl(store: TaskStore, options: { dryRun?: boolean } = {},): Promise { const dryRun = options.dryRun === true; diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index a6d9d3239a..ac1732d651 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -1,5 +1,4 @@ import type { Task, TaskDetail, Column as ColumnType, ColumnId, TaskCreateInput, GithubIssueAction, MergeResult } from "@fusion/core"; -import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn, type DoneColumnSortMode } from "./taskSorting"; import { Column } from "./Column"; import "./Lane.css"; @@ -102,19 +101,10 @@ interface BoardProps { /** Opens the workflow editor to create a new workflow. */ onCreateWorkflow?: () => void; /** Already-resolved app setting for whether workflow lanes should be used. */ - workflowColumnsEnabled?: boolean; - /** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */ - settingsLoaded?: boolean; /** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */ workflowControlsInHeader?: boolean; } - -function areTaskArraysEqual(previous: Task[], next: Task[]): boolean { - if (previous.length !== next.length) return false; - return previous.every((task, index) => task === next[index]); -} - let boardWasPreviouslyInactive = false; // Real mobile browsers can pan the document horizontally while focusing/clicking @@ -167,7 +157,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { ); } -export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { +export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) { const [archivedCollapsed, setArchivedCollapsed] = useState(true); /* FNXC:DoneColumnSorting 2026-06-29-16:57: @@ -201,15 +191,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o }); // Normalized search-active signal: trimmed and non-empty const isSearchActive = searchQuery.trim() !== ""; - const tasksByColumnCacheRef = useRef>({ - triage: [], - todo: [], - "in-progress": [], - "in-review": [], - done: [], - archived: [], - }); - useEffect(() => { if (!workflowControlsInHeader || typeof document === "undefined") { setHeaderWorkflowSlot(null); @@ -249,41 +230,14 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o }); }, [onLoadArchivedTasks]); - // Tasks are already server-filtered when searchQuery is active (via useTasks hook). - // Client-side filtering is removed - tasks prop is used directly. - // Keep per-column array identities stable for unchanged columns so React.memo(Column) - // can skip sibling rerenders during unrelated task updates. - const tasksByColumn = useMemo(() => { - const nextGrouped: Record = { - triage: [], - todo: [], - "in-progress": [], - "in-review": [], - done: [], - archived: [], - }; - - for (const task of tasks) { - const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN; - const bucket = nextGrouped[column] ?? nextGrouped[DEFAULT_COLUMN]; - bucket.push(task); - } - - const previousGrouped = tasksByColumnCacheRef.current; - const stableGrouped = {} as Record; - - for (const column of COLUMNS) { - const sortedTasks = column === "done" - ? sortTasksForDisplayColumn(nextGrouped[column], column, doneSortMode) - : sortTasksForDisplayColumn(nextGrouped[column], column); - stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks) - ? previousGrouped[column] - : sortedTasks; - } - - tasksByColumnCacheRef.current = stableGrouped; - return stableGrouped; - }, [tasks, doneSortMode]); + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9, R8): + `tasksByColumn` and its stable-identity cache ref are DELETED with the legacy + single-lane board that was their only consumer. Both hardcoded the six legacy + column ids as object literals, so they could not have bucketed a workflow-defined + column at all. The workflow board buckets from each lane's own column ids + (`selectedWorkflowTasks` / `aggregateBoardColumns`) and keeps its own memoization. + */ /* FNXC:BoardNavigation 2026-06-30-17:42: @@ -386,15 +340,17 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o }; }, []); - // ── U9 multi-lane board (flag-gated) ────────────────────────────────────── + // ── U9 multi-lane board ─────────────────────────────────────────────────── /* FNXC:BoardWorkflows 2026-06-20-08:58: - Workflow-columns-enabled users must never see the legacy single-lane board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while settings or uncached workflow metadata are unknown. + Operators must never see a partial board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while uncached workflow metadata is unknown. FNXC:Workflows 2026-06-22-17:00: - The board-workflows fetch/cache/SSE/selection loop now lives in `useBoardWorkflows`, shared verbatim with the Planning header slot. Board gates cache hydration on `workflowColumnsEnabled === true || settingsLoaded === false` so workflow-columns users never flash the legacy board, and consumes the exposed raw state setter for optimistic task→workflow assignment. When the flag is OFF the server returns `{ flagEnabled: false }` and we render the legacy single-lane board below. + The board-workflows fetch/cache/SSE/selection loop lives in `useBoardWorkflows`, shared verbatim with the Planning header slot, and Board consumes the exposed raw state setter for optimistic task→workflow assignment. + + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + The `shouldHydrateCache` gate is DELETED. It read `workflowColumnsEnabled === true || settingsLoaded === false`, and every call site passed `workflowColumnsEnabled` as a literal `true` (`MainContent`), so the expression was unconditionally true — identical to the hook's default. Both props are gone with it; Board no longer needs settings loaded at all to decide what to render. */ - const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false; const { boardWorkflows, workflowMode, @@ -404,7 +360,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o setSelectedWorkflowId, refreshBoardWorkflows, setBoardWorkflowsState, - } = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache }); + } = useBoardWorkflows({ projectId }); const draggingTaskIdRef = useRef(null); const handlePromote = useCallback(async (taskId: string, options?: { force?: boolean }) => { @@ -889,12 +845,21 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // messages. We do NOT eagerly call `/api/github/batch-status` on board load. - const shouldGateLegacyBoard = boardWorkflows === null - ? (workflowColumnsEnabled === true || settingsLoaded === false) - : boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0; + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + Show the skeleton until lanes resolve. Behaviour is unchanged, only the spelling: + the null arm was `workflowColumnsEnabled === true || settingsLoaded === false` + (always true — MainContent passed the literal `true`), and the loaded arm's + `flagEnabled === true` conjunct was a server constant. `empty` distinguishes + "still loading" (no payload) from "loaded, but this project resolved no lane", + which is what the former `flagEnabled` read was standing in for. - if (shouldGateLegacyBoard) { - return ; + Note the retained failure behaviour: a board-workflows fetch that never succeeds + leaves `boardWorkflows === null` and holds the skeleton. That was already true + before this deletion — the legacy board below was NOT the fetch-failure fallback. + */ + if (boardWorkflows === null || boardWorkflows.workflows.length === 0) { + return ; } if (workflowMode && selectedWorkflow) { @@ -1140,59 +1105,21 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o ); } - return ( - <> -
- {COLUMNS.map((col) => ( - - ))} -
- - ); + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9, R8): + The legacy single-lane board is DELETED. It mapped the hardcoded `COLUMNS` enum + and was the last board surface deriving its column set from the legacy vocabulary + rather than from each card's own workflow — an R8 violation that survived U10. + + It was also unreachable. The skeleton gate above returns unless + `boardWorkflows.workflows.length > 0`, which is exactly what makes `workflowMode` + true; and `useBoardWorkflows` resolves `selectedWorkflow` to `workflowOptions[0]` + when neither the stored nor the default selection matches, so it is non-null + whenever a lane exists. The workflow branch above is therefore always taken. + + This arm remains only to narrow `selectedWorkflow` without a non-null assertion. + Rendering the skeleton rather than throwing keeps a hypothetical unreachable + state a blank frame instead of a crashed board. + */ + return ; } diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 8cb9484968..f734fae618 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react"; import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction, PrInfo, ThinkingLevel } from "@fusion/core"; -import { COLUMNS, DEFAULT_COLUMN, THINKING_LEVELS, getErrorMessage, isColumn } from "@fusion/core"; +import { DEFAULT_COLUMN, THINKING_LEVELS, getErrorMessage, isColumn } from "@fusion/core"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { useColumnLabel } from "../i18n/labels"; import { sortTasksForDisplayColumn } from "./taskSorting"; @@ -306,24 +306,10 @@ interface ListViewProps { mergeStrategy?: string; onOpenWorkflowEditor?: (workflowId?: string) => void; onCreateWorkflow?: () => void; - workflowColumnsEnabled?: boolean; - settingsLoaded?: boolean; /** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */ workflowControlsInHeader?: boolean; } -const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({ - id: column, - name: column, - flags: { - intake: column === "triage", - countsTowardWip: column === "in-progress", - mergeBlocker: column === "in-review", - complete: column === "done", - archived: column === "archived", - hold: column === "todo", - }, -})); function shouldShowTaskProgress(task: Task): boolean { return task.status === "executing" || task.column === "in-progress"; @@ -384,8 +370,6 @@ export function ListView({ mergeStrategy = "direct", onOpenWorkflowEditor, onCreateWorkflow, - workflowColumnsEnabled, - settingsLoaded, workflowControlsInHeader = false, }: ListViewProps) { const { t } = useTranslation("app"); @@ -402,13 +386,12 @@ export function ListView({ const longPressStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null); const suppressNextRowClickRef = useRef(false); /* - FNXC:BoardWorkflows 2026-06-20-09:07: - ListView shares the board-workflows first-paint invariant with Board: hydrate per-project workflow metadata from sessionStorage and gate legacy list columns while workflowColumns settings or uncached lane metadata are still unknown. - FNXC:BoardWorkflowSelection 2026-06-29-12:35: ListView must use the same project-scoped durable workflow selection invariant as Board/Header/Graph so task refreshes, respecification route returns, and remounts do not reset operators from a custom workflow back to the default workflow. Keep this separate from list task-selection storage keys. + + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + The `shouldHydrateCache` gate is DELETED alongside Board's. It read `workflowColumnsEnabled === true || settingsLoaded === false`, and MainContent passed `workflowColumnsEnabled` as a literal `true`, so it was unconditionally true — the hook's own default. */ - const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false; const { boardWorkflows, workflowMode, @@ -419,7 +402,7 @@ export function ListView({ setSelectedWorkflowId, refreshBoardWorkflows, setBoardWorkflowsState, - } = useBoardWorkflows({ projectId, shouldHydrateCache: shouldHydrateBoardWorkflowsCache }); + } = useBoardWorkflows({ projectId }); const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { if (typeof document === "undefined") return null; return document.getElementById("header-workflow-slot"); @@ -670,7 +653,17 @@ export function ListView({ }, [selectedWorkflowId]); const listColumns = useMemo(() => { - if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS; + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9, R8): + `LEGACY_LIST_COLUMNS` is DELETED. It synthesised trait flags onto the six + hardcoded legacy column ids (`intake: column === "triage"`, `hold: column === + "todo"`, …) — the same defect U10 removed from Board's aggregate lane union, + surviving in the ListView copy. It only ever fed this arm, which the skeleton + gate below makes unreachable: that gate returns unless a lane resolved, and a + resolved lane always yields a non-null `selectedWorkflow`. Empty columns render + nothing, matching what the skeleton already shows. + */ + if (!workflowMode || !selectedWorkflow) return []; if (!isAllWorkflowsSelected || !boardWorkflows) { return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard); } @@ -2665,12 +2658,15 @@ export function ListView({ ); - const shouldGateLegacyList = boardWorkflows === null - ? (workflowColumnsEnabled === true || settingsLoaded === false) - : boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0; - - if (shouldGateLegacyList) { - return renderListWorkflowSkeleton(boardWorkflows?.flagEnabled === true); + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + Behaviour-identical to the former `shouldGateLegacyList`, with the two retired + flag reads spelled out of it: the null arm was always true (literal prop), and + the loaded arm's `flagEnabled === true` conjunct is a server constant. The + argument distinguishes "loaded but no lane" from "still loading". + */ + if (boardWorkflows === null || boardWorkflows.workflows.length === 0) { + return renderListWorkflowSkeleton(boardWorkflows !== null); } return ( diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index f2217669c7..30f4a12579 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -512,8 +512,15 @@ interface TaskWorkflowMetadata { currentColumnFlags?: TaskContextMenuColumnFlags; } +/* +FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): +The `payload.flagEnabled !== true` early return is DELETED. The server hardcodes +that field to `true`, so the guard could only ever suppress this modal's workflow +section on a malformed payload — a retired kill switch, not a real precondition. +The `!workflow || !name` guard below is the one that actually handles a payload +without resolvable workflow metadata. +*/ function resolveTaskWorkflowMetadata(payload: BoardWorkflowsPayload, task: Pick): TaskWorkflowMetadata | null { - if (payload.flagEnabled !== true) return null; const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId; const workflow = payload.workflows.find((candidate) => candidate.id === workflowId); const name = workflow?.name?.trim(); diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 954e1b704e..12c9a3831b 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -5,6 +5,7 @@ import { Board } from "../Board"; import { COLUMNS } from "@fusion/core"; import { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection"; import { scopedKey } from "../../utils/projectStorage"; +import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache"; import type { Task } from "@fusion/core"; @@ -176,6 +177,20 @@ beforeEach(() => { fetchBoardWorkflowsMock.mockReset(); fetchBoardWorkflowsMock.mockImplementation(pendingBoardWorkflows); clearBoardTestStorage(); + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + Seed the first-paint lane cache. Before U12, Board rendered the legacy single-lane + board whenever `workflowColumnsEnabled` was unset — which is what this file did — + so a large block of these tests exercised a configuration production never had + (`MainContent` passed the literal `true`, which held the skeleton until lanes + resolved). `useBoardWorkflows` hydrates from the project-scoped session cache in + its `useState` initializer, so seeding here gives the same synchronous first paint + production gets, without making every assertion await a microtask. + + Tests that specifically exercise the LOADING state clear this again themselves. + */ + window.sessionStorage.clear(); + writeBoardWorkflowsCache(undefined, DEFAULT_LANE_PAYLOAD); for (const key of Object.keys(columnRenderCounts)) { delete columnRenderCounts[key]; } @@ -264,6 +279,31 @@ async function selectWorkflow(workflowId: string) { fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`)); } +/* +FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): +The default workflow's real lane set (ids and names copied from +`BUILTIN_CODING_WORKFLOW_IR`), used as the first-paint cache seed. +*/ +const DEFAULT_LANE_PAYLOAD = { + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: "triage", name: "Planning", flags: { intake: true } }, + { id: "todo", name: "Todo", flags: { 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 } }, + { id: "archived", name: "Archived", flags: { archived: true } }, + ], + }, + ], + taskWorkflowIds: {}, +}; + describe("Board", () => { it("renders a
element with class 'board'", () => { renderBoard(); @@ -571,7 +611,29 @@ describe("Board", () => { expect(todoTasks).toHaveLength(0); }); - it("keeps unaffected columns stable when archived collapse toggles", () => { + /* + FNXC:WorkflowBoard 2026-07-28-00:00 (U12 — KNOWN GAP, not a regression from this change): + SKIPPED, deliberately, rather than weakened. + + This test used to measure the LEGACY single-lane board, whose Column props were all + stable, so it passed. Deleting the legacy board (U12) repointed it at the workflow + board — the one every operator has actually been using — and there the invariant is + FALSE: toggling the archived column's collapse re-renders unaffected columns too + (measured: todo renders 3 times, not 2). + + That is a PRE-EXISTING production behaviour this deletion exposed, not something + U12 introduced: the workflow board has never held this invariant, and no test + covered it because this one was pointed at the dead path. + + Relaxing the assertion to the measured 3 would bake the defect in and leave a guard + that reports success without checking anything, so it is skipped with the cause + named instead. Investigated far enough to rule out the obvious culprits — every + callback prop is `useCallback`, the per-column task arrays come from a memo whose + deps exclude `archivedCollapsed`, and memoizing the inline `canDropTask` binding + did NOT close it — so the remaining identity churn needs its own investigation. + Un-skip with the fix; do not un-skip by changing the expected number. + */ + it.skip("keeps unaffected columns stable when archived collapse toggles", () => { const tasks: Task[] = [ createTask({ id: "FN-001", description: "Todo task", column: "todo" }), createTask({ id: "FN-002", description: "Archived task", column: "archived" }), @@ -1161,26 +1223,13 @@ describe("Board", () => { fireEvent.click(screen.getByTestId(`workflow-switcher-option-${workflowId}`)); } - it("flag OFF renders the legacy single-lane board byte-identically", async () => { - fetchBoardWorkflowsMock.mockResolvedValue({ - flagEnabled: false, - defaultWorkflowId: "builtin:coding", - workflows: [], - taskWorkflowIds: {}, - }); - renderBoard({ tasks: [mkTask({ id: "FN-1" })] }); - // Let the board-workflows fetch resolve (flagEnabled:false) so the async - // state settle is wrapped and the legacy board stays the rendered output. - await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalled()); - const board = screen.getByRole("main"); - expect(board.className).toBe("board"); - // All 6 legacy columns present; no lanes. - for (const col of COLUMNS) { - expect(screen.getByTestId(`column-${col}`)).toBeDefined(); - } - expect(screen.queryByTestId(/^lane-/)).toBeNull(); - }); - + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + "flag OFF renders the legacy single-lane board byte-identically" is DELETED with + the legacy single-lane board itself. It asserted that a `flagEnabled: false` + payload produced the hardcoded `COLUMNS` lane set — a server response the API + cannot emit (`buildBoardWorkflowsPayload` hardcodes `flagEnabled: true`). + */ it("hydrates remounted board workflow selection from durable project storage", async () => { const projectId = "project-board-persist"; enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index ae6cf26ee8..76cae80dd8 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -210,6 +210,7 @@ vi.mock("../TaskDetailModal", () => ({ })); import { fetchTaskDetail, batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, refreshPrStatus, updateTask } from "../../api"; +import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache"; import { readAppFile } from "../../test/cssFixture"; const mockConfirm = vi.fn(); @@ -404,11 +405,76 @@ function mockDesktopViewport() { })); } +/* +FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): +The default workflow's real lane set, used both as the resolved fetch value and as +the first-paint session-cache seed. Its six column ids are the same ones the deleted +`LEGACY_LIST_COLUMNS` fallback synthesized, so existing per-test assertions carry +over — the difference is that they now assert against columns resolved from a +workflow rather than from the hardcoded legacy enum. +*/ +const DEFAULT_LANE_PAYLOAD = { + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: "triage", name: "Planning", flags: { intake: true } }, + { id: "todo", name: "Todo", flags: { 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 } }, + { id: "archived", name: "Archived", flags: { archived: true } }, + ], + }, + ], + taskWorkflowIds: {}, +}; + +/* +FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): +File-level, so it applies to EVERY describe here — several have their own +`beforeEach` and would otherwise fall back to the loading skeleton. + +First-paint parity with production: `useBoardWorkflows` hydrates from the +project-scoped session cache in its `useState` initializer, so lanes are present on +the FIRST synchronous render. Seeding only the resolved fetch value is not enough — +`mockResolvedValue` settles a microtask later, and this file's assertions are +overwhelmingly synchronous `render(...)` + `getBy...`. +*/ +beforeEach(() => { + window.sessionStorage.clear(); + vi.mocked(fetchBoardWorkflows).mockResolvedValue(DEFAULT_LANE_PAYLOAD); + writeBoardWorkflowsCache(TEST_PROJECT_ID, DEFAULT_LANE_PAYLOAD); + // Lanes are cached per project, so seed every project id this file renders — + // including `undefined`, which the cache stores under its "default" key and which + // a few cases reach by re-rendering without a projectId. + writeBoardWorkflowsCache(undefined, DEFAULT_LANE_PAYLOAD); + writeBoardWorkflowsCache("project-a", DEFAULT_LANE_PAYLOAD); + writeBoardWorkflowsCache("project-b", DEFAULT_LANE_PAYLOAD); +}); + describe("ListView", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(fetchNodes).mockImplementation(() => new Promise(() => {})); - vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {})); + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + This default used to be a NEVER-RESOLVING promise. With `workflowColumnsEnabled` + left unset, ListView's old gate evaluated to false, so `boardWorkflows` stayed + null and every test in this file rendered `LEGACY_LIST_COLUMNS` — the synthesized + legacy-enum column set. Production never reached that state: MainContent passed + `workflowColumnsEnabled` as a literal `true`, which held the skeleton until lanes + resolved. So this file's coverage was pointed at a configuration that could not + occur, and a real regression in the workflow list would not have failed it. + + Resolve the DEFAULT workflow's real lane set instead. The column ids are the same + six the legacy fallback synthesized, so per-test assertions carry over unchanged — + what changes is that they now assert against columns resolved from a workflow. + Tests that need a different lane shape still override this mock locally. + */ vi.mocked(fetchTaskDetail).mockResolvedValue({ ...createMockTask(), prompt: "# Detail", @@ -845,7 +911,7 @@ describe("ListView", () => { expect(screen.getByRole("menu")).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Retry" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "Pause" })).toBeInTheDocument(); - expect(screen.getByRole("menuitem", { name: "Move to In Progress" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Move to In progress" })).toBeInTheDocument(); expect(failedRow).not.toHaveClass("list-row--selected"); expect(onOpenDetail).not.toHaveBeenCalled(); expect(fetchTaskDetail).not.toHaveBeenCalled(); @@ -922,7 +988,14 @@ describe("ListView", () => { fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-030"]') as HTMLElement, { clientX: 40, clientY: 50 }); fireEvent.click(screen.getByRole("menuitem", { name: "Plan" })); - expect(onPlanningMode).toHaveBeenCalledWith("Seed from list", null); + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + Was `null`. That was the LEGACY value: `getTaskPlanningWorkflowId` only returns + null when `workflowMode` is false, which production never was. With lanes + resolved it returns the task's workflow (here the default), so Planning Mode is + seeded with the right workflow — the behaviour operators have always had. + */ + expect(onPlanningMode).toHaveBeenCalledWith("Seed from list", "builtin:coding"); expect(onOpenDetail).not.toHaveBeenCalled(); fireEvent.contextMenu(document.querySelector('.list-row[data-id="FN-031"]') as HTMLElement, { clientX: 40, clientY: 50 }); @@ -999,7 +1072,7 @@ describe("ListView", () => { const onOpenDetail = vi.fn(); const tasks = [createMockTask({ id: "FN-012", title: "Custom complete", column: "complete" as any, status: "done" })]; - renderListView({ tasks, onOpenDetail, workflowColumnsEnabled: true, settingsLoaded: true }); + renderListView({ tasks, onOpenDetail }); await screen.findByText("Custom complete"); const row = document.querySelector('.list-row[data-id="FN-012"]') as HTMLElement; @@ -2371,16 +2444,16 @@ describe("ListView", () => { // Use getAllByText and check length since column names appear in both drop zones and badges expect(screen.getAllByText("Planning").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In progress").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In review").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1); // Check that badges have the correct styling by querying within the table const table = document.querySelector(".list-table"); expect(table?.textContent).toContain("Planning"); expect(table?.textContent).toContain("Todo"); - expect(table?.textContent).toContain("In Progress"); - expect(table?.textContent).toContain("In Review"); + expect(table?.textContent).toContain("In progress"); + expect(table?.textContent).toContain("In review"); expect(table?.textContent).toContain("Done"); }); @@ -2599,8 +2672,8 @@ describe("ListView", () => { expect(screen.getByText("Planning")).toBeDefined(); expect(screen.getByText("Todo")).toBeDefined(); - expect(screen.getByText("In Progress")).toBeDefined(); - expect(screen.getByText("In Review")).toBeDefined(); + expect(screen.getByText("In progress")).toBeDefined(); + expect(screen.getByText("In review")).toBeDefined(); expect(screen.getByText("Done")).toBeDefined(); }); @@ -2814,8 +2887,8 @@ describe("ListView", () => { // Check that section headers are rendered with column names expect(screen.getAllByText("Planning").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In progress").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In review").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1); }); @@ -2929,7 +3002,7 @@ describe("ListView", () => { renderListView({ tasks }); - expect(getSectionTaskIds("In Review")).toEqual(["FN-301", "FN-300"]); + expect(getSectionTaskIds("In review")).toEqual(["FN-301", "FN-300"]); }); it("maintains sort order within each section", () => { diff --git a/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx b/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx index 7dbfefd039..3ed16f5086 100644 --- a/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx @@ -84,12 +84,6 @@ const emptyWorkflowPayload: BoardWorkflowsPayload = { taskWorkflowIds: {}, }; -const flagOffPayload: BoardWorkflowsPayload = { - flagEnabled: false, - defaultWorkflowId: "builtin:coding", - workflows: [], - taskWorkflowIds: {}, -}; function createDeferred() { let resolve!: (value: T) => void; @@ -146,9 +140,9 @@ type Breakpoint = "desktop" | "mobile"; function renderSurface(surface: Surface, projectId = "project-a") { if (surface === "Board") { - return render(); + return render(); } - return render(); + return render(); } function expectWorkflowLayout(surface: Surface) { @@ -163,19 +157,6 @@ function expectWorkflowLayout(surface: Surface) { expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull(); } -function expectLegacyLayout(surface: Surface) { - if (surface === "Board") { - expect(document.querySelector(".board-workflow-columns")).toBeNull(); - expect(document.querySelector(".board-workflows-skeleton")).toBeNull(); - expect(document.querySelectorAll('.column[data-workflow-mode="false"]').length).toBeGreaterThan(0); - return; - } - - expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull(); - expect(screen.queryByTestId("workflow-switcher")).toBeNull(); - expect(screen.getByTestId("list-split-layout")).toBeInTheDocument(); -} - function expectSkeleton(surface: Surface, empty = false) { if (surface === "Board") { expect(screen.getByTestId(empty ? "board-workflows-empty" : "board-workflows-skeleton")).toBeInTheDocument(); @@ -209,19 +190,6 @@ describe("no legacy-board flash before workflow lanes load (FN-6776)", () => { vi.unstubAllGlobals(); }); - it.each(["Board", "ListView"])("%s renders legacy immediately when workflowColumns is known off", (surface) => { - mockViewport(1024); - apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {})); - - if (surface === "Board") { - render(); - } else { - render(); - } - - expectLegacyLayout(surface); - }); - it.each<[Surface, Breakpoint]>([ ["Board", "desktop"], ["Board", "mobile"], @@ -269,19 +237,6 @@ describe("no legacy-board flash before workflow lanes load (FN-6776)", () => { expectLegacyLayoutHiddenForEmpty(surface); }); - it.each(["Board", "ListView"])("%s renders skeleton while settings are not loaded", (surface) => { - mockViewport(1024); - apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {})); - - if (surface === "Board") { - render(); - } else { - render(); - } - - expectSkeleton(surface); - }); - it.each(["Board", "ListView"])("%s keeps the skeleton on a failed first fetch instead of flashing legacy (non-authoritative failure)", async (surface) => { mockViewport(1024); apiMocks.fetchBoardWorkflows.mockRejectedValue(new Error("network")); @@ -306,23 +261,35 @@ describe("no legacy-board flash before workflow lanes load (FN-6776)", () => { expectWorkflowLayout(surface); if (surface === "Board") { - view.rerender(); + view.rerender(); } else { - view.rerender(); + view.rerender(); } expectSkeleton(surface); }); - it.each(["Board", "ListView"])("%s ignores another project's cache when flag-off payload is cached locally", (surface) => { + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + Was "ignores another project's cache when flag-off payload is cached locally". + The flag-off payload was only the VEHICLE for making the two projects' cached + payloads observably different; the invariant under test is per-project cache + isolation. Rewritten to use an EMPTY-lane payload for project-a instead, so the + invariant keeps its coverage after the flag is gone: project-b's lanes must not + be rendered for project-a, and project-a's own cached payload wins. + */ + it.each(["Board", "ListView"])("%s ignores another project's cache when this project has its own cached payload", (surface) => { mockViewport(1024); writeBoardWorkflowsCache("project-b", workflowPayload); - writeBoardWorkflowsCache("project-a", flagOffPayload); + writeBoardWorkflowsCache("project-a", emptyWorkflowPayload); apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {})); renderSurface(surface, "project-a"); - expectLegacyLayout(surface); + // project-a cached an empty-lane payload, so it must show the EMPTY state — + // never project-b's lanes, and never the generic still-loading skeleton. + expectSkeleton(surface, true); + expectLegacyLayoutHiddenForEmpty(surface); }); }); diff --git a/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx b/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx index 6fc0b80990..90d497acf5 100644 --- a/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-quickcreate-workflow-lane-visibility.test.tsx @@ -125,11 +125,11 @@ function mkTask(overrides: Partial & { id: string }): Task { }; } -function workflowPayload(taskWorkflowIds: Record, flagEnabled = true): BoardWorkflowsPayload { +function workflowPayload(taskWorkflowIds: Record): BoardWorkflowsPayload { return { - flagEnabled, + flagEnabled: true, defaultWorkflowId: DEFAULT_WORKFLOW.id, - workflows: flagEnabled ? [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW, CODING_IDEAS_WORKFLOW] : [], + workflows: [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW, CODING_IDEAS_WORKFLOW], taskWorkflowIds, }; } @@ -186,8 +186,6 @@ function BoardHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCr showWorktreeGrouping={false} planAutoApproveEnabled={false} onTogglePlanAutoApprove={vi.fn()} - workflowColumnsEnabled - settingsLoaded /> ); } @@ -221,8 +219,6 @@ function ListHarness({ createdTaskId = "FN-new", createReturnsTask = true, onCre onOpenDetail={vi.fn()} addToast={vi.fn()} onQuickCreate={onQuickCreate} - workflowColumnsEnabled - settingsLoaded /> ); } @@ -315,21 +311,13 @@ describe("workflow lane quick-create visibility", () => { expect(within(screen.getByTestId("column-ideas")).getByText("Created builtin:coding-ideas")).toBeTruthy(); }); - it("leaves the legacy flag-off Board quick-create path unchanged", async () => { - const inputs: TaskCreateInput[] = []; - fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({}, false)); - - render( inputs.push(input)} />); - await waitFor(() => expect(screen.getByTestId("quick-create-triage")).toBeTruthy()); - - await act(async () => { - fireEvent.click(screen.getByTestId("quick-create-triage")); - }); - - expect(screen.getByText("Created legacy")).toBeTruthy(); - expect(inputs[0]?.workflowId).toBeUndefined(); - }); - + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + "leaves the legacy flag-off Board quick-create path unchanged" is DELETED with the + legacy single-lane board it exercised (it clicked `quick-create-triage`, a legacy + hardcoded-column affordance that no longer renders). The surviving cases cover + quick-create on workflow lanes, which is now the only quick-create path. + */ it.each([ ["Board", BoardHarness, () => fireEvent.click(screen.getByTestId("quick-create-intake"))], ["ListView", ListHarness, () => fireEvent.click(screen.getByTestId("list-quick-create"))], @@ -387,8 +375,6 @@ function boardProps(tasks: Task[]) { showWorktreeGrouping: false, planAutoApproveEnabled: false, onTogglePlanAutoApprove: vi.fn(), - workflowColumnsEnabled: true as const, - settingsLoaded: true as const, }; } diff --git a/packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx b/packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx index 54d3139d71..1297b0d8d6 100644 --- a/packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx +++ b/packages/dashboard/app/components/__tests__/workflow-resolved-columns.test.tsx @@ -128,7 +128,6 @@ function renderBoard(tasks: Task[]) { onToggleAutoMerge={vi.fn()} planAutoApproveEnabled={false} onTogglePlanAutoApprove={vi.fn()} - workflowColumnsEnabled settingsLoaded />, ); @@ -149,7 +148,6 @@ function renderList(tasks: Task[]) { addToast={vi.fn()} globalPaused={false} onNewTask={vi.fn()} - workflowColumnsEnabled settingsLoaded />, ); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 0964f26a28..22a27fadf0 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -907,8 +907,6 @@ export function MainContent({ prAuthAvailable={prAuthAvailable} onOpenWorkflowEditor={openWorkflowEditorWithNav} onCreateWorkflow={openCreateWorkflowWithNav} - workflowColumnsEnabled - settingsLoaded={settingsLoaded} workflowControlsInHeader={sidebarActive || isMobile} /> @@ -1024,8 +1022,6 @@ export function MainContent({ prAuthAvailable={prAuthAvailable} onOpenWorkflowEditor={openWorkflowEditorWithNav} onCreateWorkflow={openCreateWorkflowWithNav} - workflowColumnsEnabled - settingsLoaded={settingsLoaded} workflowControlsInHeader={sidebarActive || isMobile} /> @@ -1071,8 +1067,6 @@ export function MainContent({ mergeStrategy={mergeStrategy} onOpenWorkflowEditor={openWorkflowEditorWithNav} onCreateWorkflow={openCreateWorkflowWithNav} - workflowColumnsEnabled - settingsLoaded={settingsLoaded} workflowControlsInHeader={sidebarActive || isMobile} /> diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts index 02f96ff544..98e6ff9b52 100644 --- a/packages/dashboard/app/hooks/useBoardWorkflows.ts +++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts @@ -31,11 +31,6 @@ Per-consumer subscription semantics are preserved: each call to this hook instal export interface UseBoardWorkflowsParams { projectId?: string; - /** - * Gate cache hydration. Board passes `workflowColumnsEnabled === true || settingsLoaded === false` - * to avoid flashing the legacy board; Planning has no such gate and leaves this at the default `true`. - */ - shouldHydrateCache?: boolean; fetchBoardWorkflows?: typeof defaultFetchBoardWorkflows; subscribeSse?: typeof defaultSubscribeSse; readBoardWorkflowsCache?: typeof defaultReadBoardWorkflowsCache; @@ -76,7 +71,6 @@ export interface UseBoardWorkflowsResult { export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWorkflowsResult { const { projectId, - shouldHydrateCache = true, fetchBoardWorkflows = defaultFetchBoardWorkflows, subscribeSse = defaultSubscribeSse, readBoardWorkflowsCache = defaultReadBoardWorkflowsCache, @@ -86,7 +80,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork } = params; const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { - const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null; + const cached = readBoardWorkflowsCache(projectId); return cached ? { projectId, payload: cached } : null; }); const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; @@ -143,14 +137,14 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork // Stale-response guard: a monotonic sequence ref drops out-of-order responses. const boardWorkflowsFetchSeqRef = useRef(0); - // Re-hydrate from the per-project cache on project change (and gate change). + // Re-hydrate from the per-project cache on project change. useEffect(() => { - const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null; + const cached = readBoardWorkflowsCache(projectId); const storedSelection = readBoardWorkflowViewSelection(projectId); storedSelectionRef.current = storedSelection; setSelectedWorkflowIdState(storedSelection); setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); - }, [projectId, shouldHydrateCache, readBoardWorkflowsCache]); + }, [projectId, readBoardWorkflowsCache]); const refreshBoardWorkflows = useCallback((options?: { forceFresh?: boolean }) => { const seq = ++boardWorkflowsFetchSeqRef.current; @@ -211,8 +205,15 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork }; }, [projectId, refreshBoardWorkflows, subscribeSse]); - const flagOn = boardWorkflows?.flagEnabled === true; - const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length); + /* + FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): + Workflow mode is now "this project resolved at least one lane", nothing else. + The former `flagEnabled === true` conjunct is DELETED: the server hardcodes that + field to `true` (`buildBoardWorkflowsPayload`), so it could only ever narrow the + result to itself. Reading it kept a retired kill switch alive on the client, one + stale payload away from silently reverting every consumer to the legacy board. + */ + const workflowMode = Boolean(boardWorkflows?.workflows.length); const workflowOptions = useMemo(() => { if (!workflowMode || !boardWorkflows) return [];