diff --git a/.changeset/quiet-built-in-workflows.md b/.changeset/quiet-built-in-workflows.md new file mode 100644 index 0000000000..d3ad0d8e07 --- /dev/null +++ b/.changeset/quiet-built-in-workflows.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Allow projects to enable or disable built-in workflows from settings, and show built-in workflow seam prompt text in workflow nodes. diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index b3002b18fc..74b81259bf 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -140,6 +140,38 @@ 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"] }); + + const list = await store.listWorkflowDefinitions(); + expect(list.filter((workflow) => workflow.id.startsWith("builtin:")).map((workflow) => workflow.id)).toEqual([ + "builtin:coding", + ]); + expect(await store.getWorkflowDefinition("builtin:compound-engineering")).toBeDefined(); + }); + + it("can include disabled built-ins for workflow management surfaces", async () => { + await store.updateSettings({ enabledBuiltinWorkflowIds: [] }); + + const normalList = await store.listWorkflowDefinitions(); + expect(normalList.some((workflow) => workflow.id.startsWith("builtin:"))).toBe(false); + + const managementList = await store.listWorkflowDefinitions({ includeDisabledBuiltins: true }); + expect(managementList.some((workflow) => workflow.id === "builtin:coding")).toBe(true); + expect(managementList.some((workflow) => workflow.id === "builtin:compound-engineering")).toBe(true); + }); + + it("shows the built-in seam prompt text in node config", () => { + const coding = getBuiltinWorkflow("builtin:coding"); + const execute = coding?.ir.nodes.find((node) => node.id === "execute"); + const review = coding?.ir.nodes.find((node) => node.id === "review"); + const merge = coding?.ir.nodes.find((node) => node.id === "merge"); + + expect((execute?.config as { prompt?: string } | undefined)?.prompt).toContain("You are a task execution agent"); + expect((review?.config as { prompt?: string } | undefined)?.prompt).toContain("You are an independent code and plan reviewer"); + expect((merge?.config as { prompt?: string } | undefined)?.prompt).toContain("You are a merge agent"); + }); + it("rejects editing or deleting a built-in", async () => { await expect( store.updateWorkflowDefinition("builtin:coding", { name: "x" }), diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 07a04a6435..3053add866 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -1,6 +1,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; +import { builtinPromptConfig } from "./builtin-workflow-prompts.js"; /** * The built-in default workflow as a v2 IR. Its six columns have ids that are @@ -46,9 +47,9 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { ], nodes: [ { id: "start", kind: "start", column: "triage" }, - { id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute", name: "Execute" } }, - { id: "review", kind: "prompt", column: "in-review", config: { seam: "review", name: "Review" } }, - { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge", name: "Merge boundary" } }, + { id: "execute", kind: "prompt", column: "in-progress", config: builtinPromptConfig("execute", "Execute") }, + { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, + { id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") }, { id: "end", kind: "end", column: "done" }, ], edges: [ diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts index aaeb5214d2..e6e5419d86 100644 --- a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -1,6 +1,7 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; +import { builtinPromptConfig } from "./builtin-workflow-prompts.js"; /** * The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step @@ -63,7 +64,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { nodes: [ { id: "start", kind: "start", column: "triage" }, // Planning seam: produces PROMPT.md (the declared step-source artifact). - { id: "plan", kind: "prompt", column: "in-progress", config: { seam: "planning", name: "Plan" } }, + { id: "plan", kind: "prompt", column: "in-progress", config: builtinPromptConfig("planning", "Plan") }, // KTD-12: parse the planned PROMPT.md into the task step list. This node must // dominate the foreach (validator-enforced). { @@ -86,7 +87,7 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { template: { nodes: [ // KTD-2: run exactly this step inside the task's session/worktree. - { id: "step-execute", kind: "prompt", config: { seam: "step-execute", name: "Step execute" } }, + { id: "step-execute", kind: "prompt", config: builtinPromptConfig("step-execute", "Step execute") }, // KTD-4: per-step code review; verdicts become outcome edges. { id: "step-review", kind: "step-review", config: { type: "code" } }, // Template exit (the single sink the validator requires): a config-less @@ -121,8 +122,8 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = { }, // KTD-5: rework exhaustion escalates to a manual hold (a human releases it). { id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } }, - { id: "review", kind: "prompt", column: "in-review", config: { seam: "review", name: "Review" } }, - { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge", name: "Merge boundary" } }, + { id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") }, + { id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") }, { id: "end", kind: "end", column: "done" }, ], edges: [ diff --git a/packages/core/src/builtin-workflow-prompts.ts b/packages/core/src/builtin-workflow-prompts.ts new file mode 100644 index 0000000000..4d3e20c993 --- /dev/null +++ b/packages/core/src/builtin-workflow-prompts.ts @@ -0,0 +1,18 @@ +import { BUILTIN_AGENT_PROMPTS } from "./agent-prompts.js"; + +const DEFAULT_EXECUTOR_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "executor")?.prompt ?? ""; +const DEFAULT_TRIAGE_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "triage")?.prompt ?? ""; +const DEFAULT_REVIEWER_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "reviewer")?.prompt ?? ""; +const DEFAULT_MERGER_PROMPT = BUILTIN_AGENT_PROMPTS.find((prompt) => prompt.role === "merger")?.prompt ?? ""; + +const BUILTIN_SEAM_PROMPTS: Record = { + execute: DEFAULT_EXECUTOR_PROMPT, + planning: DEFAULT_TRIAGE_PROMPT, + "step-execute": DEFAULT_EXECUTOR_PROMPT, + review: DEFAULT_REVIEWER_PROMPT, + merge: DEFAULT_MERGER_PROMPT, +}; + +export function builtinPromptConfig(seam: string, name: string): Record { + return { seam, name, prompt: BUILTIN_SEAM_PROMPTS[seam] ?? "" }; +} diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 1bc65e2204..a00f899b4d 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -1,6 +1,7 @@ import { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js"; import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; +import { builtinPromptConfig } from "./builtin-workflow-prompts.js"; import type { WorkflowDefinition } from "./workflow-definition-types.js"; import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; @@ -12,6 +13,16 @@ export function isBuiltinWorkflowId(id: string): boolean { return id.startsWith(BUILTIN_WORKFLOW_ID_PREFIX); } +export function defaultEnabledBuiltinWorkflowIds(): string[] { + return BUILTIN_WORKFLOWS.map((workflow) => workflow.id); +} + +export function isBuiltinWorkflowEnabled(id: string, enabledIds?: readonly string[]): boolean { + if (!isBuiltinWorkflowId(id)) return true; + if (!enabledIds) return true; + return enabledIds.includes(id); +} + // Stable timestamp so built-ins round-trip deterministically. const BUILTIN_TS = "2026-01-01T00:00:00.000Z"; @@ -78,9 +89,9 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ name: "Coding (built-in)", description: "The standard coding pipeline: implement, review, then merge. Equivalent to the default behavior.", nodes: [ - { id: "execute", kind: "prompt", config: { seam: "execute", name: "Execute" } }, - { id: "review", kind: "prompt", config: { seam: "review", name: "Review" } }, - { id: "merge", kind: "prompt", config: { seam: "merge", name: "Merge boundary" } }, + { id: "execute", kind: "prompt", config: builtinPromptConfig("execute", "Execute") }, + { id: "review", kind: "prompt", config: builtinPromptConfig("review", "Review") }, + { id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") }, ], }), linear({ @@ -88,8 +99,8 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ name: "Quick fix (built-in)", description: "Implement and merge with no review step — for trivial, low-risk changes.", nodes: [ - { id: "execute", kind: "prompt", config: { seam: "execute", name: "Execute" } }, - { id: "merge", kind: "prompt", config: { seam: "merge", name: "Merge boundary" } }, + { id: "execute", kind: "prompt", config: builtinPromptConfig("execute", "Execute") }, + { id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") }, ], }), linear({ @@ -97,8 +108,8 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ name: "Review-heavy (built-in)", description: "Adds an extra security pass before merge, on top of the standard review.", nodes: [ - { id: "execute", kind: "prompt", config: { seam: "execute", name: "Execute" } }, - { id: "review", kind: "prompt", config: { seam: "review", name: "Review" } }, + { id: "execute", kind: "prompt", config: builtinPromptConfig("execute", "Execute") }, + { id: "review", kind: "prompt", config: builtinPromptConfig("review", "Review") }, { id: "security", kind: "gate", @@ -108,7 +119,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ prompt: "Review the diff for security issues: injection, auth/authorization gaps, secret handling, unsafe deserialization. Block on any exploitable finding.", }, }, - { id: "merge", kind: "prompt", config: { seam: "merge", name: "Merge boundary" } }, + { id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") }, ], }), linear({ @@ -126,8 +137,8 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ prompt: "Produce a short implementation plan for this task before any code is written.", }, }, - { id: "execute", kind: "prompt", config: { seam: "execute", name: "Execute" } }, - { id: "review", kind: "prompt", config: { seam: "review", name: "Review" } }, + { id: "execute", kind: "prompt", config: builtinPromptConfig("execute", "Execute") }, + { id: "review", kind: "prompt", config: builtinPromptConfig("review", "Review") }, { id: "code-review", kind: "gate", @@ -139,7 +150,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ prompt: "Run a structured code review of the changes. Block merge on P0/P1 findings.", }, }, - { id: "merge", kind: "prompt", config: { seam: "merge", name: "Merge boundary" } }, + { id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") }, { id: "document", kind: "prompt", diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 90c9b0f0dd..5187b59b12 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -238,6 +238,7 @@ export const DEFAULT_PROJECT_SETTINGS = { globalPause: false, globalPauseReason: undefined, defaultWorkflowId: undefined, + enabledBuiltinWorkflowIds: undefined, approvedWorkflowCliCommands: undefined, approvedCliAutonomyAdapters: undefined, enginePaused: false, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index f7c7aed7cd..532dfe2bd9 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -78,7 +78,7 @@ import type { WorkflowNodeLayout, } from "./workflow-definition-types.js"; import { compileWorkflowToSteps } from "./workflow-compiler.js"; -import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js"; +import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowEnabled, isBuiltinWorkflowId } from "./builtin-workflows.js"; import { resolveWorkflowIrById } from "./workflow-ir-resolver.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; import { @@ -13215,11 +13215,25 @@ ${stepsSection}`; * filtered call can never poison an unfiltered consumer (or vice versa). */ async listWorkflowDefinitions( - options?: { kind?: WorkflowDefinition["kind"] }, + options?: { kind?: WorkflowDefinition["kind"]; includeDisabledBuiltins?: boolean }, ): Promise { const all = await this.readAllWorkflowDefinitions(); - if (options?.kind) return all.filter((wf) => wf.kind === options.kind); - return all; + let enabledBuiltinWorkflowIds: readonly string[] | undefined; + if (!options?.includeDisabledBuiltins) { + try { + const settings = await this.getSettings(); + enabledBuiltinWorkflowIds = Array.isArray(settings.enabledBuiltinWorkflowIds) + ? settings.enabledBuiltinWorkflowIds + : undefined; + } catch { + enabledBuiltinWorkflowIds = undefined; + } + } + const visible = options?.includeDisabledBuiltins + ? all + : all.filter((wf) => isBuiltinWorkflowEnabled(wf.id, enabledBuiltinWorkflowIds)); + if (options?.kind) return visible.filter((wf) => wf.kind === options.kind); + return visible; } /** Read (and cache) the full merged workflow-definition set, oldest first. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1940c905e5..53473ae3f4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3195,6 +3195,9 @@ export interface ProjectSettings { /** Default custom workflow (WF-…) applied to newly created tasks when the * caller does not specify enabledWorkflowSteps. Overridable per task. */ defaultWorkflowId?: string; + /** Built-in workflow ids visible/selectable in project workflow pickers. + * Undefined preserves the default of showing every built-in workflow. */ + enabledBuiltinWorkflowIds?: string[]; /** Raw CLI commands a user has explicitly approved for workflow CLI nodes * (trust-on-first-use). A node's command must appear here before it runs; * named scripts (settings.scripts) never require approval. */ diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index e4a59e1188..ea5f3fc652 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -24,6 +24,7 @@ describe("scope anchors", () => { expect(isGlobalSettingsKey("ntfyTopic")).toBe(true); expect(isProjectSettingsKey("maxConcurrent")).toBe(true); expect(isProjectSettingsKey("integrationBranch")).toBe(true); + expect(isProjectSettingsKey("enabledBuiltinWorkflowIds")).toBe(true); }); it("every MODEL_LANE_KEYS entry is a project settings key", () => { @@ -105,6 +106,17 @@ describe("splitSettingsSave", () => { expect(projectPatch).toEqual({ maxConcurrent: 7 }); }); + it("routes enabled built-in workflow ids as a changed project setting", () => { + const { projectPatch } = splitSettingsSave({ + payload: { enabledBuiltinWorkflowIds: ["builtin:coding"] }, + initialValues: null, + initialScopedValues: { global: {}, project: {} } as never, + activeSection: "general", + }); + + expect(projectPatch).toEqual({ enabledBuiltinWorkflowIds: ["builtin:coding"] }); + }); + it("emits null-as-delete when a project override is cleared", () => { const initialScopedValues = { global: {}, diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 0a7aa4aef5..75845cb7a1 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5043,8 +5043,9 @@ export type { } from "@fusion/core"; /** List all workflow definitions for the project. */ -export function fetchWorkflows(projectId?: string): Promise { - const path = withProjectId("/workflows", projectId); +export function fetchWorkflows(projectId?: string, options?: { includeDisabledBuiltins?: boolean }): Promise { + const query = options?.includeDisabledBuiltins ? "?includeDisabledBuiltins=true" : ""; + const path = withProjectId(`/workflows${query}`, projectId); return dedupe(path, () => api(path)); } diff --git a/packages/dashboard/app/components/WorkflowSelector.tsx b/packages/dashboard/app/components/WorkflowSelector.tsx index b893678fcb..a57c058710 100644 --- a/packages/dashboard/app/components/WorkflowSelector.tsx +++ b/packages/dashboard/app/components/WorkflowSelector.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Workflow as WorkflowIcon } from "lucide-react"; import type { WorkflowDefinition } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; -import { fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api"; +import { fetchWorkflow, fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useConfirm } from "../hooks/useConfirm"; @@ -49,7 +49,15 @@ export function WorkflowSelector({ setWorkflows([]); setLoading(true); fetchWorkflows(projectId) - .then((data) => { + .then(async (data) => { + if (value && !data.some((workflow) => workflow.id === value)) { + try { + const current = await fetchWorkflow(value, projectId); + data = [...data, current]; + } catch { + // The selected workflow may have been deleted; leave the filtered list as-is. + } + } if (!cancelled) setWorkflows(data); }) .catch((err) => { @@ -62,7 +70,7 @@ export function WorkflowSelector({ return () => { cancelled = true; }; - }, [projectId, addToast]); + }, [projectId, addToast, value]); const handleChange = useCallback( async (next: string) => { diff --git a/packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx index 01ae4f38cf..d94ab3c461 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx @@ -6,8 +6,10 @@ import { WorkflowSelector } from "../WorkflowSelector"; vi.mock("lucide-react", () => ({ Workflow: () => null })); const fetchWorkflowsMock = vi.fn(); +const fetchWorkflowMock = vi.fn(); vi.mock("../../api", () => ({ fetchWorkflows: (...args: unknown[]) => fetchWorkflowsMock(...args), + fetchWorkflow: (...args: unknown[]) => fetchWorkflowMock(...args), fetchProjectDefaultWorkflow: vi.fn(), setProjectDefaultWorkflow: vi.fn(), })); @@ -18,10 +20,12 @@ vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockCon beforeEach(() => { mockConfirm.mockReset(); fetchWorkflowsMock.mockReset(); + fetchWorkflowMock.mockReset(); fetchWorkflowsMock.mockResolvedValue([ { id: "wf-a", name: "Workflow A" }, { id: "wf-b", name: "Workflow B" }, ]); + fetchWorkflowMock.mockResolvedValue({ id: "builtin:hidden", name: "Hidden built-in" }); }); describe("WorkflowSelector switch-with-active-session confirm (U9)", () => { @@ -53,4 +57,11 @@ describe("WorkflowSelector switch-with-active-session confirm (U9)", () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith("wf-b")); expect(mockConfirm).not.toHaveBeenCalled(); }); + + it("appends the current workflow when it is hidden from the filtered list", async () => { + render(); + + await waitFor(() => expect(fetchWorkflowMock).toHaveBeenCalledWith("builtin:hidden", undefined)); + expect(screen.getByRole("option", { name: "Hidden built-in" })).toBeDefined(); + }); }); diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index a0ea50bd7a..4566844430 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -10,9 +10,11 @@ * cross-field summarizer hint are preserved verbatim from the original inline * JSX. */ -import type { ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { WorkflowDefinition } from "@fusion/core"; import { ProjectDefaultWorkflowField } from "../../WorkflowSelector"; import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoSelect"; +import { fetchWorkflows } from "../../../api"; import type { ToastType } from "../../../hooks/useToast"; import type { SectionBaseProps } from "./context"; @@ -39,6 +41,46 @@ export function GeneralSection({ projectTrackingRepoLoading, projectTrackingRepoError, }: GeneralSectionProps) { + const [builtinWorkflows, setBuiltinWorkflows] = useState([]); + + useEffect(() => { + let cancelled = false; + fetchWorkflows(projectId, { includeDisabledBuiltins: true }) + .then((workflows) => { + if (!cancelled) { + setBuiltinWorkflows(workflows.filter((workflow) => workflow.id.startsWith("builtin:"))); + } + }) + .catch(() => { + if (!cancelled) setBuiltinWorkflows([]); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + const enabledBuiltinWorkflowIds = useMemo(() => { + const configured = Array.isArray(form.enabledBuiltinWorkflowIds) ? form.enabledBuiltinWorkflowIds : undefined; + return new Set(configured ?? builtinWorkflows.map((workflow) => workflow.id)); + }, [builtinWorkflows, form.enabledBuiltinWorkflowIds]); + + 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); + if (enabled) { + current.add(workflowId); + } else { + current.delete(workflowId); + } + const nextIds = allIds.filter((id) => current.has(id)); + return { + ...f, + enabledBuiltinWorkflowIds: nextIds.length === allIds.length ? undefined : nextIds, + }; + }); + }; + return ( <> {scopeBanner} @@ -67,6 +109,25 @@ export function GeneralSection({ New tasks inherit this custom workflow's steps (overridable per task) + {builtinWorkflows.length > 0 && ( +
+ +
+ {builtinWorkflows.map((workflow) => ( + + ))} +
+ Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. +
+ )}