feat: allow disabling built-in workflows

This commit is contained in:
gsxdsm
2026-06-08 07:53:42 -07:00
parent c9b780217b
commit bd5315fd4d
15 changed files with 212 additions and 29 deletions

View File

@@ -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.

View File

@@ -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" }),

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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<string, string> = {
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<string, unknown> {
return { seam, name, prompt: BUILTIN_SEAM_PROMPTS[seam] ?? "" };
}

View File

@@ -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",

View File

@@ -238,6 +238,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
globalPause: false,
globalPauseReason: undefined,
defaultWorkflowId: undefined,
enabledBuiltinWorkflowIds: undefined,
approvedWorkflowCliCommands: undefined,
approvedCliAutonomyAdapters: undefined,
enginePaused: false,

View File

@@ -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<WorkflowDefinition[]> {
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.

View File

@@ -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. */

View File

@@ -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: {},

View File

@@ -5043,8 +5043,9 @@ export type {
} from "@fusion/core";
/** List all workflow definitions for the project. */
export function fetchWorkflows(projectId?: string): Promise<import("@fusion/core").WorkflowDefinition[]> {
const path = withProjectId("/workflows", projectId);
export function fetchWorkflows(projectId?: string, options?: { includeDisabledBuiltins?: boolean }): Promise<import("@fusion/core").WorkflowDefinition[]> {
const query = options?.includeDisabledBuiltins ? "?includeDisabledBuiltins=true" : "";
const path = withProjectId(`/workflows${query}`, projectId);
return dedupe(path, () => api<import("@fusion/core").WorkflowDefinition[]>(path));
}

View File

@@ -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) => {

View File

@@ -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(<WorkflowSelector value="builtin:hidden" onChange={vi.fn()} />);
await waitFor(() => expect(fetchWorkflowMock).toHaveBeenCalledWith("builtin:hidden", undefined));
expect(screen.getByRole("option", { name: "Hidden built-in" })).toBeDefined();
});
});

View File

@@ -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<WorkflowDefinition[]>([]);
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({
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast} />
<small>New tasks inherit this custom workflow's steps (overridable per task)</small>
</div>
{builtinWorkflows.length > 0 && (
<div className="form-group">
<label>Built-in workflows</label>
<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>{workflow.name}</span>
</label>
))}
</div>
<small>Disabled built-in workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve.</small>
</div>
)}
<div className="form-group">
<label htmlFor="ephemeralAgentsEnabled" className="checkbox-label">
<input

View File

@@ -275,7 +275,11 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
router.get("/workflows", async (req, res) => {
try {
const { store } = await getProjectContext(req);
res.json(await store.listWorkflowDefinitions());
res.json(
await store.listWorkflowDefinitions({
includeDisabledBuiltins: req.query.includeDisabledBuiltins === "true",
}),
);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);