feat(FN-835): add workflow step execution mode and improve step editor UX

- Add executionMode field to WorkflowStep type with 'agent' | 'api' options and validation
- Extend TaskStore CRUD with execution mode support and validation for prompt/description
- Add API validation layer in dashboard routes for workflow step create/update/delete
- Enhance WorkflowStepManager component with inline editing, validation feedback, and improved UX
- Add comprehensive tests for store CRUD validation and API route validation
- Clean up dashboard styles by removing unused CSS rules and dead test assertions
This commit is contained in:
gsxdsm
2026-04-04 05:00:03 -07:00
parent 601e000da4
commit e7fee33eba
8 changed files with 705 additions and 70 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import type { WorkflowStep, WorkflowStepInput } from "@fusion/core";
import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode } from "@fusion/core";
import {
fetchWorkflowSteps,
createWorkflowStep,
@@ -8,6 +8,7 @@ import {
refineWorkflowStepPrompt,
fetchWorkflowStepTemplates,
createWorkflowStepFromTemplate,
fetchScripts,
type WorkflowStepTemplate,
} from "../api";
import type { ToastType } from "../hooks/useToast";
@@ -26,6 +27,8 @@ import {
Eye,
LayoutGrid,
BookOpen,
Terminal,
MessageSquare,
} from "lucide-react";
interface WorkflowStepManagerProps {
@@ -38,7 +41,9 @@ interface WorkflowStepManagerProps {
interface StepFormData {
name: string;
description: string;
mode: WorkflowStepMode;
prompt: string;
scriptName: string;
enabled: boolean;
}
@@ -47,7 +52,9 @@ type TabId = "my-steps" | "templates";
const EMPTY_FORM: StepFormData = {
name: "",
description: "",
mode: "prompt",
prompt: "",
scriptName: "",
enabled: true,
};
@@ -94,6 +101,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const [refining, setRefining] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [addingTemplateId, setAddingTemplateId] = useState<string | null>(null);
const [availableScripts, setAvailableScripts] = useState<Record<string, string>>({});
const loadSteps = useCallback(async () => {
try {
@@ -107,6 +115,15 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
}
}, [addToast, projectId]);
const loadScripts = useCallback(async () => {
try {
const scripts = await fetchScripts(projectId);
setAvailableScripts(scripts || {});
} catch {
// Silently ignore — scripts are optional
}
}, [projectId]);
const loadTemplates = useCallback(async () => {
try {
setTemplatesLoading(true);
@@ -123,8 +140,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
if (isOpen) {
loadSteps();
loadTemplates();
loadScripts();
}
}, [isOpen, loadSteps, loadTemplates]);
}, [isOpen, loadSteps, loadTemplates, loadScripts]);
const handleCreate = useCallback(() => {
setIsCreating(true);
@@ -138,7 +156,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
setForm({
name: step.name,
description: step.description,
mode: step.mode || "prompt",
prompt: step.prompt,
scriptName: step.scriptName || "",
enabled: step.enabled,
});
}, []);
@@ -161,7 +181,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const input: WorkflowStepInput = {
name: form.name.trim(),
description: form.description.trim(),
prompt: form.prompt.trim() || undefined,
mode: form.mode,
prompt: form.mode === "prompt" ? (form.prompt.trim() || undefined) : undefined,
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
enabled: form.enabled,
};
await createWorkflowStep(input, projectId);
@@ -170,7 +192,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
await updateWorkflowStep(editingId, {
name: form.name.trim(),
description: form.description.trim(),
prompt: form.prompt,
mode: form.mode,
prompt: form.mode === "prompt" ? form.prompt : "",
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
enabled: form.enabled,
}, projectId);
addToast("Workflow step updated", "success");
@@ -204,6 +228,8 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const handleRefine = useCallback(async () => {
if (!editingId && !isCreating) return;
// Refine only works for prompt mode
if (form.mode !== "prompt") return;
// For new steps being created, we need to save first then refine
if (isCreating) {
@@ -217,6 +243,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const input: WorkflowStepInput = {
name: form.name.trim(),
description: form.description.trim(),
mode: "prompt",
prompt: form.prompt.trim() || undefined,
enabled: form.enabled,
};
@@ -407,6 +434,21 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
>
{step.enabled ? "Enabled" : "Disabled"}
</span>
<span
style={{
fontSize: "11px",
padding: "2px 6px",
borderRadius: "4px",
background: (step.mode || "prompt") === "script"
? "rgba(168, 85, 247, 0.15)"
: "rgba(59, 130, 246, 0.15)",
color: (step.mode || "prompt") === "script"
? "#a855f7"
: "#3b82f6",
}}
>
{(step.mode || "prompt") === "script" ? "Script" : "AI Prompt"}
</span>
</div>
<div
style={{
@@ -673,61 +715,169 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
/>
</div>
{/* Prompt */}
{/* Mode Selector */}
<div>
<div
<label
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
display: "block",
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
}}
>
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Agent Prompt
</label>
Execution Mode
</label>
<div
style={{ display: "flex", gap: "8px" }}
data-testid="workflow-step-mode-selector"
>
<button
className="btn-icon"
onClick={handleRefine}
disabled={!form.description.trim() || refining}
title="Refine with AI"
aria-label="Refine prompt with AI"
className={`btn ${form.mode === "prompt" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setForm((prev) => ({ ...prev, mode: "prompt", scriptName: "" }))}
style={{
fontSize: "12px",
display: "flex",
alignItems: "center",
gap: "4px",
gap: "6px",
fontSize: "12px",
padding: "6px 12px",
flex: 1,
justifyContent: "center",
}}
data-testid="refine-btn"
data-testid="mode-prompt"
>
{refining ? (
<Loader2 size={12} className="spin" />
) : (
<Sparkles size={12} />
)}
<span style={{ fontSize: "11px" }}>Refine with AI</span>
<MessageSquare size={14} />
AI Prompt
</button>
<button
className={`btn ${form.mode === "script" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setForm((prev) => ({ ...prev, mode: "script", prompt: "" }))}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "12px",
padding: "6px 12px",
flex: 1,
justifyContent: "center",
}}
data-testid="mode-script"
>
<Terminal size={14} />
Run Script
</button>
</div>
<textarea
value={form.prompt}
onChange={(e) => setForm((prev) => ({ ...prev, prompt: e.target.value }))}
placeholder="Leave empty to use AI refinement"
rows={6}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid var(--border-primary)",
background: "var(--bg-primary)",
color: "var(--text-primary)",
fontSize: "13px",
fontFamily: "monospace",
resize: "vertical",
}}
data-testid="workflow-step-prompt"
/>
</div>
{/* Prompt (AI mode only) */}
{form.mode === "prompt" && (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "4px",
}}
>
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Agent Prompt
</label>
<button
className="btn-icon"
onClick={handleRefine}
disabled={!form.description.trim() || refining}
title="Refine with AI"
aria-label="Refine prompt with AI"
style={{
fontSize: "12px",
display: "flex",
alignItems: "center",
gap: "4px",
}}
data-testid="refine-btn"
>
{refining ? (
<Loader2 size={12} className="spin" />
) : (
<Sparkles size={12} />
)}
<span style={{ fontSize: "11px" }}>Refine with AI</span>
</button>
</div>
<textarea
value={form.prompt}
onChange={(e) => setForm((prev) => ({ ...prev, prompt: e.target.value }))}
placeholder="Leave empty to use AI refinement"
rows={6}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid var(--border-primary)",
background: "var(--bg-primary)",
color: "var(--text-primary)",
fontSize: "13px",
fontFamily: "monospace",
resize: "vertical",
}}
data-testid="workflow-step-prompt"
/>
</div>
)}
{/* Script selector (script mode only) */}
{form.mode === "script" && (
<div>
<label
style={{
display: "block",
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
}}
>
Script
</label>
{Object.keys(availableScripts).length === 0 ? (
<div
style={{
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid var(--border-primary)",
background: "var(--bg-tertiary)",
color: "var(--text-secondary)",
fontSize: "12px",
}}
data-testid="no-scripts-message"
>
No scripts configured. Add scripts in Settings Scripts first.
</div>
) : (
<select
value={form.scriptName}
onChange={(e) => setForm((prev) => ({ ...prev, scriptName: e.target.value }))}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid var(--border-primary)",
background: "var(--bg-primary)",
color: "var(--text-primary)",
fontSize: "13px",
}}
data-testid="workflow-step-script-select"
>
<option value="">Select a script</option>
{Object.entries(availableScripts).map(([name, command]) => (
<option key={name} value={name}>
{name} ({command})
</option>
))}
</select>
)}
</div>
)}
{/* Enabled toggle */}
<label
style={{
@@ -762,7 +912,12 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
<button
className="btn btn-primary"
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.description.trim()}
disabled={
saving ||
!form.name.trim() ||
!form.description.trim() ||
(form.mode === "script" && !form.scriptName.trim())
}
data-testid="save-workflow-step"
>
{saving ? "Saving..." : isCreating ? "Create" : "Save"}

View File

@@ -8,6 +8,7 @@ const mockSteps: WorkflowStep[] = [
id: "WS-001",
name: "Documentation Review",
description: "Verify all public APIs have documentation",
mode: "prompt",
prompt: "Review the task changes and verify docs.",
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
@@ -17,6 +18,7 @@ const mockSteps: WorkflowStep[] = [
id: "WS-002",
name: "QA Check",
description: "Run tests and verify they pass",
mode: "prompt",
prompt: "Execute the test suite.",
enabled: false,
createdAt: "2026-01-02T00:00:00.000Z",
@@ -30,6 +32,7 @@ vi.mock("../../api", () => ({
id: "WS-003",
name: "New Step",
description: "New description",
mode: "prompt",
prompt: "",
enabled: true,
createdAt: "2026-01-03T00:00:00.000Z",
@@ -45,6 +48,7 @@ vi.mock("../../api", () => ({
prompt: "AI-generated detailed prompt",
workflowStep: { ...mockSteps[0], prompt: "AI-generated detailed prompt" },
})),
fetchScripts: vi.fn(() => Promise.resolve({ test: "pnpm test", lint: "pnpm lint" })),
}));
import {
@@ -54,6 +58,7 @@ import {
deleteWorkflowStep,
refineWorkflowStepPrompt,
} from "../../api";
import { fetchScripts } from "../../api";
const onClose = vi.fn();
const addToast = vi.fn();
@@ -133,7 +138,9 @@ describe("WorkflowStepManager", () => {
expect(createWorkflowStep).toHaveBeenCalledWith({
name: "New Step",
description: "New description",
mode: "prompt",
prompt: undefined,
scriptName: undefined,
enabled: true,
}, undefined);
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
@@ -241,4 +248,113 @@ describe("WorkflowStepManager", () => {
expect(screen.getByText("Disabled")).toBeInTheDocument();
});
});
it("shows AI Prompt mode badge for prompt steps", async () => {
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getAllByText("AI Prompt")).toHaveLength(2);
});
});
it("shows Script mode badge for script steps", async () => {
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "" },
]);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Script")).toBeInTheDocument();
});
});
it("shows mode selector when creating a new step", 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"));
expect(screen.getByTestId("workflow-step-mode-selector")).toBeInTheDocument();
expect(screen.getByTestId("mode-prompt")).toBeInTheDocument();
expect(screen.getByTestId("mode-script")).toBeInTheDocument();
});
it("shows prompt field in prompt mode and script select in script mode", 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"));
// Default is prompt mode — should show prompt field
expect(screen.getByTestId("workflow-step-prompt")).toBeInTheDocument();
expect(screen.queryByTestId("workflow-step-script-select")).not.toBeInTheDocument();
// Switch to script mode
fireEvent.click(screen.getByTestId("mode-script"));
expect(screen.queryByTestId("workflow-step-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("workflow-step-script-select")).toBeInTheDocument();
// Switch back to prompt mode
fireEvent.click(screen.getByTestId("mode-prompt"));
expect(screen.getByTestId("workflow-step-prompt")).toBeInTheDocument();
expect(screen.queryByTestId("workflow-step-script-select")).not.toBeInTheDocument();
});
it("loads script name when editing a script-mode step", async () => {
vi.mocked(fetchWorkflowSteps)
.mockResolvedValueOnce([{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "" }])
.mockResolvedValueOnce([{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "" }]);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("Edit Documentation Review"));
// Script mode should be selected
const scriptSelect = screen.getByTestId("workflow-step-script-select") as HTMLSelectElement;
expect(scriptSelect.value).toBe("test");
});
it("disables save when script mode is selected without a script name", 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 nameInput = screen.getByTestId("workflow-step-name");
const descInput = screen.getByTestId("workflow-step-description");
fireEvent.change(nameInput, { target: { value: "Test" } });
fireEvent.change(descInput, { target: { value: "Test desc" } });
// Switch to script mode
fireEvent.click(screen.getByTestId("mode-script"));
// Save should be disabled (no script selected)
const saveBtn = screen.getByTestId("save-workflow-step") as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
});
});

View File

@@ -6246,7 +6246,7 @@ describe("POST /workflow-steps", () => {
}
it("creates a workflow step", async () => {
const created = { id: "WS-001", name: "Docs", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
const created = { id: "WS-001", name: "Docs", description: "Check docs", 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({
@@ -6259,7 +6259,9 @@ describe("POST /workflow-steps", () => {
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Docs",
description: "Check docs",
mode: "prompt",
prompt: undefined,
scriptName: undefined,
enabled: undefined,
});
});
@@ -6311,7 +6313,9 @@ describe("POST /workflow-steps", () => {
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Security",
description: "Security audit",
mode: "prompt",
prompt: undefined,
scriptName: undefined,
enabled: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
@@ -6341,7 +6345,7 @@ describe("POST /workflow-steps", () => {
});
it("creates a workflow step without model fields when both empty strings", async () => {
const created = { id: "WS-001", name: "Docs", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
const created = { id: "WS-001", name: "Docs", description: "Check docs", 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({
@@ -6355,12 +6359,77 @@ describe("POST /workflow-steps", () => {
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Docs",
description: "Check docs",
mode: "prompt",
prompt: undefined,
scriptName: undefined,
enabled: undefined,
modelProvider: undefined,
modelId: undefined,
});
});
it("creates a script-mode workflow step with valid scriptName", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { test: "pnpm test", lint: "pnpm lint" },
});
const created = { id: "WS-001", name: "Run Tests", description: "Execute tests", mode: "script", scriptName: "test", 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: "Run Tests",
description: "Execute tests",
mode: "script",
scriptName: "test",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Run Tests",
description: "Execute tests",
mode: "script",
prompt: undefined,
scriptName: "test",
enabled: undefined,
});
});
it("returns 400 for script mode without scriptName", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Run Tests",
description: "Execute tests",
mode: "script",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("scriptName is required");
});
it("returns 400 for script mode with scriptName not in project scripts", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
scripts: { lint: "pnpm lint" },
});
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Run Tests",
description: "Execute tests",
mode: "script",
scriptName: "nonexistent",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("not found in project settings");
});
it("returns 400 for invalid mode value", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Test",
description: "Test",
mode: "invalid",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode must be");
});
});
describe("PATCH /workflow-steps/:id", () => {
@@ -6495,7 +6564,7 @@ describe("POST /workflow-steps/:id/refine", () => {
it("returns 400 when workflow step has no description", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Empty", description: " ", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
id: "WS-001", name: "Empty", description: " ", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
@@ -6506,8 +6575,21 @@ describe("POST /workflow-steps/:id/refine", () => {
expect(res.body.error).toContain("no description");
});
it("returns 400 when workflow step is in script mode", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Run Tests", description: "Execute test suite", mode: "script", scriptName: "test", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot refine prompt for script-mode");
});
it("falls back to description when AI is unavailable", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({});
const updatedWs = { ...ws, prompt: "Check docs" };

View File

@@ -5804,13 +5804,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* POST /api/workflow-steps
* Create a new workflow step.
* Body: { name: string, description: string, prompt?: string, enabled?: boolean }
* Body: { name: string, description: string, mode?: "prompt"|"script", prompt?: string, scriptName?: string, enabled?: boolean, modelProvider?: string, modelId?: string }
* Returns: WorkflowStep
*/
router.post("/workflow-steps", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { name, description, prompt, enabled, modelProvider, modelId } = req.body;
const { name, description, mode, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" });
@@ -5820,16 +5820,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.status(400).json({ error: "description is required" });
return;
}
// Validate mode
const resolvedMode: "prompt" | "script" = mode || "prompt";
if (resolvedMode !== "prompt" && resolvedMode !== "script") {
res.status(400).json({ error: "mode must be 'prompt' or 'script'" });
return;
}
if (prompt !== undefined && typeof prompt !== "string") {
res.status(400).json({ error: "prompt must be a string" });
return;
}
if (scriptName !== undefined && typeof scriptName !== "string") {
res.status(400).json({ error: "scriptName must be a string" });
return;
}
if (enabled !== undefined && typeof enabled !== "boolean") {
res.status(400).json({ error: "enabled must be a boolean" });
return;
}
// Validate model override pair
// Validate script mode: scriptName must reference a named script in settings
if (resolvedMode === "script") {
if (!scriptName?.trim()) {
res.status(400).json({ error: "scriptName is required when mode is 'script'" });
return;
}
const settings = await scopedStore.getSettings();
const scripts = settings.scripts || {};
if (!(scriptName.trim() in scripts)) {
res.status(400).json({ error: `Script '${scriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}` });
return;
}
}
// Validate model override pair (only relevant for prompt mode)
const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model");
// Check for name conflicts
@@ -5842,14 +5868,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const step = await scopedStore.createWorkflowStep({
name: name.trim(),
description: description.trim(),
mode: resolvedMode,
prompt: prompt?.trim(),
scriptName: scriptName?.trim(),
enabled,
modelProvider: modelPair.provider,
modelId: modelPair.modelId,
});
res.status(201).json(step);
} catch (err: any) {
const status = typeof err?.message === "string" && err.message.includes("must include both provider and modelId") ? 400 : 500;
const status = typeof err?.message === "string" && (err.message.includes("must include both provider and modelId") || err.message.includes("Script mode requires")) ? 400 : 500;
res.status(status).json({ error: err.message });
}
});
@@ -5857,13 +5885,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* PATCH /api/workflow-steps/:id
* Update a workflow step.
* Body: Partial<{ name, description, prompt, enabled }>
* Body: Partial<{ name, description, mode, prompt, scriptName, enabled, modelProvider, modelId }>
* Returns: WorkflowStep
*/
router.patch("/workflow-steps/:id", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { name, description, prompt, enabled, modelProvider, modelId } = req.body;
const { name, description, mode, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
const updates: Record<string, unknown> = {};
if (name !== undefined) {
@@ -5880,6 +5908,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
updates.description = description.trim();
}
if (mode !== undefined) {
if (mode !== "prompt" && mode !== "script") {
res.status(400).json({ error: "mode must be 'prompt' or 'script'" });
return;
}
updates.mode = mode;
}
if (prompt !== undefined) {
if (typeof prompt !== "string") {
res.status(400).json({ error: "prompt must be a string" });
@@ -5887,6 +5922,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
updates.prompt = prompt;
}
if (scriptName !== undefined) {
if (typeof scriptName !== "string") {
res.status(400).json({ error: "scriptName must be a string" });
return;
}
updates.scriptName = scriptName;
}
if (enabled !== undefined) {
if (typeof enabled !== "boolean") {
res.status(400).json({ error: "enabled must be a boolean" });
@@ -5895,6 +5937,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
updates.enabled = enabled;
}
// Validate script name references an actual script when switching to script mode
if (updates.mode === "script") {
const scriptNameToValidate = (updates.scriptName as string | undefined);
if (!scriptNameToValidate?.trim()) {
res.status(400).json({ error: "scriptName is required when mode is 'script'" });
return;
}
const settings = await scopedStore.getSettings();
const scripts = settings.scripts || {};
if (!(scriptNameToValidate.trim() in scripts)) {
res.status(400).json({ error: `Script '${scriptNameToValidate.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}` });
return;
}
}
// Validate and apply model override pair
if (modelProvider !== undefined || modelId !== undefined) {
const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model");
@@ -5908,7 +5965,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });
} else {
const status = typeof err?.message === "string" && err.message.includes("must include both provider and modelId") ? 400 : 500;
const status = typeof err?.message === "string" && (err.message.includes("must include both provider and modelId") || err.message.includes("Script mode requires")) ? 400 : 500;
res.status(status).json({ error: err.message });
}
}
@@ -5936,6 +5993,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* POST /api/workflow-steps/:id/refine
* Use AI to refine the workflow step's description into a detailed agent prompt.
* Only available for prompt-mode steps.
* Returns: { prompt: string, workflowStep: WorkflowStep }
*/
router.post("/workflow-steps/:id/refine", async (req, res) => {
@@ -5947,6 +6005,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
if (step.mode === "script") {
res.status(400).json({ error: "Cannot refine prompt for script-mode workflow steps" });
return;
}
if (!step.description?.trim()) {
res.status(400).json({ error: "Workflow step has no description to refine" });
return;