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 b8bc5469ef
commit ee6aa8f183
11 changed files with 528 additions and 5 deletions

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