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 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js"; export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, NtfyNotificationEvent, SteeringComment } from "./types.js"; export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, NtfyNotificationEvent, SteeringComment } from "./types.js";
export { AgentStore } from "./agent-store.js"; export { AgentStore } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js"; export type { AgentStoreEvents } from "./agent-store.js";
export { TaskStore } from "./store.js"; export { TaskStore } from "./store.js";

View File

@@ -4311,7 +4311,9 @@ Task with acceptance criteria
expect(ws.id).toBe("WS-001"); expect(ws.id).toBe("WS-001");
expect(ws.name).toBe("Documentation Review"); expect(ws.name).toBe("Documentation Review");
expect(ws.description).toBe("Verify all public APIs have documentation"); expect(ws.description).toBe("Verify all public APIs have documentation");
expect(ws.mode).toBe("prompt");
expect(ws.prompt).toBe("Review the task changes and verify that all new public functions have docs."); expect(ws.prompt).toBe("Review the task changes and verify that all new public functions have docs.");
expect(ws.scriptName).toBeUndefined();
expect(ws.enabled).toBe(true); expect(ws.enabled).toBe(true);
expect(ws.createdAt).toBeDefined(); expect(ws.createdAt).toBeDefined();
expect(ws.updatedAt).toBeDefined(); expect(ws.updatedAt).toBeDefined();
@@ -4326,10 +4328,50 @@ Task with acceptance criteria
expect(ws.id).toBe("WS-001"); expect(ws.id).toBe("WS-001");
expect(ws.name).toBe("QA Check"); expect(ws.name).toBe("QA Check");
expect(ws.description).toBe("Run tests and verify they pass"); expect(ws.description).toBe("Run tests and verify they pass");
expect(ws.mode).toBe("prompt"); // Default mode
expect(ws.prompt).toBe(""); // Empty when not provided expect(ws.prompt).toBe(""); // Empty when not provided
expect(ws.enabled).toBe(true); // Default enabled expect(ws.enabled).toBe(true); // Default enabled
}); });
it("should create a script-mode workflow step", async () => {
const ws = await store.createWorkflowStep({
name: "Run Tests",
description: "Execute the test suite",
mode: "script",
scriptName: "test",
});
expect(ws.id).toBe("WS-001");
expect(ws.name).toBe("Run Tests");
expect(ws.mode).toBe("script");
expect(ws.prompt).toBe("");
expect(ws.scriptName).toBe("test");
expect(ws.modelProvider).toBeUndefined();
expect(ws.modelId).toBeUndefined();
expect(ws.enabled).toBe(true);
});
it("should reject script mode without scriptName", async () => {
await expect(
store.createWorkflowStep({
name: "Broken",
description: "No script name",
mode: "script",
}),
).rejects.toThrow("Script mode requires a scriptName");
});
it("should reject script mode with empty scriptName", async () => {
await expect(
store.createWorkflowStep({
name: "Broken",
description: "Empty script name",
mode: "script",
scriptName: " ",
}),
).rejects.toThrow("Script mode requires a scriptName");
});
it("should auto-increment workflow step IDs", async () => { it("should auto-increment workflow step IDs", async () => {
const ws1 = await store.createWorkflowStep({ name: "Step 1", description: "First" }); const ws1 = await store.createWorkflowStep({ name: "Step 1", description: "First" });
const ws2 = await store.createWorkflowStep({ name: "Step 2", description: "Second" }); const ws2 = await store.createWorkflowStep({ name: "Step 2", description: "Second" });
@@ -4385,6 +4427,7 @@ Task with acceptance criteria
expect(updated.name).toBe("Updated"); expect(updated.name).toBe("Updated");
expect(updated.description).toBe("Updated desc"); expect(updated.description).toBe("Updated desc");
expect(updated.mode).toBe("prompt");
expect(updated.prompt).toBe("Updated prompt"); expect(updated.prompt).toBe("Updated prompt");
expect(updated.enabled).toBe(false); expect(updated.enabled).toBe(false);
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual( expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
@@ -4392,6 +4435,91 @@ Task with acceptance criteria
); );
}); });
it("should switch a workflow step from prompt to script mode", async () => {
const ws = await store.createWorkflowStep({
name: "Docs",
description: "Check docs",
prompt: "Review documentation.",
mode: "prompt",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
const updated = await store.updateWorkflowStep(ws.id, {
mode: "script",
scriptName: "lint",
});
expect(updated.mode).toBe("script");
expect(updated.scriptName).toBe("lint");
expect(updated.prompt).toBe(""); // Cleared on mode switch
expect(updated.modelProvider).toBeUndefined(); // Cleared on mode switch
expect(updated.modelId).toBeUndefined(); // Cleared on mode switch
});
it("should switch a workflow step from script to prompt mode", async () => {
const ws = await store.createWorkflowStep({
name: "Lint",
description: "Run linting",
mode: "script",
scriptName: "lint",
});
const updated = await store.updateWorkflowStep(ws.id, {
mode: "prompt",
prompt: "Review code quality.",
});
expect(updated.mode).toBe("prompt");
expect(updated.scriptName).toBeUndefined(); // Cleared on mode switch
expect(updated.prompt).toBe("Review code quality.");
});
it("should reject switching to script mode without scriptName", async () => {
const ws = await store.createWorkflowStep({
name: "Docs",
description: "Check docs",
prompt: "Review documentation.",
});
await expect(
store.updateWorkflowStep(ws.id, { mode: "script" }),
).rejects.toThrow("Script mode requires a scriptName");
});
it("should ignore prompt updates for script-mode steps", async () => {
const ws = await store.createWorkflowStep({
name: "Lint",
description: "Run linting",
mode: "script",
scriptName: "lint",
});
const updated = await store.updateWorkflowStep(ws.id, {
prompt: "This should be ignored",
});
expect(updated.prompt).toBe(""); // Prompt not updated for script mode
});
it("should ignore model override updates for script-mode steps", async () => {
const ws = await store.createWorkflowStep({
name: "Lint",
description: "Run linting",
mode: "script",
scriptName: "lint",
});
const updated = await store.updateWorkflowStep(ws.id, {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
// Model overrides should not be set for script mode
expect(updated.modelProvider).toBeUndefined();
expect(updated.modelId).toBeUndefined();
});
it("should throw when updating non-existent workflow step", async () => { it("should throw when updating non-existent workflow step", async () => {
await expect( await expect(
store.updateWorkflowStep("WS-999", { name: "Nope" }) store.updateWorkflowStep("WS-999", { name: "Nope" })
@@ -4525,6 +4653,43 @@ Task with acceptance criteria
expect(found!.modelProvider).toBe("anthropic"); expect(found!.modelProvider).toBe("anthropic");
expect(found!.modelId).toBe("claude-sonnet-4-5"); expect(found!.modelId).toBe("claude-sonnet-4-5");
}); });
it("should normalize legacy workflow steps without mode to prompt mode", async () => {
// Create a step normally (it will have mode: "prompt")
const ws = await store.createWorkflowStep({
name: "Legacy Step",
description: "Pre-existing step",
prompt: "Review the code.",
});
// Simulate legacy data by writing a step without mode directly to DB
const config = await (store as any).readConfig();
// Remove mode from the stored step to simulate legacy data
delete config.workflowSteps[0].mode;
await (store as any).writeConfig(config);
// Re-read should normalize mode to "prompt"
const found = await store.getWorkflowStep(ws.id);
expect(found!.mode).toBe("prompt");
expect(found!.prompt).toBe("Review the code.");
});
it("should persist script-mode workflow step across list/get", async () => {
const ws = await store.createWorkflowStep({
name: "Type Check",
description: "Run TypeScript type checking",
mode: "script",
scriptName: "typecheck",
});
const listed = await store.listWorkflowSteps();
expect(listed[0].mode).toBe("script");
expect(listed[0].scriptName).toBe("typecheck");
const found = await store.getWorkflowStep(ws.id);
expect(found!.mode).toBe("script");
expect(found!.scriptName).toBe("typecheck");
});
}); });
// ── Title Summarization Tests ──────────────────────────────────────────── // ── Title Summarization Tests ────────────────────────────────────────────

View File

@@ -583,10 +583,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (!row) { if (!row) {
return { nextId: 1 }; return { nextId: 1 };
} }
const workflowSteps = fromJson<import("./types.js").WorkflowStep[]>(row.workflowSteps);
// Normalize legacy steps that don't have a mode field — default to "prompt"
if (workflowSteps) {
for (const ws of workflowSteps) {
if (!ws.mode) {
ws.mode = "prompt";
}
}
}
return { return {
nextId: row.nextId || 1, nextId: row.nextId || 1,
settings: fromJson<Settings>(row.settings), settings: fromJson<Settings>(row.settings),
workflowSteps: fromJson<import("./types.js").WorkflowStep[]>(row.workflowSteps), workflowSteps,
nextWorkflowStepId: row.nextWorkflowStepId || 1, nextWorkflowStepId: row.nextWorkflowStepId || 1,
}; };
} }
@@ -2522,15 +2531,24 @@ ${stepsSection}`;
const nextWsId = config.nextWorkflowStepId || 1; const nextWsId = config.nextWorkflowStepId || 1;
const id = `WS-${String(nextWsId).padStart(3, "0")}`; const id = `WS-${String(nextWsId).padStart(3, "0")}`;
const mode = input.mode || "prompt";
// Validate: script mode requires scriptName
if (mode === "script" && !input.scriptName?.trim()) {
throw new Error("Script mode requires a scriptName");
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const step: import("./types.js").WorkflowStep = { const step: import("./types.js").WorkflowStep = {
id, id,
name: input.name, name: input.name,
description: input.description, description: input.description,
prompt: input.prompt || "", mode,
prompt: mode === "prompt" ? (input.prompt || "") : "",
scriptName: mode === "script" ? input.scriptName : undefined,
enabled: input.enabled !== undefined ? input.enabled : true, enabled: input.enabled !== undefined ? input.enabled : true,
modelProvider: input.modelProvider, modelProvider: mode === "prompt" ? input.modelProvider : undefined,
modelId: input.modelId, modelId: mode === "prompt" ? input.modelId : undefined,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -2577,12 +2595,36 @@ ${stepsSection}`;
} }
const step = steps[index]; const step = steps[index];
// Handle mode change
if (updates.mode !== undefined) {
const newMode = updates.mode;
// Validate: script mode requires scriptName
if (newMode === "script" && !updates.scriptName?.trim() && !step.scriptName?.trim()) {
throw new Error("Script mode requires a scriptName");
}
step.mode = newMode;
// When switching to script mode, clear prompt and model overrides
if (newMode === "script") {
step.prompt = "";
step.modelProvider = undefined;
step.modelId = undefined;
}
// When switching to prompt mode, clear scriptName
if (newMode === "prompt") {
step.scriptName = undefined;
}
}
if (updates.name !== undefined) step.name = updates.name; if (updates.name !== undefined) step.name = updates.name;
if (updates.description !== undefined) step.description = updates.description; if (updates.description !== undefined) step.description = updates.description;
if (updates.prompt !== undefined) step.prompt = updates.prompt; if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt;
if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName;
if (updates.enabled !== undefined) step.enabled = updates.enabled; if (updates.enabled !== undefined) step.enabled = updates.enabled;
if ("modelProvider" in updates) step.modelProvider = updates.modelProvider; if (step.mode === "prompt") {
if ("modelId" in updates) step.modelId = updates.modelId; if ("modelProvider" in updates) step.modelProvider = updates.modelProvider;
if ("modelId" in updates) step.modelId = updates.modelId;
}
step.updatedAt = new Date().toISOString(); step.updatedAt = new Date().toISOString();
config.workflowSteps = steps; config.workflowSteps = steps;

View File

@@ -44,6 +44,9 @@ export interface ModelPreset {
} }
/** A reusable workflow step definition that can run after task implementation. */ /** A reusable workflow step definition that can run after task implementation. */
/** Execution mode for a workflow step. */
export type WorkflowStepMode = "prompt" | "script";
export interface WorkflowStep { export interface WorkflowStep {
/** Unique identifier (e.g., "WS-001") */ /** Unique identifier (e.g., "WS-001") */
id: string; id: string;
@@ -51,17 +54,21 @@ export interface WorkflowStep {
name: string; name: string;
/** Short description for UI display */ /** Short description for UI display */
description: string; description: string;
/** Full agent prompt to execute when this step runs */ /** Execution mode — "prompt" runs an AI agent, "script" runs a named project script */
mode: WorkflowStepMode;
/** Full agent prompt to execute when this step runs (used when mode is "prompt") */
prompt: string; prompt: string;
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
scriptName?: string;
/** Whether this step is available for selection on new tasks */ /** Whether this step is available for selection on new tasks */
enabled: boolean; enabled: boolean;
/** AI model provider override for the workflow step agent (e.g., "anthropic"). /** AI model provider override for the workflow step agent (e.g., "anthropic").
* Must be set together with `modelId`. When both model fields are undefined, * Must be set together with `modelId`. When both model fields are undefined,
* the executor uses global settings defaults. */ * the executor uses global settings defaults. Only used when mode is "prompt". */
modelProvider?: string; modelProvider?: string;
/** AI model ID override for the workflow step agent (e.g., "claude-sonnet-4-5"). /** AI model ID override for the workflow step agent (e.g., "claude-sonnet-4-5").
* Must be set together with `modelProvider`. When both model fields are undefined, * Must be set together with `modelProvider`. When both model fields are undefined,
* the executor uses global settings defaults. */ * the executor uses global settings defaults. Only used when mode is "prompt". */
modelId?: string; modelId?: string;
/** ISO-8601 timestamp of creation */ /** ISO-8601 timestamp of creation */
createdAt: string; createdAt: string;
@@ -76,13 +83,18 @@ export type NtfyNotificationEvent = "in-review" | "merged" | "failed";
export interface WorkflowStepInput { export interface WorkflowStepInput {
name: string; name: string;
description: string; description: string;
/** Optional — can be AI-generated later via refinement */ /** Execution mode — defaults to "prompt" if not specified */
mode?: WorkflowStepMode;
/** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */
prompt?: string; prompt?: string;
/** Script name from project settings (required when mode is "script").
* Must reference a named script in `settings.scripts` — no raw commands. */
scriptName?: string;
/** Defaults to true if not specified */ /** Defaults to true if not specified */
enabled?: boolean; enabled?: boolean;
/** AI model provider override. Must be set together with modelId. */ /** AI model provider override. Must be set together with modelId. Only used when mode is "prompt". */
modelProvider?: string; modelProvider?: string;
/** AI model ID override. Must be set together with modelProvider. */ /** AI model ID override. Must be set together with modelProvider. Only used when mode is "prompt". */
modelId?: string; modelId?: string;
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { WorkflowStep, WorkflowStepInput } from "@fusion/core"; import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode } from "@fusion/core";
import { import {
fetchWorkflowSteps, fetchWorkflowSteps,
createWorkflowStep, createWorkflowStep,
@@ -8,6 +8,7 @@ import {
refineWorkflowStepPrompt, refineWorkflowStepPrompt,
fetchWorkflowStepTemplates, fetchWorkflowStepTemplates,
createWorkflowStepFromTemplate, createWorkflowStepFromTemplate,
fetchScripts,
type WorkflowStepTemplate, type WorkflowStepTemplate,
} from "../api"; } from "../api";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
@@ -26,6 +27,8 @@ import {
Eye, Eye,
LayoutGrid, LayoutGrid,
BookOpen, BookOpen,
Terminal,
MessageSquare,
} from "lucide-react"; } from "lucide-react";
interface WorkflowStepManagerProps { interface WorkflowStepManagerProps {
@@ -38,7 +41,9 @@ interface WorkflowStepManagerProps {
interface StepFormData { interface StepFormData {
name: string; name: string;
description: string; description: string;
mode: WorkflowStepMode;
prompt: string; prompt: string;
scriptName: string;
enabled: boolean; enabled: boolean;
} }
@@ -47,7 +52,9 @@ type TabId = "my-steps" | "templates";
const EMPTY_FORM: StepFormData = { const EMPTY_FORM: StepFormData = {
name: "", name: "",
description: "", description: "",
mode: "prompt",
prompt: "", prompt: "",
scriptName: "",
enabled: true, enabled: true,
}; };
@@ -94,6 +101,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const [refining, setRefining] = useState(false); const [refining, setRefining] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [addingTemplateId, setAddingTemplateId] = useState<string | null>(null); const [addingTemplateId, setAddingTemplateId] = useState<string | null>(null);
const [availableScripts, setAvailableScripts] = useState<Record<string, string>>({});
const loadSteps = useCallback(async () => { const loadSteps = useCallback(async () => {
try { try {
@@ -107,6 +115,15 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
} }
}, [addToast, projectId]); }, [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 () => { const loadTemplates = useCallback(async () => {
try { try {
setTemplatesLoading(true); setTemplatesLoading(true);
@@ -123,8 +140,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
if (isOpen) { if (isOpen) {
loadSteps(); loadSteps();
loadTemplates(); loadTemplates();
loadScripts();
} }
}, [isOpen, loadSteps, loadTemplates]); }, [isOpen, loadSteps, loadTemplates, loadScripts]);
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
setIsCreating(true); setIsCreating(true);
@@ -138,7 +156,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
setForm({ setForm({
name: step.name, name: step.name,
description: step.description, description: step.description,
mode: step.mode || "prompt",
prompt: step.prompt, prompt: step.prompt,
scriptName: step.scriptName || "",
enabled: step.enabled, enabled: step.enabled,
}); });
}, []); }, []);
@@ -161,7 +181,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const input: WorkflowStepInput = { const input: WorkflowStepInput = {
name: form.name.trim(), name: form.name.trim(),
description: form.description.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, enabled: form.enabled,
}; };
await createWorkflowStep(input, projectId); await createWorkflowStep(input, projectId);
@@ -170,7 +192,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
await updateWorkflowStep(editingId, { await updateWorkflowStep(editingId, {
name: form.name.trim(), name: form.name.trim(),
description: form.description.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, enabled: form.enabled,
}, projectId); }, projectId);
addToast("Workflow step updated", "success"); addToast("Workflow step updated", "success");
@@ -204,6 +228,8 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const handleRefine = useCallback(async () => { const handleRefine = useCallback(async () => {
if (!editingId && !isCreating) return; 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 // For new steps being created, we need to save first then refine
if (isCreating) { if (isCreating) {
@@ -217,6 +243,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
const input: WorkflowStepInput = { const input: WorkflowStepInput = {
name: form.name.trim(), name: form.name.trim(),
description: form.description.trim(), description: form.description.trim(),
mode: "prompt",
prompt: form.prompt.trim() || undefined, prompt: form.prompt.trim() || undefined,
enabled: form.enabled, enabled: form.enabled,
}; };
@@ -407,6 +434,21 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
> >
{step.enabled ? "Enabled" : "Disabled"} {step.enabled ? "Enabled" : "Disabled"}
</span> </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>
<div <div
style={{ style={{
@@ -673,61 +715,169 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
/> />
</div> </div>
{/* Prompt */} {/* Mode Selector */}
<div> <div>
<div <label
style={{ style={{
display: "flex", display: "block",
justifyContent: "space-between", fontSize: "12px",
alignItems: "center", color: "var(--text-secondary)",
marginBottom: "4px", marginBottom: "4px",
}} }}
> >
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}> Execution Mode
Agent Prompt </label>
</label> <div
style={{ display: "flex", gap: "8px" }}
data-testid="workflow-step-mode-selector"
>
<button <button
className="btn-icon" className={`btn ${form.mode === "prompt" ? "btn-primary" : "btn-secondary"}`}
onClick={handleRefine} onClick={() => setForm((prev) => ({ ...prev, mode: "prompt", scriptName: "" }))}
disabled={!form.description.trim() || refining}
title="Refine with AI"
aria-label="Refine prompt with AI"
style={{ style={{
fontSize: "12px",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "4px", gap: "6px",
fontSize: "12px",
padding: "6px 12px",
flex: 1,
justifyContent: "center",
}} }}
data-testid="refine-btn" data-testid="mode-prompt"
> >
{refining ? ( <MessageSquare size={14} />
<Loader2 size={12} className="spin" /> AI Prompt
) : ( </button>
<Sparkles size={12} /> <button
)} className={`btn ${form.mode === "script" ? "btn-primary" : "btn-secondary"}`}
<span style={{ fontSize: "11px" }}>Refine with AI</span> 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> </button>
</div> </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> </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 */} {/* Enabled toggle */}
<label <label
style={{ style={{
@@ -762,7 +912,12 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={handleSave} 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" data-testid="save-workflow-step"
> >
{saving ? "Saving..." : isCreating ? "Create" : "Save"} {saving ? "Saving..." : isCreating ? "Create" : "Save"}

View File

@@ -8,6 +8,7 @@ const mockSteps: WorkflowStep[] = [
id: "WS-001", id: "WS-001",
name: "Documentation Review", name: "Documentation Review",
description: "Verify all public APIs have documentation", description: "Verify all public APIs have documentation",
mode: "prompt",
prompt: "Review the task changes and verify docs.", prompt: "Review the task changes and verify docs.",
enabled: true, enabled: true,
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
@@ -17,6 +18,7 @@ const mockSteps: WorkflowStep[] = [
id: "WS-002", id: "WS-002",
name: "QA Check", name: "QA Check",
description: "Run tests and verify they pass", description: "Run tests and verify they pass",
mode: "prompt",
prompt: "Execute the test suite.", prompt: "Execute the test suite.",
enabled: false, enabled: false,
createdAt: "2026-01-02T00:00:00.000Z", createdAt: "2026-01-02T00:00:00.000Z",
@@ -30,6 +32,7 @@ vi.mock("../../api", () => ({
id: "WS-003", id: "WS-003",
name: "New Step", name: "New Step",
description: "New description", description: "New description",
mode: "prompt",
prompt: "", prompt: "",
enabled: true, enabled: true,
createdAt: "2026-01-03T00:00:00.000Z", createdAt: "2026-01-03T00:00:00.000Z",
@@ -45,6 +48,7 @@ vi.mock("../../api", () => ({
prompt: "AI-generated detailed prompt", prompt: "AI-generated detailed prompt",
workflowStep: { ...mockSteps[0], 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 { import {
@@ -54,6 +58,7 @@ import {
deleteWorkflowStep, deleteWorkflowStep,
refineWorkflowStepPrompt, refineWorkflowStepPrompt,
} from "../../api"; } from "../../api";
import { fetchScripts } from "../../api";
const onClose = vi.fn(); const onClose = vi.fn();
const addToast = vi.fn(); const addToast = vi.fn();
@@ -133,7 +138,9 @@ describe("WorkflowStepManager", () => {
expect(createWorkflowStep).toHaveBeenCalledWith({ expect(createWorkflowStep).toHaveBeenCalledWith({
name: "New Step", name: "New Step",
description: "New description", description: "New description",
mode: "prompt",
prompt: undefined, prompt: undefined,
scriptName: undefined,
enabled: true, enabled: true,
}, undefined); }, undefined);
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success"); expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
@@ -241,4 +248,113 @@ describe("WorkflowStepManager", () => {
expect(screen.getByText("Disabled")).toBeInTheDocument(); 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 () => { 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); (store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({ const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
@@ -6259,7 +6259,9 @@ describe("POST /workflow-steps", () => {
expect(store.createWorkflowStep).toHaveBeenCalledWith({ expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Docs", name: "Docs",
description: "Check docs", description: "Check docs",
mode: "prompt",
prompt: undefined, prompt: undefined,
scriptName: undefined,
enabled: undefined, enabled: undefined,
}); });
}); });
@@ -6311,7 +6313,9 @@ describe("POST /workflow-steps", () => {
expect(store.createWorkflowStep).toHaveBeenCalledWith({ expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Security", name: "Security",
description: "Security audit", description: "Security audit",
mode: "prompt",
prompt: undefined, prompt: undefined,
scriptName: undefined,
enabled: undefined, enabled: undefined,
modelProvider: "anthropic", modelProvider: "anthropic",
modelId: "claude-sonnet-4-5", 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 () => { 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); (store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({ const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
@@ -6355,12 +6359,77 @@ describe("POST /workflow-steps", () => {
expect(store.createWorkflowStep).toHaveBeenCalledWith({ expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Docs", name: "Docs",
description: "Check docs", description: "Check docs",
mode: "prompt",
prompt: undefined, prompt: undefined,
scriptName: undefined,
enabled: undefined, enabled: undefined,
modelProvider: undefined, modelProvider: undefined,
modelId: 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", () => { 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 () => { it("returns 400 when workflow step has no description", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (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({}), { 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"); 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 () => { 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.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({}); (store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({});
const updatedWs = { ...ws, prompt: "Check docs" }; const updatedWs = { ...ws, prompt: "Check docs" };

View File

@@ -5804,13 +5804,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/** /**
* POST /api/workflow-steps * POST /api/workflow-steps
* Create a new workflow step. * 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 * Returns: WorkflowStep
*/ */
router.post("/workflow-steps", async (req, res) => { router.post("/workflow-steps", async (req, res) => {
try { try {
const scopedStore = await getScopedStore(req); 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()) { if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" }); 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" }); res.status(400).json({ error: "description is required" });
return; 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") { if (prompt !== undefined && typeof prompt !== "string") {
res.status(400).json({ error: "prompt must be a string" }); res.status(400).json({ error: "prompt must be a string" });
return; return;
} }
if (scriptName !== undefined && typeof scriptName !== "string") {
res.status(400).json({ error: "scriptName must be a string" });
return;
}
if (enabled !== undefined && typeof enabled !== "boolean") { if (enabled !== undefined && typeof enabled !== "boolean") {
res.status(400).json({ error: "enabled must be a boolean" }); res.status(400).json({ error: "enabled must be a boolean" });
return; 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"); const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model");
// Check for name conflicts // Check for name conflicts
@@ -5842,14 +5868,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const step = await scopedStore.createWorkflowStep({ const step = await scopedStore.createWorkflowStep({
name: name.trim(), name: name.trim(),
description: description.trim(), description: description.trim(),
mode: resolvedMode,
prompt: prompt?.trim(), prompt: prompt?.trim(),
scriptName: scriptName?.trim(),
enabled, enabled,
modelProvider: modelPair.provider, modelProvider: modelPair.provider,
modelId: modelPair.modelId, modelId: modelPair.modelId,
}); });
res.status(201).json(step); res.status(201).json(step);
} catch (err: any) { } 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 }); res.status(status).json({ error: err.message });
} }
}); });
@@ -5857,13 +5885,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/** /**
* PATCH /api/workflow-steps/:id * PATCH /api/workflow-steps/:id
* Update a workflow step. * Update a workflow step.
* Body: Partial<{ name, description, prompt, enabled }> * Body: Partial<{ name, description, mode, prompt, scriptName, enabled, modelProvider, modelId }>
* Returns: WorkflowStep * Returns: WorkflowStep
*/ */
router.patch("/workflow-steps/:id", async (req, res) => { router.patch("/workflow-steps/:id", async (req, res) => {
try { try {
const scopedStore = await getScopedStore(req); 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> = {}; const updates: Record<string, unknown> = {};
if (name !== undefined) { if (name !== undefined) {
@@ -5880,6 +5908,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
updates.description = description.trim(); 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 (prompt !== undefined) {
if (typeof prompt !== "string") { if (typeof prompt !== "string") {
res.status(400).json({ error: "prompt must be a 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; 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 (enabled !== undefined) {
if (typeof enabled !== "boolean") { if (typeof enabled !== "boolean") {
res.status(400).json({ error: "enabled must be a 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; 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 // Validate and apply model override pair
if (modelProvider !== undefined || modelId !== undefined) { if (modelProvider !== undefined || modelId !== undefined) {
const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model"); 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")) { if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message }); res.status(404).json({ error: err.message });
} else { } 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 }); 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 * POST /api/workflow-steps/:id/refine
* Use AI to refine the workflow step's description into a detailed agent prompt. * 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 } * Returns: { prompt: string, workflowStep: WorkflowStep }
*/ */
router.post("/workflow-steps/:id/refine", async (req, res) => { router.post("/workflow-steps/:id/refine", async (req, res) => {
@@ -5947,6 +6005,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return; return;
} }
if (step.mode === "script") {
res.status(400).json({ error: "Cannot refine prompt for script-mode workflow steps" });
return;
}
if (!step.description?.trim()) { if (!step.description?.trim()) {
res.status(400).json({ error: "Workflow step has no description to refine" }); res.status(400).json({ error: "Workflow step has no description to refine" });
return; return;