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:
@@ -83,6 +83,9 @@ function createMockStore() {
|
||||
worktreeInitCommand: undefined,
|
||||
}),
|
||||
updateStep: vi.fn().mockResolvedValue({}),
|
||||
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return store as any;
|
||||
}
|
||||
@@ -3454,3 +3457,229 @@ describe("TaskExecutor task_done with summary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow Steps Execution", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a mock agent that auto-triggers the task_done tool when prompt is called.
|
||||
* This simulates a successful task execution where the agent calls task_done().
|
||||
*/
|
||||
function createAgentWithTaskDone() {
|
||||
let capturedCustomTools: any[] = [];
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
|
||||
capturedCustomTools = opts.customTools || [];
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Find and execute task_done tool to set taskDone = true
|
||||
const taskDoneTool = capturedCustomTools.find((t: any) => t.name === "task_done");
|
||||
if (taskDoneTool) {
|
||||
await taskDoneTool.execute("tool-1", {});
|
||||
}
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
state: {},
|
||||
};
|
||||
return { session };
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it("runs workflow steps after main task execution", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Task has workflow steps enabled
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Docs Review",
|
||||
description: "Check documentation",
|
||||
prompt: "Review all docs and verify they are complete.",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// First call: main agent with task_done, subsequent calls: simple mocks for workflow step agents
|
||||
let callIdx = 0;
|
||||
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
|
||||
callIdx++;
|
||||
if (callIdx === 1) {
|
||||
// Main execution — find and trigger task_done
|
||||
const customTools = opts.customTools || [];
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
const taskDoneTool = customTools.find((t: any) => t.name === "task_done");
|
||||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
state: {},
|
||||
};
|
||||
return { session };
|
||||
} else {
|
||||
// Workflow step agent (no custom tools, uses readonly tools)
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
}) as any);
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// createKbAgent called twice: main agent + workflow step agent
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second call should be the workflow step with readonly tools
|
||||
const secondCall = mockedCreateHaiAgent.mock.calls[1];
|
||||
expect(secondCall[0].tools).toBe("readonly");
|
||||
expect(secondCall[0].systemPrompt).toContain("Docs Review");
|
||||
expect(secondCall[0].systemPrompt).toContain("Review all docs and verify they are complete.");
|
||||
|
||||
// Task should move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
});
|
||||
|
||||
it("skips workflow steps with no prompt", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockResolvedValue({
|
||||
id: "WS-001",
|
||||
name: "Empty Step",
|
||||
description: "No prompt",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
createAgentWithTaskDone();
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should only call createKbAgent once (main execution), skip workflow step
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should log that it was skipped
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
expect.stringContaining("has no prompt"),
|
||||
);
|
||||
|
||||
// Task should still move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
});
|
||||
|
||||
it("handles tasks with no workflow steps", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
createAgentWithTaskDone();
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", { onComplete });
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Only main agent call
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
// Task should still move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-review");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@kb/core";
|
||||
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep } from "@kb/core";
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
@@ -507,6 +507,14 @@ export class TaskExecutor {
|
||||
}
|
||||
|
||||
if (taskDone) {
|
||||
// Run workflow steps before moving to in-review
|
||||
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||
if (!workflowSuccess) {
|
||||
await this.store.updateTask(task.id, { status: "failed", error: "Workflow step failed" });
|
||||
this.options.onError?.(task, new Error("Workflow step failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
executorLog.log(`✓ ${task.id} completed → in-review`);
|
||||
this.options.onComplete?.(task);
|
||||
@@ -1001,6 +1009,146 @@ export class TaskExecutor {
|
||||
* @param startPoint — Optional git ref to branch from (e.g., `kb/kb-041`).
|
||||
* When provided, the worktree starts from that ref instead of HEAD.
|
||||
*/
|
||||
/**
|
||||
* Run workflow step agents sequentially after main task execution completes.
|
||||
* Each workflow step spawns a separate agent with the step's prompt.
|
||||
* Returns true if all steps pass, false if any fails.
|
||||
*/
|
||||
private async runWorkflowSteps(
|
||||
task: Task,
|
||||
worktreePath: string,
|
||||
settings: Settings,
|
||||
): Promise<boolean> {
|
||||
// Check if task has enabled workflow steps
|
||||
const currentTask = await this.store.getTask(task.id);
|
||||
if (!currentTask.enabledWorkflowSteps?.length) return true;
|
||||
|
||||
const workflowStepIds = currentTask.enabledWorkflowSteps;
|
||||
|
||||
for (const wsId of workflowStepIds) {
|
||||
const ws = await this.store.getWorkflowStep(wsId);
|
||||
if (!ws) {
|
||||
await this.store.logEntry(task.id, `Workflow step ${wsId} not found — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ws.prompt?.trim()) {
|
||||
await this.store.logEntry(task.id, `Workflow step '${ws.name}' has no prompt — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.logEntry(task.id, `Starting workflow step: ${ws.name}`);
|
||||
executorLog.log(`${task.id} — running workflow step: ${ws.name}`);
|
||||
|
||||
try {
|
||||
const result = await this.executeWorkflowStep(task, ws, worktreePath, settings);
|
||||
|
||||
if (result.success) {
|
||||
await this.store.logEntry(task.id, `Workflow step completed: ${ws.name}`);
|
||||
executorLog.log(`${task.id} — workflow step passed: ${ws.name}`);
|
||||
} else {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Workflow step failed: ${ws.name}`,
|
||||
result.error || "Unknown error",
|
||||
);
|
||||
executorLog.error(`${task.id} — workflow step failed: ${ws.name} — ${result.error}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Workflow step failed: ${ws.name}`,
|
||||
err.message || "Unknown error",
|
||||
);
|
||||
executorLog.error(`${task.id} — workflow step error: ${ws.name} — ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single workflow step by spawning an agent with the step's prompt.
|
||||
*/
|
||||
private async executeWorkflowStep(
|
||||
task: Task,
|
||||
workflowStep: WorkflowStep,
|
||||
worktreePath: string,
|
||||
settings: Settings,
|
||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||||
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
|
||||
|
||||
Task Context:
|
||||
- Task ID: ${task.id}
|
||||
- Task Description: ${task.description}
|
||||
- Worktree: ${worktreePath}
|
||||
|
||||
Your Instructions:
|
||||
${workflowStep.prompt}
|
||||
|
||||
You have access to the file system to review changes.
|
||||
When your review is complete and everything looks good, simply state your findings.
|
||||
If issues are found that need attention, describe them clearly.`;
|
||||
|
||||
const agentLogger = new AgentLogger({
|
||||
store: this.store,
|
||||
taskId: task.id,
|
||||
agent: "reviewer",
|
||||
onAgentText: (taskId, delta) => {
|
||||
this.options.onAgentText?.(taskId, delta);
|
||||
},
|
||||
onAgentTool: (taskId, toolName) => {
|
||||
this.options.onAgentTool?.(taskId, toolName);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { session } = await createKbAgent({
|
||||
cwd: worktreePath,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||
});
|
||||
|
||||
let output = "";
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update") {
|
||||
const msgEvent = event.assistantMessageEvent;
|
||||
if (msgEvent.type === "text_delta") {
|
||||
output += msgEvent.delta;
|
||||
agentLogger.onText(msgEvent.delta);
|
||||
} else if (msgEvent.type === "thinking_delta") {
|
||||
agentLogger.onThinking(msgEvent.delta);
|
||||
}
|
||||
}
|
||||
if (event.type === "tool_execution_start") {
|
||||
agentLogger.onToolStart(event.toolName, event.args as Record<string, unknown> | undefined);
|
||||
}
|
||||
if (event.type === "tool_execution_end") {
|
||||
agentLogger.onToolEnd(event.toolName, event.isError, event.result);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt(
|
||||
`Execute the workflow step "${workflowStep.name}" for task ${task.id}.\n\n` +
|
||||
`Review the work done in this worktree and evaluate it against the criteria in your instructions.`,
|
||||
);
|
||||
|
||||
checkSessionError(session);
|
||||
session.dispose();
|
||||
await agentLogger.flush();
|
||||
|
||||
return { success: true, output };
|
||||
} catch (err: any) {
|
||||
await agentLogger.flush();
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
private createWorktree(branch: string, path: string, startPoint?: string): void {
|
||||
if (existsSync(path)) {
|
||||
executorLog.log(`Worktree already exists: ${path}`);
|
||||
|
||||
Reference in New Issue
Block a user