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:
gsxdsm
2026-03-31 03:55:01 -07:00
parent 3fe8dd3c89
commit 139fbb9f29
18 changed files with 2141 additions and 8 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 } 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";

View File

@@ -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();
});
});
});

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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} />
</>
);

View File

@@ -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",
});
}

View File

@@ -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)}

View File

@@ -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">

View 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>
);
}

View File

@@ -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"],
})
);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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");
});
});

View File

@@ -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}`);