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 {