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