feat(FN-883): add workflow step default-on support with task creation opt-out

- Add defaultOn field to WorkflowStep model, API endpoints (GET/POST/PATCH), and WorkflowStepManager UI
- Add skip-default-steps checkbox to TaskForm and NewTaskModal for explicit opt-out of default workflow steps
- Pre-select default workflow steps on new task creation forms automatically
- Add CSS styling for skip-default-steps toggle row
- Add comprehensive tests for WorkflowStepManager, TaskForm, and NewTaskModal components
- Add route tests for PATCH workflow-step endpoint and default selection logic
- Update README and task-structure reference docs
This commit is contained in:
gsxdsm
2026-04-05 10:20:42 -07:00
parent 0b12705967
commit 50cd55e9f9
11 changed files with 528 additions and 5 deletions

View File

@@ -126,6 +126,7 @@ What the task should accomplish.
"name": "Documentation Review",
"prompt": "Review the task changes...",
"enabled": true,
"defaultOn": true,
"modelProvider": "anthropic",
"modelId": "claude-sonnet-4-5"
}

View File

@@ -26,6 +26,19 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
const [hasDirtyState, setHasDirtyState] = useState(false);
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
// Handler for workflow step changes that detects explicit user interaction
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
setWorkflowStepsExplicitlySet(true);
setSelectedWorkflowSteps(steps);
}, []);
// Callback when defaultOn steps are auto-applied by TaskForm
const handleDefaultOnApplied = useCallback(() => {
// defaultOn auto-selection is not "explicit" user interaction
setWorkflowStepsExplicitlySet(false);
}, []);
// Track dirty state
useEffect(() => {
@@ -54,6 +67,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
setHasDirtyState(false);
onClose();
}, [hasDirtyState, onClose, pendingImages]);
@@ -72,7 +86,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
description: trimmedDesc,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
enabledWorkflowSteps: selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : undefined,
// When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults.
// When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults.
enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined,
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
@@ -105,6 +121,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowSteps([]);
setWorkflowStepsExplicitlySet(false);
addToast(`Created ${task.id}`, "success");
onClose();
@@ -113,7 +130,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -154,7 +171,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
selectedPresetId={selectedPresetId}
onSelectedPresetIdChange={setSelectedPresetId}
selectedWorkflowSteps={selectedWorkflowSteps}
onWorkflowStepsChange={setSelectedWorkflowSteps}
onWorkflowStepsChange={handleWorkflowStepsChange}
onDefaultOnApplied={handleDefaultOnApplied}
pendingImages={pendingImages}
onImagesChange={setPendingImages}
tasks={tasks}

View File

@@ -39,6 +39,8 @@ export interface TaskFormProps {
// Workflow steps
selectedWorkflowSteps: string[];
onWorkflowStepsChange: (steps: string[]) => void;
/** Callback fired when defaultOn steps have been preselected (create mode). Parent can use this to distinguish "no selection yet" from "user explicitly cleared". */
onDefaultOnApplied?: (stepIds: string[]) => void;
// Attachments
pendingImages: PendingImage[];
@@ -75,6 +77,7 @@ export function TaskForm({
onSelectedPresetIdChange,
selectedWorkflowSteps,
onWorkflowStepsChange,
onDefaultOnApplied,
pendingImages,
onImagesChange,
tasks,
@@ -141,6 +144,29 @@ export function TaskForm({
}
}, [isActive, settings, availablePresets, mode]);
// Auto-select defaultOn workflow steps (create mode, once per activation)
const defaultOnAppliedRef = useRef(false);
useEffect(() => {
if (mode !== "create" || !isActive) return;
if (defaultOnAppliedRef.current) return;
if (workflowSteps.length === 0) return;
const defaultOnSteps = workflowSteps.filter((s) => s.defaultOn);
if (defaultOnSteps.length === 0) return;
defaultOnAppliedRef.current = true;
const stepIds = defaultOnSteps.map((s) => s.id);
onWorkflowStepsChange(stepIds);
onDefaultOnApplied?.(stepIds);
}, [mode, isActive, workflowSteps]);
// Reset defaultOn tracking when form deactivates
useEffect(() => {
if (!isActive) {
defaultOnAppliedRef.current = false;
}
}, [isActive]);
// Auto-focus description (create) or title (edit) when active
useEffect(() => {
if (!isActive) return;

View File

@@ -49,6 +49,7 @@ interface StepFormData {
prompt: string;
scriptName: string;
enabled: boolean;
defaultOn: boolean;
modelProvider: string;
modelId: string;
}
@@ -63,6 +64,7 @@ const EMPTY_FORM: StepFormData = {
prompt: "",
scriptName: "",
enabled: true,
defaultOn: false,
modelProvider: "",
modelId: "",
};
@@ -194,6 +196,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
prompt: step.prompt,
scriptName: step.scriptName || "",
enabled: step.enabled,
defaultOn: step.defaultOn || false,
modelProvider: step.modelProvider || "",
modelId: step.modelId || "",
});
@@ -229,6 +232,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
prompt: form.mode === "prompt" ? (form.prompt.trim() || undefined) : undefined,
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
enabled: form.enabled,
defaultOn: form.defaultOn || undefined,
...modelFields,
};
await createWorkflowStep(input, projectId);
@@ -242,6 +246,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
prompt: form.mode === "prompt" ? form.prompt : "",
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
enabled: form.enabled,
defaultOn: form.defaultOn,
...modelFields,
}, projectId);
addToast("Workflow step updated", "success");
@@ -297,6 +302,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
mode: "prompt",
prompt: form.prompt.trim() || undefined,
enabled: form.enabled,
defaultOn: form.defaultOn || undefined,
...modelFields,
};
const created = await createWorkflowStep(input, projectId);
@@ -429,6 +435,11 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
<span className={`wfm-badge ${(step.phase || "pre-merge") === "post-merge" ? "wfm-badge-post-merge" : "wfm-badge-pre-merge"}`}>
{(step.phase || "pre-merge") === "post-merge" ? "Post-merge" : "Pre-merge"}
</span>
{step.defaultOn && (
<span className="wfm-badge wfm-badge-default-on">
Default on
</span>
)}
</div>
<div className="wfm-step-card-desc">
{step.description}
@@ -744,6 +755,17 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
Enabled (available for selection on new tasks)
</label>
{/* Default on toggle */}
<label className="wfm-checkbox-label">
<input
type="checkbox"
checked={form.defaultOn}
onChange={(e) => setForm((prev) => ({ ...prev, defaultOn: e.target.checked }))}
data-testid="workflow-step-default-on"
/>
Default on for new tasks
</label>
{/* Form actions */}
<div className="wfm-form-actions">
<button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>

View File

@@ -512,4 +512,100 @@ describe("NewTaskModal", () => {
});
});
});
// DefaultOn workflow step handling (FN-883)
describe("defaultOn workflow step handling", () => {
it("sends undefined enabledWorkflowSteps when no defaultOn steps and user hasn't interacted", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, createdAt: "", updatedAt: "" },
]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
// Don't interact with workflow steps at all
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "No interaction task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: undefined,
}),
);
});
});
it("sends empty array when user explicitly deselects all defaultOn steps", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
// Wait for auto-selection to happen
await waitFor(() => {
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
// User explicitly deselects the auto-selected step
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Deselected task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: [],
}),
);
});
});
it("sends defaultOn step IDs when user doesn't modify the auto-selected steps", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security", description: "Check security", prompt: "Check", enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
]);
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
});
// Wait for auto-selection to happen
await waitFor(() => {
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
// Don't modify the selection — just submit.
// Since user hasn't explicitly changed steps, the explicitlySet flag is false,
// so the modal sends undefined (backend applies its own defaults)
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Auto-selected task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
enabledWorkflowSteps: undefined,
}),
);
});
});
});
});

View File

@@ -767,3 +767,136 @@ describe("TaskForm workflow step reordering (FN-836)", () => {
});
});
});
describe("TaskForm defaultOn auto-selection (FN-883)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("auto-selects defaultOn workflow steps in create mode", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
const onDefaultOnApplied = vi.fn();
renderTaskForm({
mode: "create",
onWorkflowStepsChange,
onDefaultOnApplied,
});
await waitFor(() => {
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
});
expect(onDefaultOnApplied).toHaveBeenCalledWith(["WS-001"]);
});
it("does not auto-select workflow steps in edit mode", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({
mode: "edit",
title: "Test",
onTitleChange: vi.fn(),
onWorkflowStepsChange,
});
// Wait for workflow steps to load
await waitFor(() => {
expect(fetchWorkflowSteps).toHaveBeenCalled();
});
// Should NOT have called onWorkflowStepsChange with defaults
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
});
it("does not re-apply defaults after user changes workflow steps", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
]);
let currentSteps: string[] = [];
const onWorkflowStepsChange = vi.fn((steps: string[]) => {
currentSteps = steps;
});
const { rerender } = renderTaskForm({
mode: "create",
selectedWorkflowSteps: currentSteps,
onWorkflowStepsChange,
});
// Wait for auto-selection
await waitFor(() => {
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
});
// Simulate parent state update by rerendering with new steps
rerender(
<TaskForm
mode="create"
description=""
onDescriptionChange={vi.fn()}
dependencies={[]}
onDependenciesChange={vi.fn()}
executorModel=""
onExecutorModelChange={vi.fn()}
validatorModel=""
onValidatorModelChange={vi.fn()}
presetMode="default"
onPresetModeChange={vi.fn()}
selectedPresetId=""
onSelectedPresetIdChange={vi.fn()}
selectedWorkflowSteps={currentSteps}
onWorkflowStepsChange={onWorkflowStepsChange}
pendingImages={[]}
onImagesChange={vi.fn()}
tasks={[]}
addToast={vi.fn()}
isActive={true}
/>
);
// Clear the mock to track further calls
onWorkflowStepsChange.mockClear();
// Simulate user toggling WS-002 checkbox
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
// Should have been called with user action (adding WS-002 to existing WS-001)
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002"]);
// No additional auto-selection calls
expect(onWorkflowStepsChange).toHaveBeenCalledTimes(1);
});
it("does not auto-select when no steps have defaultOn", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", enabled: true, createdAt: "", updatedAt: "" },
]);
const onWorkflowStepsChange = vi.fn();
renderTaskForm({
mode: "create",
onWorkflowStepsChange,
});
await waitFor(() => {
expect(fetchWorkflowSteps).toHaveBeenCalled();
});
// Should NOT have called onWorkflowStepsChange
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
});
});

View File

@@ -191,6 +191,7 @@ describe("WorkflowStepManager", () => {
prompt: undefined,
scriptName: undefined,
enabled: true,
defaultOn: undefined,
}, undefined);
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
});
@@ -454,6 +455,7 @@ describe("WorkflowStepManager", () => {
prompt: undefined,
scriptName: undefined,
enabled: true,
defaultOn: undefined,
}, undefined);
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
});
@@ -490,6 +492,108 @@ describe("WorkflowStepManager", () => {
expect(addToast).toHaveBeenCalledWith("Workflow step updated", "success");
});
});
it("shows defaultOn checkbox in create form", async () => {
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([]);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("add-workflow-step")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-workflow-step"));
const defaultOnCheckbox = screen.getByTestId("workflow-step-default-on") as HTMLInputElement;
expect(defaultOnCheckbox).toBeInTheDocument();
expect(defaultOnCheckbox.checked).toBe(false);
});
it("creates workflow step with defaultOn true", async () => {
vi.mocked(fetchWorkflowSteps)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("add-workflow-step")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-workflow-step"));
const nameInput = screen.getByTestId("workflow-step-name");
const descInput = screen.getByTestId("workflow-step-description");
fireEvent.change(nameInput, { target: { value: "Auto Step" } });
fireEvent.change(descInput, { target: { value: "Auto-enabled" } });
// Check the defaultOn checkbox
const defaultOnCheckbox = screen.getByTestId("workflow-step-default-on");
fireEvent.click(defaultOnCheckbox);
fireEvent.click(screen.getByTestId("save-workflow-step"));
await waitFor(() => {
expect(createWorkflowStep).toHaveBeenCalledWith(
expect.objectContaining({
name: "Auto Step",
defaultOn: true,
}),
undefined,
);
});
});
it("prefills defaultOn when editing a step with defaultOn true", async () => {
const stepsWithDefaultOn: WorkflowStep[] = [
{ ...mockSteps[0], defaultOn: true },
];
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(stepsWithDefaultOn);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("Edit Documentation Review"));
const defaultOnCheckbox = screen.getByTestId("workflow-step-default-on") as HTMLInputElement;
expect(defaultOnCheckbox.checked).toBe(true);
});
it("shows default-on badge for steps with defaultOn true", async () => {
const stepsWithDefaultOn: WorkflowStep[] = [
{ ...mockSteps[0], defaultOn: true },
{ ...mockSteps[1], defaultOn: false },
];
vi.mocked(fetchWorkflowSteps).mockReset();
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(stepsWithDefaultOn);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
expect(screen.getByText("QA Check")).toBeInTheDocument();
});
// Only the first step should have the "Default on" badge
const badges = screen.getAllByText("Default on");
expect(badges).toHaveLength(1);
});
it("does not show default-on badge when defaultOn is false or undefined", async () => {
vi.mocked(fetchWorkflowSteps).mockReset();
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
});
expect(screen.queryByText("Default on")).not.toBeInTheDocument();
});
});
describe("WorkflowStepManager theme class structure", () => {

View File

@@ -12984,6 +12984,11 @@ html .column.drag-over * {
color: #8b5cf6;
}
.wfm-badge-default-on {
background: rgba(234, 179, 8, 0.15);
color: #ca8a04;
}
.wfm-badge-category {
font-size: 11px;
padding: 2px 6px;

View File

@@ -6313,6 +6313,7 @@ describe("POST /workflow-steps", () => {
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
});
});
@@ -6368,6 +6369,7 @@ describe("POST /workflow-steps", () => {
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
@@ -6415,6 +6417,7 @@ describe("POST /workflow-steps", () => {
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
modelProvider: undefined,
modelId: undefined,
});
@@ -6443,6 +6446,7 @@ describe("POST /workflow-steps", () => {
prompt: undefined,
scriptName: "test",
enabled: undefined,
defaultOn: false,
});
});
@@ -6503,6 +6507,7 @@ describe("POST /workflow-steps", () => {
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: false,
});
});
@@ -6516,6 +6521,55 @@ describe("POST /workflow-steps", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("phase must be");
});
it("creates a workflow step with defaultOn true", async () => {
const created = { id: "WS-010", name: "Auto Step", description: "Auto-enabled", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Auto Step",
description: "Auto-enabled",
defaultOn: true,
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Auto Step",
description: "Auto-enabled",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
defaultOn: true,
});
});
it("defaults defaultOn to false when not specified", async () => {
const created = { id: "WS-011", name: "Manual Step", description: "Manual only", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Manual Step",
description: "Manual only",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith(
expect.objectContaining({ defaultOn: false })
);
});
it("returns 400 when defaultOn is not a boolean", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Bad Step",
description: "Bad defaultOn",
defaultOn: "yes",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("defaultOn");
});
});
describe("PATCH /workflow-steps/:id", () => {
@@ -6668,6 +6722,39 @@ describe("PATCH /workflow-steps/:id", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("phase must be");
});
it("updates defaultOn to true", async () => {
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
defaultOn: true,
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: true }));
});
it("updates defaultOn to false", async () => {
const updated = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, defaultOn: false, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
defaultOn: false,
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: false }));
});
it("returns 400 when defaultOn is not a boolean in PATCH", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
defaultOn: "yes",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("defaultOn");
});
});
describe("DELETE /workflow-steps/:id", () => {

View File

@@ -6001,7 +6001,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/workflow-steps", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { name, description, mode, phase, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
const { name, description, mode, phase, prompt, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" });
@@ -6037,6 +6037,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.status(400).json({ error: "enabled must be a boolean" });
return;
}
if (defaultOn !== undefined && typeof defaultOn !== "boolean") {
res.status(400).json({ error: "defaultOn must be a boolean" });
return;
}
// Validate script mode: scriptName must reference a named script in settings
if (resolvedMode === "script") {
@@ -6070,6 +6074,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
prompt: prompt?.trim(),
scriptName: scriptName?.trim(),
enabled,
defaultOn: defaultOn === true,
modelProvider: modelPair.provider,
modelId: modelPair.modelId,
});
@@ -6089,7 +6094,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.patch("/workflow-steps/:id", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { name, description, mode, phase, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
const { name, description, mode, phase, prompt, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
const updates: Record<string, unknown> = {};
if (name !== undefined) {
@@ -6141,6 +6146,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
updates.enabled = enabled;
}
if (defaultOn !== undefined) {
if (typeof defaultOn !== "boolean") {
res.status(400).json({ error: "defaultOn must be a boolean" });
return;
}
updates.defaultOn = defaultOn;
}
// Validate script-mode requirements against the resulting state (existing + updates)
// This catches cases where an existing script-mode step has its scriptName updated