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 {
|
||||
|
||||
Reference in New Issue
Block a user