Merge pull request #1511 from Runfusion/feature/disable-built-in-workflows
feat: allow disabling built-in workflows
This commit is contained in:
5
.changeset/quiet-built-in-workflows.md
Normal file
5
.changeset/quiet-built-in-workflows.md
Normal 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.
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
18
packages/core/src/builtin-workflow-prompts.ts
Normal file
18
packages/core/src/builtin-workflow-prompts.ts
Normal 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] ?? "" };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -238,6 +238,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
globalPause: false,
|
||||
globalPauseReason: undefined,
|
||||
defaultWorkflowId: undefined,
|
||||
enabledBuiltinWorkflowIds: undefined,
|
||||
approvedWorkflowCliCommands: undefined,
|
||||
approvedCliAutonomyAdapters: undefined,
|
||||
enginePaused: false,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user