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:
7
.changeset/u12-delete-legacy-board-path.md
Normal file
7
.changeset/u12-delete-legacy-board-path.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user