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 caea21db12
commit f770e2b264
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 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 type { AgentStoreEvents } from "./agent-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.name).toBe("Documentation Review");
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.scriptName).toBeUndefined();
expect(ws.enabled).toBe(true);
expect(ws.createdAt).toBeDefined();
expect(ws.updatedAt).toBeDefined();
@@ -4326,10 +4328,50 @@ Task with acceptance criteria
expect(ws.id).toBe("WS-001");
expect(ws.name).toBe("QA Check");
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.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 () => {
const ws1 = await store.createWorkflowStep({ name: "Step 1", description: "First" });
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.description).toBe("Updated desc");
expect(updated.mode).toBe("prompt");
expect(updated.prompt).toBe("Updated prompt");
expect(updated.enabled).toBe(false);
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 () => {
await expect(
store.updateWorkflowStep("WS-999", { name: "Nope" })
@@ -4525,6 +4653,43 @@ Task with acceptance criteria
expect(found!.modelProvider).toBe("anthropic");
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 ────────────────────────────────────────────

View File

@@ -583,10 +583,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (!row) {
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 {
nextId: row.nextId || 1,
settings: fromJson<Settings>(row.settings),
workflowSteps: fromJson<import("./types.js").WorkflowStep[]>(row.workflowSteps),
workflowSteps,
nextWorkflowStepId: row.nextWorkflowStepId || 1,
};
}
@@ -2522,15 +2531,24 @@ ${stepsSection}`;
const nextWsId = config.nextWorkflowStepId || 1;
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 step: import("./types.js").WorkflowStep = {
id,
name: input.name,
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,
modelProvider: input.modelProvider,
modelId: input.modelId,
modelProvider: mode === "prompt" ? input.modelProvider : undefined,
modelId: mode === "prompt" ? input.modelId : undefined,
createdAt: now,
updatedAt: now,
};
@@ -2577,12 +2595,36 @@ ${stepsSection}`;
}
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.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 ("modelProvider" in updates) step.modelProvider = updates.modelProvider;
if ("modelId" in updates) step.modelId = updates.modelId;
if (step.mode === "prompt") {
if ("modelProvider" in updates) step.modelProvider = updates.modelProvider;
if ("modelId" in updates) step.modelId = updates.modelId;
}
step.updatedAt = new Date().toISOString();
config.workflowSteps = steps;

View File

@@ -44,6 +44,9 @@ export interface ModelPreset {
}
/** 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 {
/** Unique identifier (e.g., "WS-001") */
id: string;
@@ -51,17 +54,21 @@ export interface WorkflowStep {
name: string;
/** Short description for UI display */
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;
/** 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 */
enabled: boolean;
/** AI model provider override for the workflow step agent (e.g., "anthropic").
* 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;
/** 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,
* the executor uses global settings defaults. */
* the executor uses global settings defaults. Only used when mode is "prompt". */
modelId?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
@@ -76,13 +83,18 @@ export type NtfyNotificationEvent = "in-review" | "merged" | "failed";
export interface WorkflowStepInput {
name: 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;
/** 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 */
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;
/** 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;
}