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:
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user