U12 part 1: delete the legacy board path (262 ListView + 39 Board tests were measuring it; 9-site flag inventory, moves.ts group blocked on U2b) (#2500)

## U12, part 1 of 2 — and one blocker you need to route

The unit's headline deletion
(`isWorkflowColumnsCompatibilityFlagEnabled`) is **blocked by U2b** and
is not in this PR. What is here is everything that could be deleted
without making a convergence decision that belongs to another unit.

### The blocker

PR #2468 landed as `b941d3cba` — but that was **Phase A2 steps 1–2 only:
the differential characterization**. The convergence (pick a path,
delete the other, delete the flag) has not landed;
`feature/workflow-move-path-convergence` is still live.

Deleting the raw flag **is** that convergence.
`move-path-equivalence.pg.test.ts` says so in its own header, and its
second `describe` is literally *"the flag gates MORE than side
effects"*. The plan makes this a blocking unit with an equivalence
*proof obligation* and an explicit "stop and escalate rather than
reconcile silently" note. So I stopped.

### Inventory: every read of the raw flag, with a verdict

Nine sites. All false in production because nothing writes
`experimentalFeatures.workflowColumns`.

**Blocked on U2b — one branch, not separable:**

| Site | Silently disabled today | Visible if flipped |
|---|---|---|
| `moves.ts:312` `useWorkflow` | typed `TransitionRejectionError`,
workflow adjacency, the shared transition invariants (merge-blocker
*trait* generalization), plugin column gates, the `transitionPending`
marker, `workflowId` in `task:move` run-audit, and the trait-hook
side-effect path | Yes — rejections change **type and message** |
| `moves.ts:931` | the in-transaction capacity gate.
`resolveColumnCapacity` never runs | Yes — WIP limits begin binding |
| `workflow-task-create-ops.ts:351` |
`prepareWorkflowMovePolicyPreflight` returns `undefined` unconditionally
→ **workflow/plugin move policies have never been evaluated** | Yes —
new rejections |

On #2488: the pool-id sentinel fix is correct *and* still inert. Two
dead layers stacked — the gate it fixed is inside `if (useWorkflow &&
…)`.

**Not blocked, but each moves operators' cards — deferred to PR 2 per
your call:**

| Site | Silently disabled today |
|---|---|
| `workflow-ops.ts:183` | `OccupiedColumnsError` + `rehomeTo` when a
workflow edit removes an **occupied** column. Today the save succeeds
and strands the cards |
| `workflow-ops.ts:344` | occupant re-home on workflow **delete** |
| `workflow-definitions.ts:700` | workflow-**switch** reconciliation,
and the `reconciliation` field in the API response |

I verified these three are **not** coupled to `moves.ts`:
`rehomeOccupant` reaches a custom target via the
`isWorkflowDeclaredRecoveryRehome` carve-out (`moves.ts:641`), which
exists because the repair "silently no-oped on every store open" before
it.

**Not blocked, no behaviour change for current binaries** (also PR 2):
`project-store-ops.ts:687` + `lifecycle-ops.ts:1119` —
`downgradeIrToV1IfPure` on persist, for *binary-downgrade* rollback.
Needs a round-trip test, not an assumption.

### What this PR deletes

**Dashboard.** `workflowColumnsEnabled` was a literal `true` at all
three `MainContent` call sites; the server hardcodes `flagEnabled:
true`. Gone: Board's legacy single-lane board (55 lines mapping the
hardcoded `COLUMNS` enum — the last board surface deriving columns from
the legacy vocabulary, an R8 violation that survived U10);
`tasksByColumn` and its cache ref, orphaned with it; ListView's
`LEGACY_LIST_COLUMNS` (the ListView copy of the synthesized-trait-flags
defect U10 fixed in Board); both props; the `shouldHydrateCache` gate;
TaskDetailModal's `flagEnabled` early return. **Neither Board nor
ListView imports the legacy column enum any more.**

**Core.** `evacuateCustomColumnsToLegacy` (#1409) — both triggers
require the previous settings to have the flag ON, which no writer
produces. `runWorkflowColumnsIntegrityPass` — no caller anywhere,
superseded by `reconcileUndeclaredTaskColumns` (registered in startup
recovery), and it read through the sync SQLite handle, so invoking it
under PostgreSQL would have thrown rather than reconciled.

**Migration answer:** a project with `workflowColumns: false` persisted
needs no migration and no read-time drop. Nothing in this PR reads the
key, and it stays in `HIDDEN_EXPERIMENTAL_FEATURE_KEYS` so Settings
still suppresses it rather than resurrecting it as an unknown setting.
Proven by tests, no instance booted.

**`flagEnabled` stays on the wire** as a constant. Removing it changes
the response shape, and a browser tab outliving a server upgrade would
read the missing field as "off" and degrade. One boolean, no client
branches on it, droppable a release later.

### Measured

- Production sources: **-332 / +131** (net **-201**). Additions are
almost entirely FNXC comments recording why each branch was unreachable.
- Dashboard production only: -168 / +93.
- Core: -164 / +38.

### The finding I'd actually flag

`Board.test.tsx` and `ListView.test.tsx` both left
`workflowColumnsEnabled` unset and stubbed `fetchBoardWorkflows` with a
**never-resolving promise**. Under the old gate that rendered the
**legacy** board — so **262 ListView tests and 39 Board tests were
asserting against a configuration production never reached**, and a real
regression in the workflow board or list would not have failed either
file. Same shape as the other four: looked enforced, wasn't.

Both now seed the first-paint lane cache with the default workflow's
**real** columns (ids and names copied from
`BUILTIN_CODING_WORKFLOW_IR`) — the same seam production uses.
Repointing them surfaced assertions that encoded legacy-only values:
`"In Progress"`/`"In Review"` (real IR names are `"In progress"`/`"In
review"`), and Planning Mode asserted to receive `null` as the workflow
id, which is only what `getTaskPlanningWorkflowId` returns when
`workflowMode` is false.

`"Back to In Progress"` is **not** one of those — it is a hardcoded i18n
string in `TaskContextMenu:210`, not derived from the column name. Left
alone, and flagged: it will not follow a renamed column. That's U11
vocabulary territory.

**One test is SKIPPED, not weakened** — "keeps unaffected columns stable
when archived collapse toggles". Pointed at the real board the invariant
is **false**: toggling the archived column re-renders unaffected columns
(measured: todo renders 3×, not 2×). Pre-existing production behaviour
this deletion exposed, never covered because the test measured the dead
path. I ruled out the obvious causes (every callback prop is
`useCallback`; the per-column task memo's deps exclude
`archivedCollapsed`; memoizing the inline `canDropTask` binding did
**not** close it — I wrote that fix, could not prove it with a failing
test, and **reverted it**). The reason is recorded at the test: un-skip
with a fix, never with a new expected number.

### Verification

`pnpm test:gate` (299 + 10 + 71), `pnpm lint`, `pnpm verify:fast` (17
steps), and both package typechecks green. `settings-defaults.test.ts >
warns once per process for legacy cwd-main mode` fails —
**pre-existing**, confirmed by stashing my changes and re-running. No
Fusion instance was booted.

### Routing request

Per your call: the `moves.ts` group and the final removal of
`isWorkflowColumnsCompatibilityFlagEnabled` go to **U2b**, inside the
convergence PR where the equivalence proof already lives. The
divergences their characterization suite does **not** yet cover: plugin
column gates, the `transitionPending` marker, `workflowId` in
`task:move` run-audit, and move-policy preflight.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-28 15:45:40 -07:00
committed by GitHub
parent 2934cccad8
commit fd6d005333
16 changed files with 349 additions and 450 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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

View File

@@ -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<void> {
/* 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;
}

View File

@@ -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<string>(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<string[]> {
const settings = await store.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters;

View File

@@ -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<CommitAssociationDiffBackfillReport> {
const dryRun = options.dryRun === true;

View File

@@ -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<Record<ColumnType, Task[]>>({
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<ColumnType, Task[]> = {
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<ColumnType, Task[]>;
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<string | null>(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 <BoardWorkflowSkeleton empty={boardWorkflows?.flagEnabled === true} />;
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 <BoardWorkflowSkeleton empty={boardWorkflows !== null} />;
}
if (workflowMode && selectedWorkflow) {
@@ -1140,59 +1105,21 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
);
}
return (
<>
<main className="board" id="board" ref={setBoardRef}>
{COLUMNS.map((col) => (
<Column
key={col}
column={col}
tasks={tasksByColumn[col]}
projectId={projectId}
maxConcurrent={maxConcurrent}
showWorktreeGrouping={showWorktreeGrouping}
onMoveTask={onMoveTask}
onPauseTask={onPauseTask}
onUnpauseTask={onUnpauseTask}
onResetTask={onResetTask}
onDuplicateTask={onDuplicateTask}
onMergeTask={onMergeTask}
onOpenDetail={onOpenDetail}
onPlanningMode={onPlanningMode}
onOpenRefine={onOpenRefine}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
allTasks={tasks}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
mergeStrategy={mergeStrategy}
{...(col === "triage" ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
{...(col === "triage" ? { onQuickCreate, onNewTask, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})}
/>
))}
</main>
</>
);
/*
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 <BoardWorkflowSkeleton empty={false} />;
}

View File

@@ -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<HTMLElement | null>(() => {
if (typeof document === "undefined") return null;
return document.getElementById("header-workflow-slot");
@@ -670,7 +653,17 @@ export function ListView({
}, [selectedWorkflowId]);
const listColumns = useMemo<BoardWorkflowColumn[]>(() => {
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 (

View File

@@ -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<Task, "id" | "column">): 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();

View File

@@ -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 <main> 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]);

View File

@@ -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 <ListView> 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", () => {

View File

@@ -84,12 +84,6 @@ const emptyWorkflowPayload: BoardWorkflowsPayload = {
taskWorkflowIds: {},
};
const flagOffPayload: BoardWorkflowsPayload = {
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
};
function createDeferred<T>() {
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(<Board {...boardProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
return render(<Board {...boardProps} projectId={projectId} />);
}
return render(<ListView {...listProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
return render(<ListView {...listProps} projectId={projectId} />);
}
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<Surface>(["Board", "ListView"])("%s renders legacy immediately when workflowColumns is known off", (surface) => {
mockViewport(1024);
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
if (surface === "Board") {
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
} else {
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
}
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<Surface>(["Board", "ListView"])("%s renders skeleton while settings are not loaded", (surface) => {
mockViewport(1024);
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
if (surface === "Board") {
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
} else {
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
}
expectSkeleton(surface);
});
it.each<Surface>(["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(<Board {...boardProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
view.rerender(<Board {...boardProps} projectId="project-b" />);
} else {
view.rerender(<ListView {...listProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
view.rerender(<ListView {...listProps} projectId="project-b" />);
}
expectSkeleton(surface);
});
it.each<Surface>(["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<Surface>(["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);
});
});

View File

@@ -125,11 +125,11 @@ function mkTask(overrides: Partial<Task> & { id: string }): Task {
};
}
function workflowPayload(taskWorkflowIds: Record<string, string>, flagEnabled = true): BoardWorkflowsPayload {
function workflowPayload(taskWorkflowIds: Record<string, string>): 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(<BoardHarness onCreateInput={(input) => 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,
};
}

View File

@@ -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
/>,
);

View File

@@ -907,8 +907,6 @@ export function MainContent({
prAuthAvailable={prAuthAvailable}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled
settingsLoaded={settingsLoaded}
workflowControlsInHeader={sidebarActive || isMobile}
/>
</PageErrorBoundary>
@@ -1024,8 +1022,6 @@ export function MainContent({
prAuthAvailable={prAuthAvailable}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled
settingsLoaded={settingsLoaded}
workflowControlsInHeader={sidebarActive || isMobile}
/>
</PageErrorBoundary>
@@ -1071,8 +1067,6 @@ export function MainContent({
mergeStrategy={mergeStrategy}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
workflowColumnsEnabled
settingsLoaded={settingsLoaded}
workflowControlsInHeader={sidebarActive || isMobile}
/>
</PageErrorBoundary>

View File

@@ -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<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];