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:
7
.changeset/fn-023-disabled-builtin-workflows.md
Normal file
7
.changeset/fn-023-disabled-builtin-workflows.md
Normal 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.
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -614,6 +614,12 @@ export {
|
||||
isBuiltinWorkflowId,
|
||||
isBuiltinWorkflowPluginGated,
|
||||
isBuiltinWorkflowDeprecated,
|
||||
isBuiltinWorkflowToggleEligible,
|
||||
toggleEligibleBuiltinWorkflowIds,
|
||||
defaultEnabledBuiltinWorkflowIds,
|
||||
effectiveEnabledBuiltinWorkflowIds,
|
||||
validateEnabledBuiltinWorkflowIds,
|
||||
resolveEffectiveDefaultWorkflowId,
|
||||
DEFAULT_WORKFLOW_ID,
|
||||
resolveDefaultWorkflowIr,
|
||||
} from "./workflows/builtin-workflows.js";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [] }));
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user