FN-023: respect disabled built-in workflows

Make disabled built-in workflows unavailable for new selections while preserving existing task resolution.

- Validate enabled built-in workflow settings atomically and require a valid available option.
- Resolve defaults and origin pins through the effective enabled workflow set.
- Filter dashboard selectors while retaining management and direct-resolution paths.
- Add core and dashboard regression coverage plus operator-facing documentation.

Files changed:
 .changeset/fn-023-disabled-builtin-workflows.md    |  7 +++
 docs/dashboard-guide.md                            |  5 +-
 docs/workflow-steps.md                             |  6 +-
 packages/core/src/__tests__/builtin-workflows.test.ts   | 35 +++++++++--
 packages/core/src/index.ts                         |  6 ++
 packages/core/src/task-store/moves.ts              | 17 +++--
 packages/core/src/task-store/settings-ops.ts       | 22 +++++++
 packages/core/src/task-store/task-store-helpers.ts | 33 +++++++++-
 packages/core/src/workflows/builtin-workflows.ts   | 70 +++++++++++++++++++--
 packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx | 45 ++++++++++++++
 packages/dashboard/app/api/projects/board-workflows.ts |  2 +
 packages/dashboard/app/components/__tests__/Board.test.tsx | 22 +++++++
 packages/dashboard/app/components/__tests__/ListView.test.tsx | 38 ++++++++++++
 packages/dashboard/app/components/settings/sections/GeneralSection.tsx | 19 +++++-
 packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.builtin-workflows.test.tsx | 72 ++++++++++++++++++++++
 packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts | 20 ++++++
 packages/dashboard/app/hooks/useBoardWorkflows.ts | 18 ++++--
 packages/dashboard/src/__tests__/board-workflows.test.ts | 35 +++++++++--
 packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts | 37 +++++++++++
 packages/dashboard/src/routes/board-workflows.ts | 41 +++++++++---
 packages/dashboard/src/routes/register-settings-memory-routes.ts | 46 +++++++++++++-
 21 files changed, 553 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-023

Fusion-Task-Lineage: 1043fc42-2a02-49dd-b695-d9ae3af60e90

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 01:00:38 +00:00
parent 612195fde6
commit b88bb1d532
21 changed files with 553 additions and 43 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep disabled built-in workflows out of dashboard workflow selectors.
category: fix
dev: Project Settings now requires at least one enabled built-in workflow.

View File

@@ -371,7 +371,8 @@ FNXC:TriageRename 2026-07-08-00:00 (FN-7660): the board column formerly labeled
- The choices are independent and Board-local: changing Planning, Todo, In Progress, a review lane, a complete lane, or a renamed/custom workflow lane leaves every other lane's choice unchanged and does not create a persisted project preference. Complete lanes retain **Archive All Done** in the same menu when available.
- The physical Archived lane uses the same actions-menu choices, but its selection is sent to the bounded PostgreSQL archive endpoint. The server orders the full project-scoped archive before each 100-row `LIMIT/OFFSET` page, so **Show more** continues the selected global order without fetching or client-sorting the entire archive. Archived arrival is most recently archived first.
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. -->
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks.
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18: Settings General requires at least one enabled normal built-in. The shared board-workflows payload marks currently selectable definitions separately from disabled definitions retained for explicitly assigned existing tasks, so every responsive picker hides disabled built-ins without losing card metadata. -->
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The All workflows option renders an aggregate column/task set across workflows while keeping workflow-specific creates and edits scoped to real workflow ids.
FNXC:WorkflowSelection 2026-06-30-00:00: The view preference persists either a real workflow id or the All workflows sentinel so refresh/remount restores the operator's last top-level workflow context without treating the sentinel as a backend workflow id.
FNXC:WorkflowSelection 2026-07-01-00:00: All workflows is available on Board, List, Planning, Missions, and Graph top-level selectors; Planning/Missions task creation receives default/no-specific-workflow behavior instead of the sentinel. -->
@@ -379,7 +380,7 @@ FNXC:WorkflowSelection 2026-07-01-00:00: All workflows is available on Board, Li
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane.
FNXC:WorkflowSelection 2026-07-01-23:04: Board/List dropdown counts use computeWorkflowStatusCounts as the single source of truth. The All workflows row reports the helper-owned aggregate exactly once and must not be recomputed by summing the map that already contains the aggregate sentinel. -->
<!-- FNXC:WorkflowSelection 2026-06-29-21:40: Refinement creation from Task Detail and done-task chat must preserve both the source task workflow and the operator's selected Board/List lane, so non-default workflow users do not get bounced back to Coding/default after refinement. -->
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board and List also show **All workflows** before real workflows as a dashboard-only aggregate view with combined counts that sum only the real visible workflow rows exactly once and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, persists as top-level workflow view state, and quick-create/Plan/Subtask/Mission handoffs translate it to a real default workflow id or no-specific-workflow behavior so task creation never sends the sentinel. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board and List also show **All workflows** before real workflows as a dashboard-only aggregate view with combined counts that sum only the real visible workflow rows exactly once and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, persists as top-level workflow view state, and quick-create/Plan/Subtask/Mission handoffs translate it to a real default workflow id or no-specific-workflow behavior so task creation never sends the sentinel. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Disabled built-ins are absent from this shared option list, including Header/Planning/Missions and Graph portal variants on desktop or mobile; an existing task explicitly assigned to one remains available in aggregate/card metadata but is not offered as a new-selection or edit row. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; Board, List, Planning/Missions header selectors, and Graph also restore the dashboard-only **All workflows** aggregate view when that was the last selected top-level workflow context. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
- Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh.
<!-- FNXC:BoardTaskCache 2026-06-29-20:05: Board/List returns from non-task-SSE views should reuse a fresh in-memory task snapshot to avoid redundant all-task fetches and loading flashes, while stale, missing, or errored snapshots still trigger one catch-up fetch and restore SSE updates. -->

View File

@@ -26,10 +26,14 @@ FNXC:WorkflowRuntime 2026-06-28-08:10:
Selectable built-in workflows must share the canonical dispatch traits: their held work enters through a capacity-released `todo`/backlog column and moves to the first WIP execution column via the hold/release sweep, so non-default built-ins do not need a separate dispatcher.
-->
Fusion workflows define the task lifecycle policy that moves work from an idea to delivery. The default coding path is **Plan/Triage → Execute → graph-native optional gates → Review → Merge**, but that path is now represented as a workflow selection rather than only as fixed engine behavior. A task with no explicit workflow resolves to `builtin:coding`; an explicit missing/corrupt custom workflow fails closed instead of silently falling back.
Fusion workflows define the task lifecycle policy that moves work from an idea to delivery. The default coding path is **Plan/Triage → Execute → graph-native optional gates → Review → Merge**, but that path is now represented as a workflow selection rather than only as fixed engine behavior. `builtin:coding` remains the catalog default; when project enablement excludes it, unselected/new work and dashboard defaults use the first enabled normal built-in in catalog order. An explicitly selected existing built-in remains resolvable even when it is disabled for new selection, while an explicit missing/corrupt custom workflow fails closed instead of silently falling back.
### Selecting workflows
<!--
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
Project Settings treats an unset `enabledBuiltinWorkflowIds` as all normal built-ins enabled and requires every explicit list to retain at least one valid, available built-in. Disabled built-ins stay in management/direct-resolution paths only; they are omitted from new-task and board/header/list/Planning/Missions/Graph pickers.
-->
Operators can select workflows in the dashboard wherever the task or board workflow selector is shown. Agents and automation can discover, author, tune, and assign them with the workflow tools:
- `fn_workflow_list` / `fn_workflow_get` — list built-in and custom workflow definitions and inspect a definition's IR before editing.

View File

@@ -1166,21 +1166,44 @@ describe("built-in workflows", () => {
expect(await store.getWorkflowDefinition("builtin:coding")).toBeDefined();
});
it("filters disabled built-ins from normal listings but keeps direct resolution", async () => {
await store.updateSettings({ enabledBuiltinWorkflowIds: ["builtin:coding"] });
it("filters disabled built-ins, resolves an enabled effective default, and keeps direct resolution", async () => {
await store.updateSettings({
defaultWorkflowId: "builtin:coding",
enabledBuiltinWorkflowIds: ["builtin:quick-fix"],
});
const list = await store.listWorkflowDefinitions();
expect(list.filter((workflow) => workflow.id.startsWith("builtin:")).map((workflow) => workflow.id)).toEqual([
"builtin:coding",
"builtin:quick-fix",
]);
expect(await store.getWorkflowDefinition("builtin:review-heavy")).toBeDefined();
expect(await store.getDefaultWorkflowId()).toBe("builtin:quick-fix");
expect(await store.getWorkflowDefinition("builtin:coding")).toBeDefined();
const task = await store.createTask({ description: "inherit the enabled workflow" });
expect(await store.getTaskWorkflowSelectionAsync(task.id)).toMatchObject({ workflowId: "builtin:quick-fix" });
});
it("requires one valid enabled built-in and rejects malformed sets atomically", async () => {
await store.updateSettings({ enabledBuiltinWorkflowIds: ["builtin:quick-fix"] });
const invalidSets = [
[],
["builtin:not-a-workflow"],
["builtin:pr-workflow"],
["builtin:brainstorming"],
["builtin:compound-engineering"],
["builtin:quick-fix", "builtin:quick-fix"],
];
for (const enabledBuiltinWorkflowIds of invalidSets) {
await expect(store.updateSettings({ enabledBuiltinWorkflowIds })).rejects.toThrow(/enabledBuiltinWorkflowIds/);
expect((await store.getSettings()).enabledBuiltinWorkflowIds).toEqual(["builtin:quick-fix"]);
}
});
it("can include disabled built-ins for workflow management surfaces", async () => {
await store.updateSettings({ enabledBuiltinWorkflowIds: [] });
await store.updateSettings({ enabledBuiltinWorkflowIds: ["builtin:quick-fix"] });
const normalList = await store.listWorkflowDefinitions();
expect(normalList.some((workflow) => workflow.id.startsWith("builtin:"))).toBe(false);
expect(normalList.some((workflow) => workflow.id === "builtin:coding")).toBe(false);
const managementList = await store.listWorkflowDefinitions({ includeDisabledBuiltins: true });
expect(managementList.some((workflow) => workflow.id === "builtin:coding")).toBe(true);

View File

@@ -614,6 +614,12 @@ export {
isBuiltinWorkflowId,
isBuiltinWorkflowPluginGated,
isBuiltinWorkflowDeprecated,
isBuiltinWorkflowToggleEligible,
toggleEligibleBuiltinWorkflowIds,
defaultEnabledBuiltinWorkflowIds,
effectiveEnabledBuiltinWorkflowIds,
validateEnabledBuiltinWorkflowIds,
resolveEffectiveDefaultWorkflowId,
DEFAULT_WORKFLOW_ID,
resolveDefaultWorkflowIr,
} from "./workflows/builtin-workflows.js";

View File

@@ -84,20 +84,27 @@ precisely the R1 sentinel defect in a new costume: gate and counter talking abou
different pools, so a finite limit cannot bind. One read in, both derived from it.
*/
async function resolveWorkflowIrForSelectedWorkflowId(store: TaskStore, workflowId: string | undefined): Promise<WorkflowIr> {
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
Move policy resolution is a no-selection path too. Read the store's effective
project default before falling back to the catalog Coding identity, so a task
without an explicit selection cannot be evaluated against a disabled workflow.
*/
const effectiveWorkflowId = workflowId ?? await store.getDefaultWorkflowId();
/* FNXC:WorkflowBuiltins 2026-07-19-10:24: every no-selection/unresolvable fallback goes through resolveDefaultWorkflowIr() so this resolver and prepareWorkflowMovePolicyPreflightImpl agree on the default IR (see the helper's note on the "preflight is stale" drift). */
if (!workflowId) {
if (!effectiveWorkflowId) {
return store.applyBuiltInPromptOverridesAsync(DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr());
}
if (isBuiltinWorkflowId(workflowId)) {
const builtin = getBuiltinWorkflow(workflowId);
if (isBuiltinWorkflowId(effectiveWorkflowId)) {
const builtin = getBuiltinWorkflow(effectiveWorkflowId);
const ir = builtin?.ir;
return store.applyBuiltInPromptOverridesAsync(
workflowId,
effectiveWorkflowId,
ir === undefined ? resolveDefaultWorkflowIr() : typeof ir === "string" ? parseWorkflowIr(ir) : ir,
);
}
try {
const def = await store.getWorkflowDefinition(workflowId);
const def = await store.getWorkflowDefinition(effectiveWorkflowId);
return def ? parseWorkflowIr(def.ir) : resolveDefaultWorkflowIr();
} catch {
return resolveDefaultWorkflowIr();

View File

@@ -21,6 +21,10 @@ import {readProjectConfig as readProjectConfigAsync, writeProjectConfig as write
import {appendConfigurationRevision, createConfigurationRevision} from "../async-stores/async-configuration-revision-store.js";
import {isValidProviderInstanceId} from "../provider-instance.js";
import {applyWorkspaceModeToggle, withWorkspaceModeLock, type WorkspaceModeToggleOps} from "../git/git-repository.js";
import {
getRequiredPluginIdForBuiltinWorkflow,
validateEnabledBuiltinWorkflowIds,
} from "../workflows/builtin-workflows.js";
/*
* FNXC:CredentialInstanceSelection 2026-08-01-05:38:
@@ -54,6 +58,17 @@ export function __setWorkspaceModeOpsForTesting(ops: Partial<WorkspaceModeToggle
workspaceModeOpsForTesting = ops;
}
async function assertValidEnabledBuiltinWorkflowIds(store: TaskStore, value: unknown): Promise<void> {
validateEnabledBuiltinWorkflowIds(value);
if (!Array.isArray(value)) return;
for (const rawId of value) {
const requiredPluginId = getRequiredPluginIdForBuiltinWorkflow(rawId);
if (requiredPluginId && !(await store.isPluginInstalled(requiredPluginId))) {
throw new Error(`enabledBuiltinWorkflowIds contains unavailable plugin-gated workflow id: ${rawId}`);
}
}
}
function assertValidCredentialInstanceSettingsPatch(patch: Record<string, unknown>): void {
for (const [key, value] of Object.entries(patch)) {
if (key.endsWith("CredentialInstanceId") || key === "defaultCredentialInstanceIdOverride") {
@@ -259,6 +274,13 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial<Settin
const globalSettings = await store.globalSettingsStore.getSettings();
const previousMerged = canonicalizeSettings({ ...DEFAULT_SETTINGS, ...globalSettings, ...config.settings } as Settings);
const updatedProjectSettings = canonicalizeSettings({ ...config.settings, ...projectPatch } as Settings);
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
Validate the resolved project snapshot while the configuration lock is held,
before either the settings row or its immutable revision can be written. This
keeps malformed enablement lists atomic across dashboard, CLI, and import writers.
*/
await assertValidEnabledBuiltinWorkflowIds(store, updatedProjectSettings.enabledBuiltinWorkflowIds);
// FNXC:TaskPinnedWorktrees 2026-07-16-00:00: reject recycleWorktrees + worktreeNaming:"task-id"
// (mutually exclusive) against the resolved next state BEFORE persisting the invalid combination.
assertWorktreeNamingRecycleExclusive({ ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings);

View File

@@ -11,7 +11,12 @@
*/
import { TaskStore } from "../store.js";
import { isBuiltinWorkflowId } from "../workflows/builtin-workflows.js";
import {
isBuiltinWorkflowEnabled,
isBuiltinWorkflowId,
isBuiltinWorkflowToggleEligible,
resolveEffectiveDefaultWorkflowId,
} from "../workflows/builtin-workflows.js";
import { InsightStore } from "../insights/insight-store.js";
import { ResearchStore } from "../research/research-store.js";
import { parseWorkflowIr } from "../workflows/workflow-ir.js";
@@ -352,8 +357,14 @@ export async function applyBuiltInPromptOverridesAsyncImpl(store: TaskStore, wor
export async function getDefaultWorkflowIdImpl(store: TaskStore): Promise<string | undefined> {
const settings = await store.getSettingsFast();
const id = (settings as { defaultWorkflowId?: string }).defaultWorkflowId;
return id && id.trim() ? id : undefined;
const typedSettings = settings as { defaultWorkflowId?: string; enabledBuiltinWorkflowIds?: string[] };
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
All no-selection callers share this effective resolver. A disabled built-in
configured as the project default falls through to the first enabled catalog
workflow, while custom defaults retain their explicit identity.
*/
return resolveEffectiveDefaultWorkflowId(typedSettings.defaultWorkflowId, typedSettings.enabledBuiltinWorkflowIds);
}
/**
@@ -381,21 +392,37 @@ export async function resolveOriginWorkflowOverrideIdImpl(
origin: TaskOriginWorkflowKind,
): Promise<string | undefined> {
let candidate: string | undefined;
let enabledBuiltinWorkflowIds: string[] | undefined;
try {
const settings = (await store.getSettingsFast()) as {
taskCreateWorkflowId?: string;
refinementTaskWorkflowId?: string;
boardSelectedWorkflowId?: string;
enabledBuiltinWorkflowIds?: string[];
};
const pinned = origin === "refinement"
? settings.refinementTaskWorkflowId
: settings.taskCreateWorkflowId;
candidate = pinned?.trim() || settings.boardSelectedWorkflowId?.trim() || undefined;
enabledBuiltinWorkflowIds = settings.enabledBuiltinWorkflowIds;
} catch {
return undefined;
}
if (!candidate) return undefined;
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
Origin pins are new-selection settings, not compatibility assignments. A pinned
built-in that was disabled must inherit the effective project default; existing
tasks still resolve their stored workflow id through the direct definition path.
*/
if (
isBuiltinWorkflowId(candidate)
&& (!isBuiltinWorkflowToggleEligible(candidate) || !isBuiltinWorkflowEnabled(candidate, enabledBuiltinWorkflowIds))
) {
return undefined;
}
try {
const def = await store.getWorkflowDefinition(candidate);
if (!def || def.kind === "fragment") return undefined;

View File

@@ -55,12 +55,72 @@ export function getRequiredPluginIdForBuiltinWorkflow(id: string): string | unde
return PLUGIN_GATED_BUILTIN_WORKFLOWS.get(id);
}
/*
* FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
* Project workflow toggles use the catalog's normal-workflow membership rather than a
* client-maintained list. Fragments and deprecated definitions remain resolvable for
* compatibility, but cannot be enabled for new work; plugin-gated definitions are
* eligible only when the settings boundary confirms their plugin is installed.
*/
export function isBuiltinWorkflowToggleEligible(id: string): boolean {
const workflow = BUILTIN_WORKFLOWS.find((candidate) => candidate.id === id);
return Boolean(workflow && workflow.kind !== "fragment" && !isBuiltinWorkflowDeprecated(id));
}
export function toggleEligibleBuiltinWorkflowIds(): string[] {
return BUILTIN_WORKFLOWS
.filter((workflow) => isBuiltinWorkflowToggleEligible(workflow.id))
.map((workflow) => workflow.id);
}
export function defaultEnabledBuiltinWorkflowIds(): string[] {
return BUILTIN_WORKFLOWS.filter(
(workflow) => workflow.kind !== "fragment"
&& !isBuiltinWorkflowPluginGated(workflow.id)
&& !isBuiltinWorkflowDeprecated(workflow.id),
).map((workflow) => workflow.id);
return toggleEligibleBuiltinWorkflowIds().filter((id) => !isBuiltinWorkflowPluginGated(id));
}
/** Validate the shape and catalog membership of a persisted enablement list. */
export function validateEnabledBuiltinWorkflowIds(value: unknown): asserts value is string[] | null | undefined {
if (value === undefined || value === null) return;
if (!Array.isArray(value)) {
throw new Error("enabledBuiltinWorkflowIds must be an array or null");
}
if (value.length === 0) {
throw new Error("enabledBuiltinWorkflowIds must keep at least one built-in workflow enabled");
}
const seen = new Set<string>();
for (const rawId of value) {
if (typeof rawId !== "string" || !isBuiltinWorkflowToggleEligible(rawId)) {
throw new Error(`enabledBuiltinWorkflowIds contains an unknown, deprecated, fragment, or invalid workflow id: ${String(rawId)}`);
}
if (seen.has(rawId)) {
throw new Error(`enabledBuiltinWorkflowIds contains duplicate workflow id: ${rawId}`);
}
seen.add(rawId);
}
}
/** Resolve the effective enabled set in catalog order. */
export function effectiveEnabledBuiltinWorkflowIds(enabledIds?: readonly string[]): string[] {
const configured = enabledIds === undefined
? new Set(defaultEnabledBuiltinWorkflowIds())
: new Set(enabledIds);
return toggleEligibleBuiltinWorkflowIds().filter((id) => configured.has(id));
}
/*
* FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
* `DEFAULT_WORKFLOW_ID` remains the catalog identity of Coding. Routing a new or
* unselected task instead uses the first enabled catalog workflow when a configured
* built-in default is disabled; custom defaults remain explicit and untouched.
*/
export function resolveEffectiveDefaultWorkflowId(
configuredWorkflowId?: string | null,
enabledIds?: readonly string[],
): string {
const enabled = effectiveEnabledBuiltinWorkflowIds(enabledIds);
const configured = configuredWorkflowId?.trim();
if (configured && !isBuiltinWorkflowId(configured)) return configured;
if (configured && enabled.includes(configured)) return configured;
return enabled[0] ?? defaultEnabledBuiltinWorkflowIds()[0] ?? DEFAULT_WORKFLOW_ID;
}
function ceCodeReviewOptionalGroupNode(column: string): WorkflowIrNode {

View File

@@ -275,6 +275,51 @@ describe("workflow selection across dashboard surfaces", () => {
expect(within(graphTasks).queryByTestId("graph-task-FN-graph")).toBeNull();
});
it("omits disabled Coding from Header and Graph while repairing a stale selection", async () => {
const quickFix: BoardWorkflowDefinition = { id: "builtin:quick-fix", name: "Quick Fix", columns: [], selectable: true };
const review: BoardWorkflowDefinition = { id: "builtin:review-heavy", name: "Review Heavy", columns: [], selectable: true };
const coding: BoardWorkflowDefinition = { ...DEFAULT_WORKFLOW, selectable: false };
localStorage.setItem("kb:project-disabled-coding:kb-dashboard-board-workflow-selection", coding.id);
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({
defaultWorkflowId: quickFix.id,
workflows: [coding, quickFix, review],
taskWorkflowIds: { "FN-default": coding.id },
}));
render(<CrossSurfaceHarness projectId="project-disabled-coding" />);
const switchers = await screen.findAllByTestId("workflow-switcher");
await waitFor(() => {
expect(screen.getByTestId("header-selection")).toHaveTextContent(quickFix.id);
expect(screen.getByTestId("graph-selection")).toHaveTextContent(quickFix.id);
expect(localStorage.getItem("kb:project-disabled-coding:kb-dashboard-board-workflow-selection")).toBe(quickFix.id);
});
fireEvent.click(switchers[0]);
expect(screen.queryByTestId("workflow-switcher-option-builtin:coding")).toBeNull();
expect(screen.getByTestId("workflow-switcher-option-builtin:quick-fix")).toBeInTheDocument();
expect(screen.getByTestId("workflow-switcher-option-builtin:review-heavy")).toBeInTheDocument();
expect(within(screen.getByTestId("graph-tasks")).queryByTestId("graph-task-FN-default")).toBeNull();
});
it("keeps an explicitly disabled workflow task visible in the All workflows context without a selector row", async () => {
localStorage.setItem("kb:project-disabled-task:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID);
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({
defaultWorkflowId: "builtin:quick-fix",
workflows: [
{ ...DEFAULT_WORKFLOW, selectable: false },
{ id: "builtin:quick-fix", name: "Quick Fix", columns: [], selectable: true },
],
taskWorkflowIds: { "FN-default": DEFAULT_WORKFLOW.id },
}));
render(<CrossSurfaceHarness projectId="project-disabled-task" />);
await waitFor(() => expect(screen.getByTestId("header-selection")).toHaveTextContent(ALL_WORKFLOWS_BOARD_VIEW_ID));
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement();
expect(screen.getByTestId("graph-tasks")).toBeInTheDocument();
});
it("preserves boundary behavior for disabled, empty, and single-workflow payloads", async () => {
localStorage.setItem("kb:project-disabled:kb-dashboard-board-workflow-selection", GRAPH_WORKFLOW.id);
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] }));

View File

@@ -71,6 +71,8 @@ export interface BoardWorkflowColumn {
export interface BoardWorkflowDefinition {
id: string;
name: string;
/** Whether this definition may be selected for new board/task work. Older payloads omit it and remain selectable. */
selectable?: boolean;
/** Optional compact custom workflow icon; built-ins render the Fusion mark by id. */
icon?: string;
columns: BoardWorkflowColumn[];

View File

@@ -1321,6 +1321,28 @@ describe("Board", () => {
expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("Coding");
});
it("keeps an explicitly disabled Coding lane out of the Board selector", async () => {
const quickFix = { ...DEFAULT_WORKFLOW, id: "builtin:quick-fix", name: "Quick Fix" };
const review = { ...DEFAULT_WORKFLOW, id: "builtin:review-heavy", name: "Review Heavy" };
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: quickFix.id,
workflows: [
{ ...DEFAULT_WORKFLOW, selectable: false },
{ ...quickFix, selectable: true },
{ ...review, selectable: true },
],
taskWorkflowIds: { "FN-1": DEFAULT_WORKFLOW.id },
});
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
const selector = await screen.findByTestId("workflow-switcher");
fireEvent.click(selector);
expect(screen.queryByTestId("workflow-switcher-option-builtin:coding")).toBeNull();
expect(screen.getByTestId("workflow-switcher-option-builtin:quick-fix")).toBeInTheDocument();
expect(screen.getByTestId("workflow-switcher-option-builtin:review-heavy")).toBeInTheDocument();
});
it("puts create controls on the workflow intake column instead of the first visible column", async () => {
const workflow = {
id: "wf-intake-second",

View File

@@ -1662,6 +1662,44 @@ describe("ListView", () => {
expect(screen.getByRole("listbox", { name: "Workflow" })).toBeInTheDocument();
});
it("keeps disabled Coding out of the List selector while retaining its task assignment", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:quick-fix",
workflows: [
{
id: "builtin:coding",
name: "Coding",
selectable: false,
columns: [{ id: "todo", name: "Todo", flags: { hold: true } }],
},
{
id: "builtin:quick-fix",
name: "Quick Fix",
selectable: true,
columns: [{ id: "todo", name: "Todo", flags: { hold: true } }],
},
{
id: "builtin:review-heavy",
name: "Review Heavy",
selectable: true,
columns: [{ id: "todo", name: "Todo", flags: { hold: true } }],
},
],
taskWorkflowIds: { "FN-001": "builtin:coding" },
});
renderListView({
tasks: [createMockTask({ id: "FN-001", column: "todo", title: "Disabled Coding task" })],
});
const selector = await screen.findByTestId("workflow-switcher");
fireEvent.click(selector);
expect(screen.queryByTestId("workflow-switcher-option-builtin:coding")).toBeNull();
expect(screen.getByTestId("workflow-switcher-option-builtin:quick-fix")).toBeInTheDocument();
expect(screen.getByTestId("workflow-switcher-option-builtin:review-heavy")).toBeInTheDocument();
});
it("keeps a custom list workflow selected after task refresh and workflow payload revalidation", async () => {
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,

View File

@@ -129,10 +129,18 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
const configured = Array.isArray(form.enabledBuiltinWorkflowIds) ? form.enabledBuiltinWorkflowIds : undefined;
return new Set(configured ?? builtinWorkflows.map((workflow) => workflow.id));
}, [builtinWorkflows, form.enabledBuiltinWorkflowIds]);
const enabledBuiltinWorkflowCount = builtinWorkflows.filter((workflow) => enabledBuiltinWorkflowIds.has(workflow.id)).length;
const setBuiltinWorkflowEnabled = (workflowId: string, enabled: boolean) => {
setForm((f) => {
const allIds = builtinWorkflows.map((workflow) => workflow.id);
const current = new Set(Array.isArray(f.enabledBuiltinWorkflowIds) ? f.enabledBuiltinWorkflowIds : allIds);
const currentEnabledCount = allIds.filter((id) => current.has(id)).length;
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
The form itself enforces the persistence invariant, including callers
that invoke the setter without clicking the disabled final checkbox.
*/
if (!enabled && current.has(workflowId) && currentEnabledCount <= 1) return f;
if (enabled) {
current.add(workflowId);
}
@@ -251,11 +259,16 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
<SettingsHelpTip settingKey="enabledBuiltinWorkflowIds">{t("settings.general.disabledFusionWorkflowsAreHiddenFromWorkflow", "Disabled Fusion workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. Default: all built-in workflows enabled (unset).")}</SettingsHelpTip>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{builtinWorkflows.map((workflow) => (<label key={workflow.id} htmlFor={`builtin-workflow-${workflow.id}`} className="checkbox-label">
<input id={`builtin-workflow-${workflow.id}`} type="checkbox" checked={enabledBuiltinWorkflowIds.has(workflow.id)} onChange={(e) => setBuiltinWorkflowEnabled(workflow.id, e.target.checked)}/>
<span id="builtin-workflow-enablement-hint" className="sr-only">At least one built-in workflow must remain enabled.</span>
{builtinWorkflows.map((workflow) => {
const checked = enabledBuiltinWorkflowIds.has(workflow.id);
const isLastEnabled = checked && enabledBuiltinWorkflowCount <= 1;
return (<label key={workflow.id} htmlFor={`builtin-workflow-${workflow.id}`} className="checkbox-label" title={isLastEnabled ? "At least one built-in workflow must remain enabled" : undefined}>
<input id={`builtin-workflow-${workflow.id}`} type="checkbox" checked={checked} disabled={isLastEnabled} aria-describedby="builtin-workflow-enablement-hint" onChange={(e) => setBuiltinWorkflowEnabled(workflow.id, e.target.checked)}/>
<WorkflowIcon workflowId={workflow.id} decorative />
<span>{workflow.name}</span>
</label>))}
</label>);
})}
</div>
</div>)}
<div className="form-group">

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react";
import { GeneralSection } from "../GeneralSection";
import type { SettingsFormState } from "../context";
import { fetchWorkflows } from "../../../../api";
vi.mock("react-i18next", async (importOriginal) => ({
...await importOriginal<typeof import("react-i18next")>(),
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
vi.mock("../../../../api", async (importOriginal) => ({
...await importOriginal<typeof import("../../../../api")>(),
fetchWorkflows: vi.fn(),
}));
const WORKFLOWS = [
{ id: "builtin:coding", name: "Coding", ir: {} },
{ id: "builtin:quick-fix", name: "Quick Fix", ir: {} },
] as unknown as import("@fusion/core").WorkflowDefinition[];
function GeneralHost({ initialForm }: { initialForm: Partial<SettingsFormState> }) {
const [form, setForm] = useState(initialForm as SettingsFormState);
return (
<GeneralSection
form={form}
setForm={setForm}
addToast={vi.fn()}
prefixError={null}
setPrefixError={vi.fn()}
projectTrackingRepoOptions={[]}
projectTrackingRepoLoading={false}
projectTrackingRepoError={null}
/>
);
}
beforeEach(() => {
vi.mocked(fetchWorkflows).mockReset();
vi.mocked(fetchWorkflows).mockResolvedValue(WORKFLOWS);
});
afterEach(() => cleanup());
describe("GeneralSection built-in workflow enablement", () => {
it("disables only the final checked workflow and prevents clearing it", async () => {
render(<GeneralHost initialForm={{ enabledBuiltinWorkflowIds: ["builtin:coding", "builtin:quick-fix"] }} />);
const coding = await screen.findByLabelText("Coding") as HTMLInputElement;
const quickFix = await screen.findByLabelText("Quick Fix") as HTMLInputElement;
expect(coding.disabled).toBe(false);
expect(quickFix.disabled).toBe(false);
fireEvent.click(quickFix);
await waitFor(() => expect(coding.disabled).toBe(true));
expect(quickFix.checked).toBe(false);
expect(coding).toHaveAttribute("aria-describedby", "builtin-workflow-enablement-hint");
fireEvent.click(coding);
expect(coding.checked).toBe(true);
});
it("starts with a sole configured workflow disabled and explains the guard accessibly", async () => {
render(<GeneralHost initialForm={{ enabledBuiltinWorkflowIds: ["builtin:coding"] }} />);
const coding = await screen.findByLabelText("Coding") as HTMLInputElement;
expect(coding.checked).toBe(true);
expect(coding.disabled).toBe(true);
expect(document.getElementById("builtin-workflow-enablement-hint")).toHaveTextContent("At least one built-in workflow must remain enabled");
});
});

View File

@@ -65,6 +65,26 @@ describe("useBoardWorkflows", () => {
expect(deps.writeBoardWorkflowsCache).toHaveBeenCalledWith("p1", payload);
});
it("hides disabled definitions from options while retaining explicit task metadata", async () => {
localStorage.setItem("kb:p1:kb-dashboard-board-workflow-selection", "builtin:coding");
const payload = makePayload({
defaultWorkflowId: "builtin:quick-fix",
workflows: [
{ id: "builtin:coding", name: "Coding", selectable: false, columns: [] },
{ id: "builtin:quick-fix", name: "Quick Fix", selectable: true, columns: [] },
],
taskWorkflowIds: { "FN-CODING": "builtin:coding" },
});
const deps = makeDeps(() => Promise.resolve(payload));
const { result } = renderHook(() => useBoardWorkflows({ projectId: "p1", ...deps }));
await waitFor(() => expect(result.current.selectedWorkflow?.id).toBe("builtin:quick-fix"));
expect(result.current.workflowOptions.map((workflow) => workflow.id)).toEqual(["builtin:quick-fix"]);
expect(result.current.boardWorkflows?.workflows.find((workflow) => workflow.id === "builtin:coding")).toMatchObject({ selectable: false });
expect(result.current.boardWorkflows?.taskWorkflowIds["FN-CODING"]).toBe("builtin:coding");
expect(localStorage.getItem("kb:p1:kb-dashboard-board-workflow-selection")).toBeNull();
});
it("hydrates board workflows synchronously from cache before refetch resolves", () => {
const cachedPayload = makePayload({
defaultWorkflowId: "wf-b",

View File

@@ -228,11 +228,19 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];
return [...boardWorkflows.workflows].sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
The payload retains disabled definitions only for cards with explicit legacy
assignments. Switcher options must use the server's selectable marker so
those compatibility lanes cannot leak into any shared picker.
*/
return boardWorkflows.workflows
.filter((workflow) => workflow.selectable !== false)
.sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
}, [boardWorkflows, workflowMode]);
const isAllWorkflowsSelected = selectedWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR, type WorkflowIr } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, getBuiltinWorkflow, type WorkflowIr } from "@fusion/core";
import { buildBoardWorkflowsPayload } from "../routes/board-workflows.js";
const CUSTOM_WORKFLOW_ID = "WF-DESCRIPTIONS";
@@ -70,6 +70,39 @@ The canonical map must therefore be a FALLBACK for a column whose IR name adds n
the raw id, or the same words in different case) — never an override of a name the IR chose.
This is also the mechanism that would clobber U11's Todo->Planning rename.
*/
describe("buildBoardWorkflowsPayload disabled built-ins", () => {
function disabledCodingStore(taskWorkflowId?: string) {
const quickFix = getBuiltinWorkflow("builtin:quick-fix")!;
return {
getSettings: vi.fn(async () => ({
defaultWorkflowId: "builtin:coding",
enabledBuiltinWorkflowIds: ["builtin:quick-fix"],
})),
getTaskWorkflowSelection: vi.fn(() => taskWorkflowId ? { workflowId: taskWorkflowId } : null),
getWorkflowDefinition: vi.fn(async () => undefined),
listWorkflowDefinitions: vi.fn(async () => [quickFix]),
};
}
it("uses the enabled workflow as the default and omits disabled Coding with no tasks", async () => {
const payload = await buildBoardWorkflowsPayload(disabledCodingStore() as never, []);
expect(payload.defaultWorkflowId).toBe("builtin:quick-fix");
expect(payload.workflows.map((workflow) => workflow.id)).toEqual(["builtin:quick-fix"]);
expect(payload.workflows[0]?.selectable).toBe(true);
});
it("retains an explicitly assigned disabled Coding definition without making it selectable", async () => {
const payload = await buildBoardWorkflowsPayload(disabledCodingStore("builtin:coding") as never, ["FN-CODING"]);
expect(payload.taskWorkflowIds["FN-CODING"]).toBe("builtin:coding");
expect(payload.workflows.map((workflow) => [workflow.id, workflow.selectable])).toEqual([
["builtin:coding", false],
["builtin:quick-fix", true],
]);
});
});
describe("buildBoardWorkflowsPayload built-in column labels", () => {
function builtinStore(workflowId: string) {
return {

View File

@@ -9,6 +9,7 @@ import {
registerSettingsMemoryRoutes,
} from "../register-settings-memory-routes.js";
import { request as performRequest } from "../../test-request.js";
import * as sse from "../../sse.js";
const {
resolveWorktrunkBinaryMock,
@@ -182,6 +183,42 @@ describe("register-settings-memory-routes worktrunk gate", () => {
expect(scopedStore.updateSettings).toHaveBeenCalledTimes(1);
});
it("rejects an empty built-in workflow set before persistence", async () => {
const { app, scopedStore } = createApp();
const res = await patchSettings(app, { enabledBuiltinWorkflowIds: [] });
expect(res.status).toBe(400);
expect(res.body.error).toContain("at least one built-in workflow");
expect(scopedStore.updateSettings).not.toHaveBeenCalled();
});
it("accepts a single valid built-in workflow", async () => {
const { app, scopedStore } = createApp();
const res = await patchSettings(app, { enabledBuiltinWorkflowIds: ["builtin:quick-fix"] });
expect(res.status).toBe(200);
expect(scopedStore.updateSettings).toHaveBeenCalledWith(
{ enabledBuiltinWorkflowIds: ["builtin:quick-fix"] },
{ kind: "api", id: "http:unverified" },
);
});
it("emits one workflow invalidation only after enablement persistence", async () => {
const { app } = createApp();
const emit = vi.spyOn(sse, "emitWorkflowSseEvent");
expect((await patchSettings(app, { autoMerge: true })).status).toBe(200);
expect(emit).not.toHaveBeenCalled();
const res = await patchSettings(app, { enabledBuiltinWorkflowIds: ["builtin:quick-fix"] });
expect(res.status).toBe(200);
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith("workflow:updated", { reason: "enabledBuiltinWorkflowIds" }, "p1");
emit.mockRestore();
});
it("rejects recycleWorktrees + worktreeNaming:task-id together (mutually exclusive) with 400", async () => {
const { app, scopedStore } = createApp();

View File

@@ -20,6 +20,7 @@ const severityAuditLog = createLogger("dashboard-board-workflows");
import {
resolveDefaultWorkflowIr,
resolveEffectiveDefaultWorkflowId,
getBuiltinWorkflow,
isBuiltinWorkflowId,
parseWorkflowIr,
@@ -72,6 +73,8 @@ export interface BoardWorkflowColumn {
export interface BoardWorkflowDefinition {
id: string;
name: string;
/** Whether this definition may be selected for new board/task work. Optional for older cached payloads. */
selectable?: boolean;
/** Optional compact custom workflow icon; built-ins render the Fusion mark by id. */
icon?: string;
columns: BoardWorkflowColumn[];
@@ -179,6 +182,7 @@ function describeFields(ir: WorkflowIr): BoardWorkflowField[] | undefined {
async function describeWorkflow(
store: Pick<TaskStore, "getWorkflowDefinition">,
workflowId: string,
selectable: boolean,
): Promise<BoardWorkflowDefinition> {
// The display name comes from the persisted definition when available,
// otherwise the IR's own name (default workflow).
@@ -186,7 +190,7 @@ async function describeWorkflow(
const ir = await resolveWorkflowIrById(store, workflowId);
const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name;
const fields = describeFields(ir);
return { id: workflowId, name, columns: describeColumns(ir, true), ...(fields ? { fields } : {}) };
return { id: workflowId, name, selectable, columns: describeColumns(ir, true), ...(fields ? { fields } : {}) };
}
// Custom workflow: fetch the definition once and derive both IR and name from
// it (previously getWorkflowDefinition was called twice per workflow).
@@ -212,7 +216,7 @@ async function describeWorkflow(
// fall through to the default IR/name
}
const fields = describeFields(ir);
return { id: workflowId, name, ...(icon ? { icon } : {}), columns: describeColumns(ir), ...(fields ? { fields } : {}) };
return { id: workflowId, name, selectable, ...(icon ? { icon } : {}), columns: describeColumns(ir), ...(fields ? { fields } : {}) };
}
/**
@@ -244,32 +248,51 @@ export async function buildBoardWorkflowsPayload(
*/
void settingsOverride;
const flagEnabled = true;
let settings: Pick<Settings, "defaultWorkflowId" | "enabledBuiltinWorkflowIds"> = {};
try {
const loaded = await store.getSettings();
if (loaded) settings = loaded;
} catch {
// A degraded settings read still permits explicit task assignments to render.
}
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
Board metadata uses the same effective default as task creation. The catalog
Coding id is only the fallback identity; it is never injected when project
enablement has selected another built-in.
*/
const defaultWorkflowId = resolveEffectiveDefaultWorkflowId(
settings.defaultWorkflowId,
settings.enabledBuiltinWorkflowIds,
);
const taskWorkflowIds: Record<string, string> = {};
const referenced = new Set<string>();
const selectableWorkflowIds = new Set<string>([defaultWorkflowId]);
for (const taskId of taskIds) {
let workflowId = DEFAULT_WORKFLOW_LANE_ID;
let workflowId = defaultWorkflowId;
try {
const selection = store.getTaskWorkflowSelectionAsync
? await store.getTaskWorkflowSelectionAsync(taskId)
: store.getTaskWorkflowSelection(taskId);
if (selection?.workflowId) workflowId = selection.workflowId;
} catch {
workflowId = DEFAULT_WORKFLOW_LANE_ID;
workflowId = defaultWorkflowId;
}
taskWorkflowIds[taskId] = workflowId;
referenced.add(workflowId);
}
// The default workflow lane is always describable so a no-task board still
// resolves it (and the client's default-lane-first ordering is stable).
referenced.add(DEFAULT_WORKFLOW_LANE_ID);
// The effective default is always describable so a no-task board still
// resolves it and the client has one authoritative selectable lane.
referenced.add(defaultWorkflowId);
try {
const definitions = await store.listWorkflowDefinitions();
for (const definition of definitions) {
if (definition.kind === "fragment") continue;
selectableWorkflowIds.add(definition.id);
referenced.add(definition.id);
}
} catch (err) {
@@ -281,12 +304,12 @@ export async function buildBoardWorkflowsPayload(
const workflows: BoardWorkflowDefinition[] = [];
for (const workflowId of referenced) {
workflows.push(await describeWorkflow(store, workflowId));
workflows.push(await describeWorkflow(store, workflowId, selectableWorkflowIds.has(workflowId)));
}
return {
flagEnabled,
defaultWorkflowId: DEFAULT_WORKFLOW_LANE_ID,
defaultWorkflowId,
workflows,
taskWorkflowIds,
};

View File

@@ -1,4 +1,9 @@
import { createLogger } from "@fusion/core";
import {
createLogger,
effectiveEnabledBuiltinWorkflowIds,
getRequiredPluginIdForBuiltinWorkflow,
validateEnabledBuiltinWorkflowIds,
} from "@fusion/core";
import { resolveRequestActor } from "../request-actor.js";
const severityAuditLog = createLogger("dashboard-register-settings-memory-routes");
@@ -67,6 +72,7 @@ import { mkdir } from "node:fs/promises";
import { promisify } from "node:util";
import { ApiError, badRequest } from "../api-error.js";
import { resolveGithubTrackingAuth } from "../github-auth.js";
import { emitWorkflowSseEvent } from "../sse.js";
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
import type { ApiRoutesContext } from "./types.js";
@@ -595,7 +601,11 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
router.put("/settings", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const workflowEnablementPatch = Object.prototype.hasOwnProperty.call(req.body ?? {}, "enabledBuiltinWorkflowIds");
const { store: scopedStore, projectId } = await getProjectContext(req);
const previousWorkflowSettings = workflowEnablementPatch
? await scopedStore.getSettings()
: undefined;
// Strip server-owned fields that should never be persisted to config.json.
// These are computed server-side and injected only on GET /settings.
@@ -615,6 +625,22 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
throw badRequest(`Cannot update global settings via this endpoint. Use PUT /settings/global instead. Global fields found: ${globalFieldsFound.join(", ")}`);
}
if (Object.prototype.hasOwnProperty.call(clientSettings, "enabledBuiltinWorkflowIds")) {
try {
validateEnabledBuiltinWorkflowIds(clientSettings.enabledBuiltinWorkflowIds);
if (Array.isArray(clientSettings.enabledBuiltinWorkflowIds)) {
for (const workflowId of clientSettings.enabledBuiltinWorkflowIds) {
const requiredPluginId = getRequiredPluginIdForBuiltinWorkflow(workflowId);
if (requiredPluginId && !(await scopedStore.isPluginInstalled(requiredPluginId))) {
throw new Error(`enabledBuiltinWorkflowIds contains unavailable plugin-gated workflow id: ${workflowId}`);
}
}
}
} catch (error) {
throw badRequest(error instanceof Error ? error.message : String(error));
}
}
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
}
@@ -764,7 +790,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
}
const settings = await scopedStore.updateSettings(clientSettings, resolveRequestActor(req));
if (
workflowEnablementPatch
&& previousWorkflowSettings
&& JSON.stringify(effectiveEnabledBuiltinWorkflowIds(previousWorkflowSettings.enabledBuiltinWorkflowIds))
!== JSON.stringify(effectiveEnabledBuiltinWorkflowIds(settings.enabledBuiltinWorkflowIds))
) {
/*
FNXC:DisabledBuiltinWorkflows 2026-08-19-00:18:
Settings changes invalidate the shared board-workflow cache only after the
PostgreSQL settings/revision transaction commits. One event refreshes every
Header, Board, List, Planning, and Graph consumer without polling.
*/
emitWorkflowSseEvent("workflow:updated", { reason: "enabledBuiltinWorkflowIds" }, projectId);
}
res.json(settings);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -779,6 +818,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
errorMessage.includes("modelPresets")
|| errorMessage.includes("must include both provider and modelId")
|| errorMessage.includes("mutually exclusive")
|| errorMessage.includes("enabledBuiltinWorkflowIds")
) ? 400 : 500;
throw new ApiError(status, errorMessage);
}