diff --git a/.changeset/fn-023-disabled-builtin-workflows.md b/.changeset/fn-023-disabled-builtin-workflows.md new file mode 100644 index 0000000000..ae9ee276ba --- /dev/null +++ b/.changeset/fn-023-disabled-builtin-workflows.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 95eec4e756..fa3cfda3f5 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. - + @@ -379,7 +380,7 @@ FNXC:WorkflowSelection 2026-07-01-00:00: All workflows is available on Board, Li -- 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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 96cfb4600b..ce7038392e 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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 + 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. diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 8536b1f6cc..ea79d3db22 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c274800dc..b3b1031b7a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -614,6 +614,12 @@ export { isBuiltinWorkflowId, isBuiltinWorkflowPluginGated, isBuiltinWorkflowDeprecated, + isBuiltinWorkflowToggleEligible, + toggleEligibleBuiltinWorkflowIds, + defaultEnabledBuiltinWorkflowIds, + effectiveEnabledBuiltinWorkflowIds, + validateEnabledBuiltinWorkflowIds, + resolveEffectiveDefaultWorkflowId, DEFAULT_WORKFLOW_ID, resolveDefaultWorkflowIr, } from "./workflows/builtin-workflows.js"; diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index d839994e91..af0f4c6780 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -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 { + /* + 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(); diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index 12cbd13450..1da144a243 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -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 { + 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): 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 { 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 { 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; diff --git a/packages/core/src/workflows/builtin-workflows.ts b/packages/core/src/workflows/builtin-workflows.ts index f0a2b2ec07..8f39fb0847 100644 --- a/packages/core/src/workflows/builtin-workflows.ts +++ b/packages/core/src/workflows/builtin-workflows.ts @@ -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(); + 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 { diff --git a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx index 9e1ab2f607..2585c90889 100644 --- a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx +++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx @@ -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(); + + 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(); + + 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: [] })); diff --git a/packages/dashboard/app/api/projects/board-workflows.ts b/packages/dashboard/app/api/projects/board-workflows.ts index a6f25859a7..6b340416ab 100644 --- a/packages/dashboard/app/api/projects/board-workflows.ts +++ b/packages/dashboard/app/api/projects/board-workflows.ts @@ -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[]; diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 0ea4060820..f752670715 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -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", diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 00d08782f9..7b6983d572 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -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, diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 8d496449a1..ec5b326d82 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -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 {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).")}
- {builtinWorkflows.map((workflow) => (); + })}
)}
diff --git a/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.builtin-workflows.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.builtin-workflows.test.tsx new file mode 100644 index 0000000000..a69853e749 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.builtin-workflows.test.tsx @@ -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(), + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), +})); + +vi.mock("../../../../api", async (importOriginal) => ({ + ...await importOriginal(), + 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 }) { + const [form, setForm] = useState(initialForm as SettingsFormState); + return ( + + ); +} + +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(); + + 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(); + + 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"); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts index 0df13cfe2b..b573d3e947 100644 --- a/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBoardWorkflows.test.ts @@ -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", diff --git a/packages/dashboard/app/hooks/useBoardWorkflows.ts b/packages/dashboard/app/hooks/useBoardWorkflows.ts index 6e921e6cc2..5806019be4 100644 --- a/packages/dashboard/app/hooks/useBoardWorkflows.ts +++ b/packages/dashboard/app/hooks/useBoardWorkflows.ts @@ -228,11 +228,19 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork const workflowOptions = useMemo(() => { 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; diff --git a/packages/dashboard/src/__tests__/board-workflows.test.ts b/packages/dashboard/src/__tests__/board-workflows.test.ts index 9009bff8f5..3e7500c898 100644 --- a/packages/dashboard/src/__tests__/board-workflows.test.ts +++ b/packages/dashboard/src/__tests__/board-workflows.test.ts @@ -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 { diff --git a/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts b/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts index 80f07fd60b..b2dbd0d335 100644 --- a/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-settings-memory-worktrunk.test.ts @@ -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(); diff --git a/packages/dashboard/src/routes/board-workflows.ts b/packages/dashboard/src/routes/board-workflows.ts index 765e8dc11e..ca48ea61ff 100644 --- a/packages/dashboard/src/routes/board-workflows.ts +++ b/packages/dashboard/src/routes/board-workflows.ts @@ -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, workflowId: string, + selectable: boolean, ): Promise { // 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 = {}; + 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 = {}; const referenced = new Set(); + const selectableWorkflowIds = new Set([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, }; diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 4adb911fe6..e8a7fdbdbc 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -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); }