feat(KB-218): add workflow steps for post-implementation review
- Add core data model for workflow step definitions with AI-assisted prompt refinement - Create API routes for CRUD operations and prompt refinement via /api/workflow-steps - Add WorkflowStepManager dashboard UI for defining and managing workflow steps - Integrate workflow step selection into NewTaskModal for per-task enablement - Execute workflow steps sequentially in executor after task_done() with readonly tools - Run workflow step agents before moving tasks to in-review, failing on step errors - Add comprehensive tests for store, API routes, components, and executor integration
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 } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { GlobalSettingsStore } from "./global-settings.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
|
||||
@@ -3087,4 +3087,159 @@ describe("TaskStore", () => {
|
||||
expect(taskFailedLogs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Workflow Steps ─────────────────────────────────────────────────
|
||||
|
||||
describe("Workflow Steps", () => {
|
||||
it("should create a workflow step with all fields", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Documentation Review",
|
||||
description: "Verify all public APIs have documentation",
|
||||
prompt: "Review the task changes and verify that all new public functions have docs.",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(ws.id).toBe("WS-001");
|
||||
expect(ws.name).toBe("Documentation Review");
|
||||
expect(ws.description).toBe("Verify all public APIs have documentation");
|
||||
expect(ws.prompt).toBe("Review the task changes and verify that all new public functions have docs.");
|
||||
expect(ws.enabled).toBe(true);
|
||||
expect(ws.createdAt).toBeDefined();
|
||||
expect(ws.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a workflow step with minimal fields", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "QA Check",
|
||||
description: "Run tests and verify they pass",
|
||||
});
|
||||
|
||||
expect(ws.id).toBe("WS-001");
|
||||
expect(ws.name).toBe("QA Check");
|
||||
expect(ws.description).toBe("Run tests and verify they pass");
|
||||
expect(ws.prompt).toBe(""); // Empty when not provided
|
||||
expect(ws.enabled).toBe(true); // Default enabled
|
||||
});
|
||||
|
||||
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" });
|
||||
const ws3 = await store.createWorkflowStep({ name: "Step 3", description: "Third" });
|
||||
|
||||
expect(ws1.id).toBe("WS-001");
|
||||
expect(ws2.id).toBe("WS-002");
|
||||
expect(ws3.id).toBe("WS-003");
|
||||
});
|
||||
|
||||
it("should list workflow steps", async () => {
|
||||
await store.createWorkflowStep({ name: "Step 1", description: "First" });
|
||||
await store.createWorkflowStep({ name: "Step 2", description: "Second" });
|
||||
|
||||
const steps = await store.listWorkflowSteps();
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].name).toBe("Step 1");
|
||||
expect(steps[1].name).toBe("Step 2");
|
||||
});
|
||||
|
||||
it("should return empty array when no workflow steps exist", async () => {
|
||||
const steps = await store.listWorkflowSteps();
|
||||
expect(steps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should get a single workflow step by ID", async () => {
|
||||
const ws = await store.createWorkflowStep({ name: "Docs", description: "Check docs" });
|
||||
const found = await store.getWorkflowStep(ws.id);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(ws.id);
|
||||
expect(found!.name).toBe("Docs");
|
||||
});
|
||||
|
||||
it("should return undefined for non-existent workflow step", async () => {
|
||||
const found = await store.getWorkflowStep("WS-999");
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should update a workflow step", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Original",
|
||||
description: "Original desc",
|
||||
prompt: "Original prompt",
|
||||
});
|
||||
|
||||
const updated = await store.updateWorkflowStep(ws.id, {
|
||||
name: "Updated",
|
||||
description: "Updated desc",
|
||||
prompt: "Updated prompt",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(updated.name).toBe("Updated");
|
||||
expect(updated.description).toBe("Updated desc");
|
||||
expect(updated.prompt).toBe("Updated prompt");
|
||||
expect(updated.enabled).toBe(false);
|
||||
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(ws.updatedAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when updating non-existent workflow step", async () => {
|
||||
await expect(
|
||||
store.updateWorkflowStep("WS-999", { name: "Nope" })
|
||||
).rejects.toThrow("Workflow step 'WS-999' not found");
|
||||
});
|
||||
|
||||
it("should delete a workflow step", async () => {
|
||||
const ws = await store.createWorkflowStep({ name: "ToDelete", description: "Gone" });
|
||||
await store.deleteWorkflowStep(ws.id);
|
||||
|
||||
const steps = await store.listWorkflowSteps();
|
||||
expect(steps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should throw when deleting non-existent workflow step", async () => {
|
||||
await expect(store.deleteWorkflowStep("WS-999")).rejects.toThrow(
|
||||
"Workflow step 'WS-999' not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove references from tasks when deleting a workflow step", async () => {
|
||||
const ws = await store.createWorkflowStep({ name: "Docs", description: "Check docs" });
|
||||
const task = await store.createTask({
|
||||
description: "Test task with workflow steps",
|
||||
enabledWorkflowSteps: [ws.id],
|
||||
});
|
||||
|
||||
expect(task.enabledWorkflowSteps).toEqual([ws.id]);
|
||||
|
||||
await store.deleteWorkflowStep(ws.id);
|
||||
|
||||
// Wait for async cleanup
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const updatedTask = await store.getTask(task.id);
|
||||
expect(updatedTask.enabledWorkflowSteps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create a task with enabledWorkflowSteps", async () => {
|
||||
const ws1 = await store.createWorkflowStep({ name: "Docs", description: "Check docs" });
|
||||
const ws2 = await store.createWorkflowStep({ name: "QA", description: "Run tests" });
|
||||
|
||||
const task = await store.createTask({
|
||||
description: "Task with workflow steps",
|
||||
enabledWorkflowSteps: [ws1.id, ws2.id],
|
||||
});
|
||||
|
||||
expect(task.enabledWorkflowSteps).toEqual([ws1.id, ws2.id]);
|
||||
});
|
||||
|
||||
it("should not set enabledWorkflowSteps when empty array provided", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "Task without workflow steps",
|
||||
enabledWorkflowSteps: [],
|
||||
});
|
||||
|
||||
expect(task.enabledWorkflowSteps).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -389,6 +389,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
column: input.column || "triage",
|
||||
dependencies: input.dependencies || [],
|
||||
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
|
||||
enabledWorkflowSteps: input.enabledWorkflowSteps?.length ? input.enabledWorkflowSteps : undefined,
|
||||
modelPresetId: input.modelPresetId,
|
||||
modelProvider: input.modelProvider,
|
||||
modelId: input.modelId,
|
||||
@@ -1948,6 +1949,125 @@ ${deps}
|
||||
${stepsSection}`;
|
||||
}
|
||||
|
||||
// ── Workflow Step CRUD Methods ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new workflow step definition.
|
||||
* Generates a unique ID (WS-001, WS-002, etc.) and stores in config.json.
|
||||
*/
|
||||
async createWorkflowStep(input: import("./types.js").WorkflowStepInput): Promise<import("./types.js").WorkflowStep> {
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const nextWsId = config.nextWorkflowStepId || 1;
|
||||
const id = `WS-${String(nextWsId).padStart(3, "0")}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const step: import("./types.js").WorkflowStep = {
|
||||
id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
prompt: input.prompt || "",
|
||||
enabled: input.enabled !== undefined ? input.enabled : true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (!config.workflowSteps) {
|
||||
config.workflowSteps = [];
|
||||
}
|
||||
config.workflowSteps.push(step);
|
||||
config.nextWorkflowStepId = nextWsId + 1;
|
||||
await this.writeConfig(config);
|
||||
|
||||
return step;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all workflow step definitions from config.json.
|
||||
*/
|
||||
async listWorkflowSteps(): Promise<import("./types.js").WorkflowStep[]> {
|
||||
const config = await this.readConfig();
|
||||
return config.workflowSteps || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single workflow step by ID.
|
||||
*/
|
||||
async getWorkflowStep(id: string): Promise<import("./types.js").WorkflowStep | undefined> {
|
||||
const config = await this.readConfig();
|
||||
return (config.workflowSteps || []).find((ws) => ws.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a workflow step definition.
|
||||
* @throws Error if the workflow step is not found
|
||||
*/
|
||||
async updateWorkflowStep(id: string, updates: Partial<import("./types.js").WorkflowStepInput>): Promise<import("./types.js").WorkflowStep> {
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const steps = config.workflowSteps || [];
|
||||
const index = steps.findIndex((ws) => ws.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Workflow step '${id}' not found`);
|
||||
}
|
||||
|
||||
const step = steps[index];
|
||||
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.enabled !== undefined) step.enabled = updates.enabled;
|
||||
step.updatedAt = new Date().toISOString();
|
||||
|
||||
config.workflowSteps = steps;
|
||||
await this.writeConfig(config);
|
||||
|
||||
return step;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a workflow step definition.
|
||||
* Also removes the ID from any tasks that reference it in enabledWorkflowSteps.
|
||||
* @throws Error if the workflow step is not found
|
||||
*/
|
||||
async deleteWorkflowStep(id: string): Promise<void> {
|
||||
await this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const steps = config.workflowSteps || [];
|
||||
const index = steps.findIndex((ws) => ws.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Workflow step '${id}' not found`);
|
||||
}
|
||||
|
||||
steps.splice(index, 1);
|
||||
config.workflowSteps = steps;
|
||||
await this.writeConfig(config);
|
||||
});
|
||||
|
||||
// Clean up references from existing tasks (best-effort, outside config lock)
|
||||
try {
|
||||
const tasks = await this.listTasks();
|
||||
for (const task of tasks) {
|
||||
if (task.enabledWorkflowSteps?.includes(id)) {
|
||||
const updated = task.enabledWorkflowSteps.filter((wsId) => wsId !== id);
|
||||
// Direct task.json mutation for enabledWorkflowSteps cleanup
|
||||
await this.withTaskLock(task.id, async () => {
|
||||
const dir = this.taskDir(task.id);
|
||||
const t = await this.readTaskJson(dir);
|
||||
t.enabledWorkflowSteps = updated.length > 0 ? updated : undefined;
|
||||
t.updatedAt = new Date().toISOString();
|
||||
await this.atomicWriteTaskJson(dir, t);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: task cleanup is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
getRootDir(): string {
|
||||
return this.rootDir;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,34 @@ export interface ModelPreset {
|
||||
validatorModelId?: string;
|
||||
}
|
||||
|
||||
/** A reusable workflow step definition that can run after task implementation. */
|
||||
export interface WorkflowStep {
|
||||
/** Unique identifier (e.g., "WS-001") */
|
||||
id: string;
|
||||
/** Display name (e.g., "Documentation Review") */
|
||||
name: string;
|
||||
/** Short description for UI display */
|
||||
description: string;
|
||||
/** Full agent prompt to execute when this step runs */
|
||||
prompt: string;
|
||||
/** Whether this step is available for selection on new tasks */
|
||||
enabled: boolean;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new workflow step. */
|
||||
export interface WorkflowStepInput {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Optional — can be AI-generated later via refinement */
|
||||
prompt?: string;
|
||||
/** Defaults to true if not specified */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PrInfo {
|
||||
url: string;
|
||||
number: number;
|
||||
@@ -193,6 +221,8 @@ export interface Task {
|
||||
* Must be set together with `validatorModelProvider`. When both validator model
|
||||
* fields are undefined, the reviewer uses global settings defaults. */
|
||||
validatorModelId?: string;
|
||||
/** IDs of workflow steps enabled for this task, run after implementation completes */
|
||||
enabledWorkflowSteps?: string[];
|
||||
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
|
||||
mergeRetries?: number;
|
||||
/** Error message from the last failure, if the task failed during execution */
|
||||
@@ -216,6 +246,8 @@ export interface TaskCreateInput {
|
||||
column?: Column;
|
||||
dependencies?: string[];
|
||||
breakIntoSubtasks?: boolean;
|
||||
/** IDs of workflow steps to enable for this task */
|
||||
enabledWorkflowSteps?: string[];
|
||||
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
|
||||
modelPresetId?: string;
|
||||
/** AI model provider override for the executor agent (e.g., "anthropic").
|
||||
@@ -505,6 +537,10 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
export interface BoardConfig {
|
||||
nextId: number;
|
||||
settings?: Settings;
|
||||
/** Global workflow step definitions */
|
||||
workflowSteps?: WorkflowStep[];
|
||||
/** Auto-incrementing counter for workflow step IDs */
|
||||
nextWorkflowStepId?: number;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UsageIndicator } from "./components/UsageIndicator";
|
||||
import { NewTaskModal } from "./components/NewTaskModal";
|
||||
import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
|
||||
import { ActivityLogModal } from "./components/ActivityLogModal";
|
||||
import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
@@ -36,6 +37,7 @@ function AppInner() {
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [rootDir, setRootDir] = useState<string>(".");
|
||||
@@ -240,6 +242,7 @@ function AppInner() {
|
||||
onOpenActivityLog={handleOpenActivityLog}
|
||||
onOpenSchedules={handleOpenSchedules}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
filesOpen={filesOpen}
|
||||
@@ -378,6 +381,11 @@ function AppInner() {
|
||||
tasks={tasks}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<WorkflowStepManager
|
||||
isOpen={workflowStepsOpen}
|
||||
onClose={() => setWorkflowStepsOpen(false)}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
BatchStatusEntry,
|
||||
ActivityLogEntry,
|
||||
ActivityEventType,
|
||||
WorkflowStep,
|
||||
WorkflowStepInput,
|
||||
} from "@kb/core";
|
||||
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@kb/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "@kb/core";
|
||||
@@ -101,6 +103,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
modelId,
|
||||
@@ -116,6 +119,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
modelId,
|
||||
@@ -1141,3 +1145,38 @@ export function fetchActivityLog(options?: { limit?: number; since?: string; typ
|
||||
export function clearActivityLog(): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>("/activity", { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ── Workflow Steps ─────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch all workflow step definitions */
|
||||
export function fetchWorkflowSteps(): Promise<WorkflowStep[]> {
|
||||
return api<WorkflowStep[]>("/workflow-steps");
|
||||
}
|
||||
|
||||
/** Create a new workflow step */
|
||||
export function createWorkflowStep(input: WorkflowStepInput): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>("/workflow-steps", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a workflow step */
|
||||
export function updateWorkflowStep(id: string, updates: Partial<WorkflowStepInput>): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(`/workflow-steps/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a workflow step */
|
||||
export function deleteWorkflowStep(id: string): Promise<void> {
|
||||
return api<void>(`/workflow-steps/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Refine a workflow step's prompt using AI */
|
||||
export function refineWorkflowStepPrompt(id: string): Promise<{ prompt: string; workflowStep: WorkflowStep }> {
|
||||
return api<{ prompt: string; workflowStep: WorkflowStep }>(`/workflow-steps/${id}/refine`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow } from "lucide-react";
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -24,6 +24,7 @@ interface HeaderProps {
|
||||
onOpenActivityLog?: () => void;
|
||||
onOpenSchedules?: () => void;
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
/** Opens the top-level workspace-aware file browser modal. */
|
||||
onOpenFiles?: () => void;
|
||||
@@ -63,6 +64,7 @@ export function Header({
|
||||
onOpenActivityLog,
|
||||
onOpenSchedules,
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
onToggleTerminal,
|
||||
onOpenFiles,
|
||||
filesOpen,
|
||||
@@ -351,6 +353,18 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Workflow Steps - desktop only */}
|
||||
{!isMobile && onOpenWorkflowSteps && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenWorkflowSteps}
|
||||
title="Workflow Steps"
|
||||
data-testid="workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings - always inline on desktop */}
|
||||
{!isMobile && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
@@ -426,6 +440,17 @@ export function Header({
|
||||
<Clock size={16} />
|
||||
<span>Scheduled Tasks</span>
|
||||
</button>
|
||||
{onOpenWorkflowSteps && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenWorkflowSteps)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
<span>Workflow Steps</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSettings)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings } from "@kb/core";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, fetchSettings } from "../api";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
@@ -317,6 +317,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [enablePlanningMode, setEnablePlanningMode] = useState(false);
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -333,6 +335,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
fetchSettings()
|
||||
.then((nextSettings) => setSettings(nextSettings))
|
||||
.catch(() => setSettings(null));
|
||||
fetchWorkflowSteps()
|
||||
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
|
||||
.catch(() => setWorkflowSteps([]));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -344,9 +349,10 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
pendingImages.length > 0 ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
enablePlanningMode;
|
||||
enablePlanningMode ||
|
||||
selectedWorkflowSteps.length > 0;
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode, selectedWorkflowSteps]);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
@@ -455,6 +461,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -479,6 +486,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
|
||||
// Close modal and trigger planning mode
|
||||
onClose();
|
||||
@@ -500,6 +508,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
description: trimmedDesc,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
enabledWorkflowSteps: selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : undefined,
|
||||
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
|
||||
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
|
||||
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
|
||||
@@ -532,6 +541,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
@@ -760,6 +770,46 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
{workflowSteps.length > 0 && (
|
||||
<div className="form-group" data-testid="workflow-steps-section">
|
||||
<label>Workflow Steps</label>
|
||||
<small style={{ marginBottom: "8px", display: "block" }}>
|
||||
Select steps to run after task implementation completes
|
||||
</small>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{workflowSteps.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
|
||||
data-testid={`workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedWorkflowSteps((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, step.id]
|
||||
: prev.filter((id) => id !== step.id)
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{ marginTop: "2px" }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "13px" }}>{step.name}</span>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Planning Mode Toggle */}
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
|
||||
468
packages/dashboard/app/components/WorkflowStepManager.tsx
Normal file
468
packages/dashboard/app/components/WorkflowStepManager.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { WorkflowStep, WorkflowStepInput } from "@kb/core";
|
||||
import { fetchWorkflowSteps, createWorkflowStep, updateWorkflowStep, deleteWorkflowStep, refineWorkflowStepPrompt } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { X, Plus, Pencil, Trash2, Sparkles, Check, Loader2 } from "lucide-react";
|
||||
|
||||
interface WorkflowStepManagerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
interface StepFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: StepFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepManagerProps) {
|
||||
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [form, setForm] = useState<StepFormData>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [refining, setRefining] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchWorkflowSteps();
|
||||
setSteps(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load workflow steps", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSteps();
|
||||
}
|
||||
}, [isOpen, loadSteps]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setIsCreating(true);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((step: WorkflowStep) => {
|
||||
setEditingId(step.id);
|
||||
setIsCreating(false);
|
||||
setForm({
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
prompt: step.prompt,
|
||||
enabled: step.enabled,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(false);
|
||||
setForm(EMPTY_FORM);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim() || !form.description.trim()) {
|
||||
addToast("Name and description are required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isCreating) {
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
await createWorkflowStep(input);
|
||||
addToast("Workflow step created", "success");
|
||||
} else if (editingId) {
|
||||
await updateWorkflowStep(editingId, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt,
|
||||
enabled: form.enabled,
|
||||
});
|
||||
addToast("Workflow step updated", "success");
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to save workflow step", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, isCreating, editingId, addToast, loadSteps]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await deleteWorkflowStep(id);
|
||||
addToast("Workflow step deleted", "success");
|
||||
setDeleteConfirmId(null);
|
||||
if (editingId === id) {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete workflow step", "error");
|
||||
}
|
||||
}, [editingId, addToast, loadSteps]);
|
||||
|
||||
const handleRefine = useCallback(async () => {
|
||||
if (!editingId && !isCreating) return;
|
||||
|
||||
// For new steps being created, we need to save first then refine
|
||||
if (isCreating) {
|
||||
if (!form.name.trim() || !form.description.trim()) {
|
||||
addToast("Name and description are required before refining", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
const created = await createWorkflowStep(input);
|
||||
setIsCreating(false);
|
||||
setEditingId(created.id);
|
||||
|
||||
// Now refine
|
||||
setRefining(true);
|
||||
const result = await refineWorkflowStepPrompt(created.id);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast("Prompt refined with AI", "success");
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to refine prompt", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setRefining(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingId) return;
|
||||
|
||||
setRefining(true);
|
||||
try {
|
||||
const result = await refineWorkflowStepPrompt(editingId);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast("Prompt refined with AI", "success");
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to refine prompt", "error");
|
||||
} finally {
|
||||
setRefining(false);
|
||||
}
|
||||
}, [editingId, isCreating, form, addToast, loadSteps]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isEditing = isCreating || editingId !== null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} data-testid="workflow-step-manager">
|
||||
<div
|
||||
className="modal workflow-step-manager-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Workflow Steps"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2>Workflow Steps</h2>
|
||||
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ padding: "16px", maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "32px", color: "var(--text-secondary)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step list */}
|
||||
{steps.length === 0 && !isEditing && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
data-testid="empty-state"
|
||||
>
|
||||
No workflow steps defined. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && !isEditing && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="workflow-step-card"
|
||||
data-testid={`workflow-step-${step.id}`}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "14px" }}>{step.name}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background: step.enabled ? "var(--status-success-bg, rgba(34, 197, 94, 0.15))" : "var(--bg-tertiary)",
|
||||
color: step.enabled ? "var(--status-success, #22c55e)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{step.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "4px", marginLeft: "8px", flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(step)}
|
||||
title="Edit"
|
||||
aria-label={`Edit ${step.name}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{deleteConfirmId === step.id ? (
|
||||
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleDelete(step.id)}
|
||||
title="Confirm delete"
|
||||
aria-label={`Confirm delete ${step.name}`}
|
||||
style={{ color: "var(--status-error, #ef4444)" }}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
title="Cancel delete"
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(step.id)}
|
||||
title="Delete"
|
||||
aria-label={`Delete ${step.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit / Create form */}
|
||||
{isEditing && (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
data-testid="workflow-step-form"
|
||||
>
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: "14px", fontWeight: 600 }}>
|
||||
{isCreating ? "New Workflow Step" : "Edit Workflow Step"}
|
||||
</h3>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Documentation Review"
|
||||
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-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Brief description of what this step does"
|
||||
rows={2}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
resize: "vertical",
|
||||
}}
|
||||
data-testid="workflow-step-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
data-testid="workflow-step-enabled"
|
||||
/>
|
||||
Enabled (available for selection on new tasks)
|
||||
</label>
|
||||
|
||||
{/* Form actions */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "4px" }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.description.trim()}
|
||||
data-testid="save-workflow-step"
|
||||
>
|
||||
{saving ? "Saving..." : isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isEditing && !loading && (
|
||||
<div className="modal-footer" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
data-testid="add-workflow-step"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Workflow Step
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ vi.mock("../../api", () => ({
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -457,4 +458,83 @@ describe("NewTaskModal", () => {
|
||||
// The overflow-y: auto is applied via CSS in styles.css
|
||||
expect(modal?.contains(modalBody)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows workflow step checkboxes when steps are available", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
{ id: "WS-002", name: "QA Check", description: "Run tests", prompt: "Run tests", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
]);
|
||||
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-steps-section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Docs Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("QA Check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show workflow steps section when no steps are available", async () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("workflow-steps-section")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles workflow step selection", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
]);
|
||||
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("passes selected workflow steps to onCreateTask", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check the workflow step
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Fill in description
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(textarea, { target: { value: "Test task" } });
|
||||
|
||||
// Submit
|
||||
const submitBtn = screen.getByText("Create Task");
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { WorkflowStepManager } from "../WorkflowStepManager";
|
||||
import type { WorkflowStep } from "@kb/core";
|
||||
|
||||
const mockSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: "WS-001",
|
||||
name: "Documentation Review",
|
||||
description: "Verify all public APIs have documentation",
|
||||
prompt: "Review the task changes and verify docs.",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "WS-002",
|
||||
name: "QA Check",
|
||||
description: "Run tests and verify they pass",
|
||||
prompt: "Execute the test suite.",
|
||||
enabled: false,
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn(() => Promise.resolve([])),
|
||||
createWorkflowStep: vi.fn(() => Promise.resolve({
|
||||
id: "WS-003",
|
||||
name: "New Step",
|
||||
description: "New description",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-03T00:00:00.000Z",
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
})),
|
||||
updateWorkflowStep: vi.fn((id: string, updates: Record<string, unknown>) => Promise.resolve({
|
||||
...mockSteps.find((s) => s.id === id),
|
||||
...updates,
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
})),
|
||||
deleteWorkflowStep: vi.fn(() => Promise.resolve()),
|
||||
refineWorkflowStepPrompt: vi.fn(() => Promise.resolve({
|
||||
prompt: "AI-generated detailed prompt",
|
||||
workflowStep: { ...mockSteps[0], prompt: "AI-generated detailed prompt" },
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchWorkflowSteps,
|
||||
createWorkflowStep,
|
||||
updateWorkflowStep,
|
||||
deleteWorkflowStep,
|
||||
refineWorkflowStepPrompt,
|
||||
} from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowStepManager", () => {
|
||||
it("does not render when closed", () => {
|
||||
const { container } = render(
|
||||
<WorkflowStepManager isOpen={false} onClose={onClose} addToast={addToast} />
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders list of workflow steps", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("QA Check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no steps exist", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No workflow steps defined/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens create form when Add button is clicked", 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-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-step-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-step-description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits new workflow step", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce([])
|
||||
.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: "New Step" } });
|
||||
fireEvent.change(descInput, { target: { value: "New description" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("save-workflow-step"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWorkflowStep).toHaveBeenCalledWith({
|
||||
name: "New Step",
|
||||
description: "New description",
|
||||
prompt: undefined,
|
||||
enabled: true,
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("edits existing workflow step", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce(mockSteps)
|
||||
.mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click edit button for the first step
|
||||
const editBtn = screen.getByLabelText("Edit Documentation Review");
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
// Form should be pre-populated
|
||||
expect(screen.getByTestId("workflow-step-form")).toBeInTheDocument();
|
||||
const nameInput = screen.getByTestId("workflow-step-name") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("Documentation Review");
|
||||
|
||||
// Change the name
|
||||
fireEvent.change(nameInput, { target: { value: "Updated Name" } });
|
||||
fireEvent.click(screen.getByTestId("save-workflow-step"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
|
||||
name: "Updated Name",
|
||||
}));
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step updated", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes workflow step with confirmation", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce(mockSteps)
|
||||
.mockResolvedValueOnce([mockSteps[1]]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete (first shows confirm dialog)
|
||||
const deleteBtn = screen.getByLabelText("Delete Documentation Review");
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByLabelText("Confirm delete Documentation Review");
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteWorkflowStep).toHaveBeenCalledWith("WS-001");
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step deleted", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls refine API and updates prompt", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce(mockSteps)
|
||||
.mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Edit first step
|
||||
fireEvent.click(screen.getByLabelText("Edit Documentation Review"));
|
||||
|
||||
// Click refine button
|
||||
const refineBtn = screen.getByTestId("refine-btn");
|
||||
fireEvent.click(refineBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineWorkflowStepPrompt).toHaveBeenCalledWith("WS-001");
|
||||
expect(addToast).toHaveBeenCalledWith("Prompt refined with AI", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows enabled/disabled badges", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Enabled")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,11 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -4683,3 +4688,231 @@ describe("GET /settings/scopes", () => {
|
||||
expect(res.body.error).toContain("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Workflow Step Routes ─────────────────────────────────────────────
|
||||
|
||||
describe("GET /workflow-steps", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns empty array when no workflow steps exist", async () => {
|
||||
const res = await GET(buildApp(), "/api/workflow-steps");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns workflow steps", async () => {
|
||||
const steps = [
|
||||
{ id: "WS-001", name: "Docs", description: "Check docs", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
];
|
||||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce(steps);
|
||||
|
||||
const res = await GET(buildApp(), "/api/workflow-steps");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(steps);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /workflow-steps", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
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" };
|
||||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||||
name: "Docs",
|
||||
description: "Check docs",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBe("WS-001");
|
||||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||||
name: "Docs",
|
||||
description: "Check docs",
|
||||
prompt: undefined,
|
||||
enabled: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 when name is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||||
description: "Check docs",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("name");
|
||||
});
|
||||
|
||||
it("returns 400 when description is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||||
name: "Docs",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("description");
|
||||
});
|
||||
|
||||
it("returns 409 when name already exists", async () => {
|
||||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Docs", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
]);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
|
||||
name: "Docs",
|
||||
description: "Another docs step",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /workflow-steps/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("updates a workflow step", async () => {
|
||||
const updated = { id: "WS-001", name: "Updated", description: "Updated desc", prompt: "Updated prompt", enabled: false, createdAt: "2026-01-01", updatedAt: "2026-01-02" };
|
||||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
|
||||
name: "Updated",
|
||||
enabled: false,
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe("Updated");
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent step", async () => {
|
||||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-999", JSON.stringify({
|
||||
name: "Nope",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /workflow-steps/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("deletes a workflow step", async () => {
|
||||
(store.deleteWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/workflow-steps/WS-001", undefined, {});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent step", async () => {
|
||||
(store.deleteWorkflowStep as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found"));
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/workflow-steps/WS-999", undefined, {});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /workflow-steps/:id/refine", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns 404 when workflow step not found", async () => {
|
||||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-999/refine", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns 400 when workflow step has no description", async () => {
|
||||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: "WS-001", name: "Empty", description: " ", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
|
||||
});
|
||||
|
||||
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("no description");
|
||||
});
|
||||
|
||||
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" };
|
||||
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({});
|
||||
const updatedWs = { ...ws, prompt: "Check docs" };
|
||||
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
// AI import will fail in test env, falling back to description
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.prompt).toBeDefined();
|
||||
expect(res.body.workflowStep).toBeDefined();
|
||||
expect(store.updateWorkflowStep).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
modelId,
|
||||
@@ -1106,12 +1107,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
|
||||
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
|
||||
|
||||
// Validate enabledWorkflowSteps if provided
|
||||
if (enabledWorkflowSteps !== undefined) {
|
||||
if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) {
|
||||
res.status(400).json({ error: "enabledWorkflowSteps must be an array of strings" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const task = await store.createTask({
|
||||
title,
|
||||
description,
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
||||
modelProvider: executorModel.provider,
|
||||
modelId: executorModel.modelId,
|
||||
@@ -4092,6 +4102,211 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Workflow Step Routes ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/workflow-steps
|
||||
* List all workflow step definitions.
|
||||
* Returns: WorkflowStep[]
|
||||
*/
|
||||
router.get("/workflow-steps", async (_req, res) => {
|
||||
try {
|
||||
const steps = await store.listWorkflowSteps();
|
||||
res.json(steps);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/workflow-steps
|
||||
* Create a new workflow step.
|
||||
* Body: { name: string, description: string, prompt?: string, enabled?: boolean }
|
||||
* Returns: WorkflowStep
|
||||
*/
|
||||
router.post("/workflow-steps", async (req, res) => {
|
||||
try {
|
||||
const { name, description, prompt, enabled } = req.body;
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
}
|
||||
if (!description || typeof description !== "string" || !description.trim()) {
|
||||
res.status(400).json({ error: "description is required" });
|
||||
return;
|
||||
}
|
||||
if (prompt !== undefined && typeof prompt !== "string") {
|
||||
res.status(400).json({ error: "prompt must be a string" });
|
||||
return;
|
||||
}
|
||||
if (enabled !== undefined && typeof enabled !== "boolean") {
|
||||
res.status(400).json({ error: "enabled must be a boolean" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for name conflicts
|
||||
const existing = await store.listWorkflowSteps();
|
||||
if (existing.some((ws) => ws.name.toLowerCase() === name.trim().toLowerCase())) {
|
||||
res.status(409).json({ error: `A workflow step named '${name.trim()}' already exists` });
|
||||
return;
|
||||
}
|
||||
|
||||
const step = await store.createWorkflowStep({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
prompt: prompt?.trim(),
|
||||
enabled,
|
||||
});
|
||||
res.status(201).json(step);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/workflow-steps/:id
|
||||
* Update a workflow step.
|
||||
* Body: Partial<{ name, description, prompt, enabled }>
|
||||
* Returns: WorkflowStep
|
||||
*/
|
||||
router.patch("/workflow-steps/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, description, prompt, enabled } = req.body;
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name !== undefined) {
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
res.status(400).json({ error: "name must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
updates.name = name.trim();
|
||||
}
|
||||
if (description !== undefined) {
|
||||
if (typeof description !== "string" || !description.trim()) {
|
||||
res.status(400).json({ error: "description must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
updates.description = description.trim();
|
||||
}
|
||||
if (prompt !== undefined) {
|
||||
if (typeof prompt !== "string") {
|
||||
res.status(400).json({ error: "prompt must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.prompt = prompt;
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
if (typeof enabled !== "boolean") {
|
||||
res.status(400).json({ error: "enabled must be a boolean" });
|
||||
return;
|
||||
}
|
||||
updates.enabled = enabled;
|
||||
}
|
||||
|
||||
const step = await store.updateWorkflowStep(req.params.id, updates);
|
||||
res.json(step);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/workflow-steps/:id
|
||||
* Delete a workflow step.
|
||||
* Returns: 204 No Content
|
||||
*/
|
||||
router.delete("/workflow-steps/:id", async (req, res) => {
|
||||
try {
|
||||
await store.deleteWorkflowStep(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/workflow-steps/:id/refine
|
||||
* Use AI to refine the workflow step's description into a detailed agent prompt.
|
||||
* Returns: { prompt: string, workflowStep: WorkflowStep }
|
||||
*/
|
||||
router.post("/workflow-steps/:id/refine", async (req, res) => {
|
||||
try {
|
||||
const step = await store.getWorkflowStep(req.params.id);
|
||||
if (!step) {
|
||||
res.status(404).json({ error: `Workflow step '${req.params.id}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!step.description?.trim()) {
|
||||
res.status(400).json({ error: "Workflow step has no description to refine" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use AI to refine the description into a detailed agent prompt
|
||||
let refinedPrompt: string;
|
||||
try {
|
||||
// Dynamic import to avoid resolution issues in tests
|
||||
const engineModule = "@kb/engine";
|
||||
const { createKbAgent } = await import(/* @vite-ignore */ engineModule);
|
||||
const settings = await store.getSettings();
|
||||
|
||||
const systemPrompt = `You are an expert at creating detailed agent prompts for workflow steps.
|
||||
|
||||
A workflow step is a quality gate that runs after a task is implemented but before it's marked complete.
|
||||
|
||||
Given a rough description, create a detailed prompt that an AI agent can follow to execute this workflow step.
|
||||
|
||||
The prompt should:
|
||||
1. Define the purpose clearly
|
||||
2. Specify what files/context to examine
|
||||
3. List specific criteria to check
|
||||
4. Describe what "success" looks like
|
||||
5. Include guidance on handling common edge cases
|
||||
|
||||
Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
const { session } = await createKbAgent({
|
||||
cwd: store.getRootDir(),
|
||||
systemPrompt,
|
||||
tools: "none",
|
||||
defaultProvider: settings.planningProvider || settings.defaultProvider,
|
||||
defaultModelId: settings.planningModelId || settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
session.on("text", (delta: string) => {
|
||||
output += delta;
|
||||
});
|
||||
|
||||
await session.prompt(
|
||||
`Refine this workflow step description into a detailed agent prompt:\n\nName: ${step.name}\nDescription: ${step.description}`
|
||||
);
|
||||
session.dispose();
|
||||
|
||||
refinedPrompt = output.trim();
|
||||
} catch (agentErr: any) {
|
||||
// Fallback: return the description as-is if AI is unavailable
|
||||
refinedPrompt = step.description;
|
||||
}
|
||||
|
||||
// Update the workflow step with the refined prompt
|
||||
const updated = await store.updateWorkflowStep(step.id, { prompt: refinedPrompt });
|
||||
res.json({ prompt: refinedPrompt, workflowStep: updated });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ function createMockStore() {
|
||||
worktreeInitCommand: undefined,
|
||||
}),
|
||||
updateStep: vi.fn().mockResolvedValue({}),
|
||||
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return store as any;
|
||||
}
|
||||
@@ -3454,3 +3457,229 @@ describe("TaskExecutor task_done with summary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow Steps Execution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a mock agent that auto-triggers the task_done tool when prompt is called.
|
||||
* This simulates a successful task execution where the agent calls task_done().
|
||||
*/
|
||||
function createAgentWithTaskDone() {
|
||||
let capturedCustomTools: any[] = [];
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
|
||||
capturedCustomTools = opts.customTools || [];
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Find and execute task_done tool to set taskDone = true
|
||||
const taskDoneTool = capturedCustomTools.find((t: any) => t.name === "task_done");
|
||||
if (taskDoneTool) {
|
||||
await taskDoneTool.execute("tool-1", {});
|
||||
}
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
state: {},
|
||||
};
|
||||
return { session };
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it("runs workflow steps after main task execution", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Task has workflow steps enabled
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Docs Review",
|
||||
description: "Check documentation",
|
||||
prompt: "Review all docs and verify they are complete.",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// First call: main agent with task_done, subsequent calls: simple mocks for workflow step agents
|
||||
let callIdx = 0;
|
||||
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
|
||||
callIdx++;
|
||||
if (callIdx === 1) {
|
||||
// Main execution — find and trigger task_done
|
||||
const customTools = opts.customTools || [];
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
||||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
state: {},
|
||||
};
|
||||
return { session };
|
||||
} else {
|
||||
// Workflow step agent (no custom tools, uses readonly tools)
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
}) as any);
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// createKbAgent called twice: main agent + workflow step agent
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second call should be the workflow step with readonly tools
|
||||
const secondCall = mockedCreateHaiAgent.mock.calls[1];
|
||||
expect(secondCall[0].tools).toBe("readonly");
|
||||
expect(secondCall[0].systemPrompt).toContain("Docs Review");
|
||||
expect(secondCall[0].systemPrompt).toContain("Review all docs and verify they are complete.");
|
||||
|
||||
// Task should move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
});
|
||||
|
||||
it("skips workflow steps with no prompt", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Empty Step",
|
||||
description: "No prompt",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
createAgentWithTaskDone();
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should only call createKbAgent once (main execution), skip workflow step
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should log that it was skipped
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
expect.stringContaining("has no prompt"),
|
||||
);
|
||||
|
||||
// Task should still move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
});
|
||||
|
||||
it("handles tasks with no workflow steps", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
createAgentWithTaskDone();
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Only main agent call
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
// Task should still move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@kb/core";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep } from "@kb/core";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
@@ -507,6 +507,14 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
if (taskDone) {
|
||||
// Run workflow steps before moving to in-review
|
||||
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||
if (!workflowSuccess) {
|
||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||
this.options.onError?.(task, new Error("Workflow step failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
executorLog.log(`✓ ${task.id} completed → in-review`);
|
||||
this.options.onComplete?.(task);
|
||||
@@ -1001,6 +1009,146 @@ export class TaskExecutor {
|
||||
* @param startPoint — Optional git ref to branch from (e.g., `kb/kb-041`).
|
||||
* When provided, the worktree starts from that ref instead of HEAD.
|
||||
*/
|
||||
/**
|
||||
* Run workflow step agents sequentially after main task execution completes.
|
||||
* Each workflow step spawns a separate agent with the step's prompt.
|
||||
* Returns true if all steps pass, false if any fails.
|
||||
*/
|
||||
private async runWorkflowSteps(
|
||||
task: Task,
|
||||
worktreePath: string,
|
||||
settings: Settings,
|
||||
): Promise<boolean> {
|
||||
// Check if task has enabled workflow steps
|
||||
const currentTask = await this.store.getTask(task.id);
|
||||
if (!currentTask.enabledWorkflowSteps?.length) return true;
|
||||
|
||||
const workflowStepIds = currentTask.enabledWorkflowSteps;
|
||||
|
||||
for (const wsId of workflowStepIds) {
|
||||
const ws = await this.store.getWorkflowStep(wsId);
|
||||
if (!ws) {
|
||||
await this.store.logEntry(task.id, `Workflow step ${wsId} not found — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ws.prompt?.trim()) {
|
||||
await this.store.logEntry(task.id, `Workflow step '${ws.name}' has no prompt — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.logEntry(task.id, `Starting workflow step: ${ws.name}`);
|
||||
executorLog.log(`${task.id} — running workflow step: ${ws.name}`);
|
||||
|
||||
try {
|
||||
const result = await this.executeWorkflowStep(task, ws, worktreePath, settings);
|
||||
|
||||
if (result.success) {
|
||||
await this.store.logEntry(task.id, `Workflow step completed: ${ws.name}`);
|
||||
executorLog.log(`${task.id} — workflow step passed: ${ws.name}`);
|
||||
} else {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Workflow step failed: ${ws.name}`,
|
||||
result.error || "Unknown error",
|
||||
);
|
||||
executorLog.error(`${task.id} — workflow step failed: ${ws.name} — ${result.error}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Workflow step failed: ${ws.name}`,
|
||||
err.message || "Unknown error",
|
||||
);
|
||||
executorLog.error(`${task.id} — workflow step error: ${ws.name} — ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single workflow step by spawning an agent with the step's prompt.
|
||||
*/
|
||||
private async executeWorkflowStep(
|
||||
task: Task,
|
||||
workflowStep: WorkflowStep,
|
||||
worktreePath: string,
|
||||
settings: Settings,
|
||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||||
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
|
||||
|
||||
Task Context:
|
||||
- Task ID: ${task.id}
|
||||
- Task Description: ${task.description}
|
||||
- Worktree: ${worktreePath}
|
||||
|
||||
Your Instructions:
|
||||
${workflowStep.prompt}
|
||||
|
||||
You have access to the file system to review changes.
|
||||
When your review is complete and everything looks good, simply state your findings.
|
||||
If issues are found that need attention, describe them clearly.`;
|
||||
|
||||
const agentLogger = new AgentLogger({
|
||||
store: this.store,
|
||||
taskId: task.id,
|
||||
agent: "reviewer",
|
||||
onAgentText: (taskId, delta) => {
|
||||
this.options.onAgentText?.(taskId, delta);
|
||||
},
|
||||
onAgentTool: (taskId, toolName) => {
|
||||
this.options.onAgentTool?.(taskId, toolName);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { session } = await createKbAgent({
|
||||
cwd: worktreePath,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update") {
|
||||
const msgEvent = event.assistantMessageEvent;
|
||||
if (msgEvent.type === "text_delta") {
|
||||
output += msgEvent.delta;
|
||||
agentLogger.onText(msgEvent.delta);
|
||||
} else if (msgEvent.type === "thinking_delta") {
|
||||
agentLogger.onThinking(msgEvent.delta);
|
||||
}
|
||||
}
|
||||
if (event.type === "tool_execution_start") {
|
||||
agentLogger.onToolStart(event.toolName, event.args as Record<string, unknown> | undefined);
|
||||
}
|
||||
if (event.type === "tool_execution_end") {
|
||||
agentLogger.onToolEnd(event.toolName, event.isError, event.result);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt(
|
||||
`Execute the workflow step "${workflowStep.name}" for task ${task.id}.\n\n` +
|
||||
`Review the work done in this worktree and evaluate it against the criteria in your instructions.`,
|
||||
);
|
||||
|
||||
checkSessionError(session);
|
||||
session.dispose();
|
||||
await agentLogger.flush();
|
||||
|
||||
return { success: true, output };
|
||||
} catch (err: any) {
|
||||
await agentLogger.flush();
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
private createWorktree(branch: string, path: string, startPoint?: string): void {
|
||||
if (existsSync(path)) {
|
||||
executorLog.log(`Worktree already exists: ${path}`);
|
||||
|
||||
Reference in New Issue
Block a user