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

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