Fix workflow step execution wiring

This commit is contained in:
gsxdsm
2026-04-05 22:28:22 -07:00
parent 2f96f2da0d
commit d2b5d58699
9 changed files with 414 additions and 11 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix browser verification workflow selection so built-in browser checks materialize into runnable workflow steps, persist on task edits, and execute with coding tools.

View File

@@ -4762,6 +4762,40 @@ Task with acceptance criteria
expect(task.enabledWorkflowSteps).toEqual([ws1.id, ws2.id]); expect(task.enabledWorkflowSteps).toEqual([ws1.id, ws2.id]);
}); });
it("should materialize built-in workflow templates when creating a task", async () => {
const task = await store.createTask({
description: "Task with browser verification",
enabledWorkflowSteps: ["browser-verification"],
});
expect(task.enabledWorkflowSteps).toEqual(["WS-001"]);
const step = await store.getWorkflowStep("WS-001");
expect(step).toMatchObject({
id: "WS-001",
templateId: "browser-verification",
name: "Browser Verification",
toolMode: "coding",
});
});
it("should reuse an existing materialized built-in workflow step", async () => {
const first = await store.createTask({
description: "First browser verification task",
enabledWorkflowSteps: ["browser-verification"],
});
const second = await store.createTask({
description: "Second browser verification task",
enabledWorkflowSteps: ["browser-verification"],
});
expect(first.enabledWorkflowSteps).toEqual(["WS-001"]);
expect(second.enabledWorkflowSteps).toEqual(["WS-001"]);
const steps = await store.listWorkflowSteps();
expect(steps.filter((step) => step.templateId === "browser-verification")).toHaveLength(1);
});
it("should not set enabledWorkflowSteps when empty array provided", async () => { it("should not set enabledWorkflowSteps when empty array provided", async () => {
const task = await store.createTask({ const task = await store.createTask({
description: "Task without workflow steps", description: "Task without workflow steps",
@@ -4994,6 +5028,32 @@ Task with acceptance criteria
expect(task.enabledWorkflowSteps).toEqual(["WS-001", "WS-002"]); expect(task.enabledWorkflowSteps).toEqual(["WS-001", "WS-002"]);
}); });
it("should update task workflow steps and materialize built-in templates", async () => {
const task = await store.createTask({ description: "Editable task" });
const updated = await store.updateTask(task.id, {
enabledWorkflowSteps: ["browser-verification"],
});
expect(updated.enabledWorkflowSteps).toEqual(["WS-001"]);
const persisted = await store.getTask(task.id);
expect(persisted.enabledWorkflowSteps).toEqual(["WS-001"]);
});
it("should resolve built-in workflow templates from getWorkflowStep", async () => {
const step = await store.getWorkflowStep("browser-verification");
expect(step).toMatchObject({
id: "browser-verification",
templateId: "browser-verification",
name: "Browser Verification",
mode: "prompt",
phase: "pre-merge",
toolMode: "coding",
});
});
// ── Workflow Step Phase ────────────────────────────────────────────── // ── Workflow Step Phase ──────────────────────────────────────────────
it("should default phase to 'pre-merge' when creating a workflow step", async () => { it("should default phase to 'pre-merge' when creating a workflow step", async () => {

View File

@@ -4,7 +4,7 @@ import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/
import { join } from "node:path"; import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs"; import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js"; import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, WORKFLOW_STEP_TEMPLATES } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js"; import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js"; import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
@@ -657,6 +657,83 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return join(this.tasksDir, id); return join(this.tasksDir, id);
} }
private getBuiltInWorkflowTemplate(templateId: string): import("./types.js").WorkflowStepTemplate | undefined {
return WORKFLOW_STEP_TEMPLATES.find((template) => template.id === templateId);
}
private toBuiltInWorkflowStep(template: import("./types.js").WorkflowStepTemplate): import("./types.js").WorkflowStep {
const now = new Date().toISOString();
return {
id: template.id,
templateId: template.id,
name: template.name,
description: template.description,
mode: "prompt",
phase: "pre-merge",
prompt: template.prompt,
toolMode: template.toolMode || "readonly",
enabled: true,
createdAt: now,
updatedAt: now,
};
}
private async ensureWorkflowStepForTemplate(templateId: string): Promise<import("./types.js").WorkflowStep> {
return this.withConfigLock(async () => {
const template = this.getBuiltInWorkflowTemplate(templateId);
if (!template) {
throw new Error(`Workflow step template '${templateId}' not found`);
}
const config = await this.readConfig();
const existing = (config.workflowSteps || []).find((ws) =>
ws.id === templateId
|| ws.templateId === templateId
|| ws.name.toLowerCase() === template.name.toLowerCase(),
);
if (existing) {
return existing;
}
const nextWsId = config.nextWorkflowStepId || 1;
const step = this.toBuiltInWorkflowStep(template);
step.id = `WS-${String(nextWsId).padStart(3, "0")}`;
if (!config.workflowSteps) {
config.workflowSteps = [];
}
config.workflowSteps.push(step);
config.nextWorkflowStepId = nextWsId + 1;
await this.writeConfig(config);
return step;
});
}
private async resolveEnabledWorkflowSteps(stepIds?: string[]): Promise<string[] | undefined> {
if (!stepIds?.length) return undefined;
const resolved: string[] = [];
const seen = new Set<string>();
for (const rawId of stepIds) {
const stepId = rawId.trim();
if (!stepId) continue;
const template = this.getBuiltInWorkflowTemplate(stepId);
const resolvedId = template
? (await this.ensureWorkflowStepForTemplate(stepId)).id
: stepId;
if (!seen.has(resolvedId)) {
seen.add(resolvedId);
resolved.push(resolvedId);
}
}
return resolved.length > 0 ? resolved : undefined;
}
async createTask( async createTask(
input: TaskCreateInput, input: TaskCreateInput,
options?: { options?: {
@@ -695,7 +772,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} }
// Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps // Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps
let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length ? input.enabledWorkflowSteps : undefined; let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length
? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps)
: undefined;
// When enabledWorkflowSteps is not provided at all (undefined), auto-apply default-on workflow steps // When enabledWorkflowSteps is not provided at all (undefined), auto-apply default-on workflow steps
if (input.enabledWorkflowSteps === undefined) { if (input.enabledWorkflowSteps === undefined) {
@@ -1016,7 +1095,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask( async updateTask(
id: string, id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null }, updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
): Promise<Task> { ): Promise<Task> {
return this.withTaskLock(id, async () => { return this.withTaskLock(id, async () => {
// Validate that task doesn't depend on itself // Validate that task doesn't depend on itself
@@ -1101,6 +1180,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.nextRecoveryAt !== undefined) { } else if (updates.nextRecoveryAt !== undefined) {
task.nextRecoveryAt = updates.nextRecoveryAt; task.nextRecoveryAt = updates.nextRecoveryAt;
} }
if (updates.enabledWorkflowSteps !== undefined) {
task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps(updates.enabledWorkflowSteps);
}
if (updates.modelProvider === null) { if (updates.modelProvider === null) {
task.modelProvider = undefined; task.modelProvider = undefined;
} else if (updates.modelProvider !== undefined) { } else if (updates.modelProvider !== undefined) {
@@ -2680,11 +2762,13 @@ ${stepsSection}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
const step: import("./types.js").WorkflowStep = { const step: import("./types.js").WorkflowStep = {
id, id,
templateId: input.templateId,
name: input.name, name: input.name,
description: input.description, description: input.description,
mode, mode,
phase: input.phase || "pre-merge", phase: input.phase || "pre-merge",
prompt: mode === "prompt" ? (input.prompt || "") : "", prompt: mode === "prompt" ? (input.prompt || "") : "",
toolMode: mode === "prompt" ? (input.toolMode || "readonly") : undefined,
scriptName: mode === "script" ? input.scriptName : undefined, scriptName: mode === "script" ? input.scriptName : undefined,
enabled: input.enabled !== undefined ? input.enabled : true, enabled: input.enabled !== undefined ? input.enabled : true,
defaultOn: input.defaultOn === true ? true : undefined, defaultOn: input.defaultOn === true ? true : undefined,
@@ -2718,7 +2802,11 @@ ${stepsSection}`;
*/ */
async getWorkflowStep(id: string): Promise<import("./types.js").WorkflowStep | undefined> { async getWorkflowStep(id: string): Promise<import("./types.js").WorkflowStep | undefined> {
const config = await this.readConfig(); const config = await this.readConfig();
return (config.workflowSteps || []).find((ws) => ws.id === id); const stored = (config.workflowSteps || []).find((ws) => ws.id === id);
if (stored) return stored;
const template = this.getBuiltInWorkflowTemplate(id);
return template ? this.toBuiltInWorkflowStep(template) : undefined;
} }
/** /**
@@ -2748,12 +2836,14 @@ ${stepsSection}`;
// When switching to script mode, clear prompt and model overrides // When switching to script mode, clear prompt and model overrides
if (newMode === "script") { if (newMode === "script") {
step.prompt = ""; step.prompt = "";
step.toolMode = undefined;
step.modelProvider = undefined; step.modelProvider = undefined;
step.modelId = undefined; step.modelId = undefined;
} }
// When switching to prompt mode, clear scriptName // When switching to prompt mode, clear scriptName
if (newMode === "prompt") { if (newMode === "prompt") {
step.scriptName = undefined; step.scriptName = undefined;
step.toolMode = step.toolMode || "readonly";
} }
} }
@@ -2761,6 +2851,7 @@ ${stepsSection}`;
if (updates.description !== undefined) step.description = updates.description; if (updates.description !== undefined) step.description = updates.description;
if (updates.phase !== undefined) step.phase = updates.phase; if (updates.phase !== undefined) step.phase = updates.phase;
if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt; if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt;
if (updates.toolMode !== undefined && step.mode === "prompt") step.toolMode = updates.toolMode;
if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName; if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName;
if (updates.enabled !== undefined) step.enabled = updates.enabled; if (updates.enabled !== undefined) step.enabled = updates.enabled;
if (updates.defaultOn !== undefined) step.defaultOn = updates.defaultOn; if (updates.defaultOn !== undefined) step.defaultOn = updates.defaultOn;

View File

@@ -59,6 +59,7 @@ export interface ModelPreset {
/** A reusable workflow step definition that can run after task implementation. */ /** A reusable workflow step definition that can run after task implementation. */
/** Execution mode for a workflow step. */ /** Execution mode for a workflow step. */
export type WorkflowStepMode = "prompt" | "script"; export type WorkflowStepMode = "prompt" | "script";
export type WorkflowStepToolMode = "readonly" | "coding";
/** Lifecycle phase for workflow step execution. */ /** Lifecycle phase for workflow step execution. */
export type WorkflowStepPhase = "pre-merge" | "post-merge"; export type WorkflowStepPhase = "pre-merge" | "post-merge";
@@ -66,6 +67,8 @@ export type WorkflowStepPhase = "pre-merge" | "post-merge";
export interface WorkflowStep { export interface WorkflowStep {
/** Unique identifier (e.g., "WS-001") */ /** Unique identifier (e.g., "WS-001") */
id: string; id: string;
/** Built-in template source ID when this step was materialized from a template. */
templateId?: string;
/** Display name (e.g., "Documentation Review") */ /** Display name (e.g., "Documentation Review") */
name: string; name: string;
/** Short description for UI display */ /** Short description for UI display */
@@ -76,6 +79,8 @@ export interface WorkflowStep {
phase?: WorkflowStepPhase; phase?: WorkflowStepPhase;
/** Full agent prompt to execute when this step runs (used when mode is "prompt") */ /** Full agent prompt to execute when this step runs (used when mode is "prompt") */
prompt: string; prompt: string;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */ /** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
scriptName?: string; scriptName?: string;
/** Whether this step is available for selection on new tasks */ /** Whether this step is available for selection on new tasks */
@@ -102,6 +107,8 @@ export interface WorkflowStep {
export type NtfyNotificationEvent = "in-review" | "merged" | "failed"; export type NtfyNotificationEvent = "in-review" | "merged" | "failed";
export interface WorkflowStepInput { export interface WorkflowStepInput {
/** Built-in template source ID when creating a concrete step from a template. */
templateId?: string;
name: string; name: string;
description: string; description: string;
/** Execution mode — defaults to "prompt" if not specified */ /** Execution mode — defaults to "prompt" if not specified */
@@ -110,6 +117,8 @@ export interface WorkflowStepInput {
phase?: WorkflowStepPhase; phase?: WorkflowStepPhase;
/** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */ /** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */
prompt?: string; prompt?: string;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Script name from project settings (required when mode is "script"). /** Script name from project settings (required when mode is "script").
* Must reference a named script in `settings.scripts` — no raw commands. */ * Must reference a named script in `settings.scripts` — no raw commands. */
scriptName?: string; scriptName?: string;
@@ -152,6 +161,8 @@ export interface WorkflowStepTemplate {
description: string; description: string;
/** Full agent prompt template */ /** Full agent prompt template */
prompt: string; prompt: string;
/** Tool set available when the template runs as a prompt-mode step. */
toolMode?: WorkflowStepToolMode;
/** Grouping category (e.g., "Quality", "Security") */ /** Grouping category (e.g., "Quality", "Security") */
category: string; category: string;
/** Optional icon identifier for UI (e.g., "file-text", "shield") */ /** Optional icon identifier for UI (e.g., "file-text", "shield") */
@@ -166,6 +177,7 @@ export const WORKFLOW_STEP_TEMPLATES: WorkflowStepTemplate[] = [
description: "Verify all public APIs, functions, and complex logic have appropriate documentation", description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
category: "Quality", category: "Quality",
icon: "file-text", icon: "file-text",
toolMode: "readonly",
prompt: `You are a documentation reviewer. Review the completed task and verify documentation quality. prompt: `You are a documentation reviewer. Review the completed task and verify documentation quality.
Review Criteria: Review Criteria:
@@ -191,6 +203,7 @@ Output Requirements:
description: "Run tests and verify they pass, check for obvious bugs", description: "Run tests and verify they pass, check for obvious bugs",
category: "Quality", category: "Quality",
icon: "check-circle", icon: "check-circle",
toolMode: "coding",
prompt: `You are a QA tester. Verify the task implementation by running tests and checking for bugs. prompt: `You are a QA tester. Verify the task implementation by running tests and checking for bugs.
Test Execution: Test Execution:
@@ -215,6 +228,7 @@ Output Requirements:
description: "Check for common security vulnerabilities and anti-patterns", description: "Check for common security vulnerabilities and anti-patterns",
category: "Security", category: "Security",
icon: "shield", icon: "shield",
toolMode: "readonly",
prompt: `You are a security auditor. Review the task changes for common security vulnerabilities. prompt: `You are a security auditor. Review the task changes for common security vulnerabilities.
Security Checklist: Security Checklist:
@@ -242,6 +256,7 @@ Output Requirements:
description: "Check for performance anti-patterns and optimization opportunities", description: "Check for performance anti-patterns and optimization opportunities",
category: "Quality", category: "Quality",
icon: "zap", icon: "zap",
toolMode: "readonly",
prompt: `You are a performance reviewer. Analyze the task changes for performance implications. prompt: `You are a performance reviewer. Analyze the task changes for performance implications.
Performance Checklist: Performance Checklist:
@@ -268,6 +283,7 @@ Output Requirements:
description: "Verify UI changes meet accessibility standards (WCAG 2.1)", description: "Verify UI changes meet accessibility standards (WCAG 2.1)",
category: "Quality", category: "Quality",
icon: "eye", icon: "eye",
toolMode: "readonly",
prompt: `You are an accessibility reviewer. Check UI changes for WCAG 2.1 compliance. prompt: `You are an accessibility reviewer. Check UI changes for WCAG 2.1 compliance.
Accessibility Checklist: Accessibility Checklist:
@@ -294,6 +310,7 @@ Output Requirements:
description: "Verify web application functionality using browser automation", description: "Verify web application functionality using browser automation",
category: "Quality", category: "Quality",
icon: "globe", icon: "globe",
toolMode: "coding",
prompt: `You are a browser verification specialist. Verify web application functionality after task implementation using the agent-browser CLI tool. prompt: `You are a browser verification specialist. Verify web application functionality after task implementation using the agent-browser CLI tool.
## Prerequisites ## Prerequisites

View File

@@ -1402,6 +1402,7 @@ describe("PATCH /tasks/:id", () => {
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
dependencies: ["FN-002"], dependencies: ["FN-002"],
enabledWorkflowSteps: undefined,
modelProvider: null, modelProvider: null,
modelId: null, modelId: null,
validatorModelProvider: null, validatorModelProvider: null,
@@ -1423,6 +1424,7 @@ describe("PATCH /tasks/:id", () => {
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
dependencies: undefined, dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null, modelProvider: null,
modelId: null, modelId: null,
validatorModelProvider: null, validatorModelProvider: null,
@@ -1454,6 +1456,7 @@ describe("PATCH /tasks/:id", () => {
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
dependencies: undefined, dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: "anthropic", modelProvider: "anthropic",
modelId: "claude-sonnet-4-5", modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai", validatorModelProvider: "openai",
@@ -1503,12 +1506,50 @@ describe("PATCH /tasks/:id", () => {
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
dependencies: undefined, dependencies: undefined,
enabledWorkflowSteps: undefined,
modelProvider: null, modelProvider: null,
modelId: null, modelId: null,
validatorModelProvider: null, validatorModelProvider: null,
validatorModelId: null, validatorModelId: null,
}); });
}); });
it("forwards enabledWorkflowSteps to store.updateTask", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
enabledWorkflowSteps: ["browser-verification"],
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
enabledWorkflowSteps: ["browser-verification"],
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: undefined,
enabledWorkflowSteps: ["browser-verification"],
modelProvider: null,
modelId: null,
validatorModelProvider: null,
validatorModelId: null,
});
});
it("returns 400 for invalid enabledWorkflowSteps type", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
enabledWorkflowSteps: [123],
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("enabledWorkflowSteps must be an array of strings");
});
}); });
describe("Attachment routes", () => { describe("Attachment routes", () => {
@@ -6941,9 +6982,11 @@ describe("POST /workflow-step-templates/:id/create", () => {
expect(res.body.id).toBe("WS-001"); expect(res.body.id).toBe("WS-001");
expect(res.body.name).toBe("Documentation Review"); expect(res.body.name).toBe("Documentation Review");
expect(store.createWorkflowStep).toHaveBeenCalledWith({ expect(store.createWorkflowStep).toHaveBeenCalledWith({
templateId: "documentation-review",
name: "Documentation Review", name: "Documentation Review",
description: "Verify all public APIs, functions, and complex logic have appropriate documentation", description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
prompt: expect.stringContaining("documentation reviewer"), prompt: expect.stringContaining("documentation reviewer"),
toolMode: "readonly",
enabled: true, enabled: true,
}); });
}); });
@@ -6968,9 +7011,11 @@ describe("POST /workflow-step-templates/:id/create", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.name).toBe("QA Check"); expect(res.body.name).toBe("QA Check");
expect(store.createWorkflowStep).toHaveBeenCalledWith({ expect(store.createWorkflowStep).toHaveBeenCalledWith({
templateId: "qa-check",
name: "QA Check", name: "QA Check",
description: "Run tests and verify they pass, check for obvious bugs", description: "Run tests and verify they pass, check for obvious bugs",
prompt: expect.stringContaining("QA tester"), prompt: expect.stringContaining("QA tester"),
toolMode: "coding",
enabled: true, enabled: true,
}); });
}); });

View File

@@ -1744,7 +1744,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
); );
res.status(201).json(task); res.status(201).json(task);
} catch (err: any) { } catch (err: any) {
const status = err.message?.includes("must be a string") ? 400 : 500; const status = err.message?.includes("must be a string") || err.message?.includes("must be an array of strings") ? 400 : 500;
res.status(status).json({ error: err.message }); res.status(status).json({ error: err.message });
} }
}); });
@@ -2457,7 +2457,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.patch("/tasks/:id", async (req, res) => { router.patch("/tasks/:id", async (req, res) => {
try { try {
const scopedStore = await getScopedStore(req); const scopedStore = await getScopedStore(req);
const { title, description, prompt, dependencies, modelProvider, modelId, validatorModelProvider, validatorModelId } = req.body; const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId } = req.body;
// Validate model fields are strings or undefined/null // Validate model fields are strings or undefined/null
const validateModelField = (value: unknown, name: string): string | null | undefined => { const validateModelField = (value: unknown, name: string): string | null | undefined => {
@@ -2473,11 +2473,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const validatedValidatorModelProvider = validateModelField(validatorModelProvider, "validatorModelProvider"); const validatedValidatorModelProvider = validateModelField(validatorModelProvider, "validatorModelProvider");
const validatedValidatorModelId = validateModelField(validatorModelId, "validatorModelId"); const validatedValidatorModelId = validateModelField(validatorModelId, "validatorModelId");
if (enabledWorkflowSteps !== undefined) {
if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) {
throw new Error("enabledWorkflowSteps must be an array of strings");
}
}
const task = await scopedStore.updateTask(req.params.id, { const task = await scopedStore.updateTask(req.params.id, {
title, title,
description, description,
prompt, prompt,
dependencies, dependencies,
enabledWorkflowSteps,
modelProvider: validatedModelProvider, modelProvider: validatedModelProvider,
modelId: validatedModelId, modelId: validatedModelId,
validatorModelProvider: validatedValidatorModelProvider, validatorModelProvider: validatedValidatorModelProvider,
@@ -2485,7 +2492,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}); });
res.json(task); res.json(task);
} catch (err: any) { } catch (err: any) {
const status = err.message?.includes("must be a string") ? 400 : 500; const status = err.message?.includes("must be a string") || err.message?.includes("must be an array of strings") ? 400 : 500;
res.status(status).json({ error: err.message }); res.status(status).json({ error: err.message });
} }
}); });
@@ -6205,7 +6212,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/workflow-steps", async (req, res) => { router.post("/workflow-steps", async (req, res) => {
try { try {
const scopedStore = await getScopedStore(req); const scopedStore = await getScopedStore(req);
const { name, description, mode, phase, prompt, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body; const { name, description, mode, phase, prompt, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
if (!name || typeof name !== "string" || !name.trim()) { if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" }); res.status(400).json({ error: "name is required" });
@@ -6233,6 +6240,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.status(400).json({ error: "prompt must be a string" }); res.status(400).json({ error: "prompt must be a string" });
return; return;
} }
if (toolMode !== undefined && toolMode !== "readonly" && toolMode !== "coding") {
res.status(400).json({ error: "toolMode must be 'readonly' or 'coding'" });
return;
}
if (scriptName !== undefined && typeof scriptName !== "string") { if (scriptName !== undefined && typeof scriptName !== "string") {
res.status(400).json({ error: "scriptName must be a string" }); res.status(400).json({ error: "scriptName must be a string" });
return; return;
@@ -6276,6 +6287,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
mode: resolvedMode, mode: resolvedMode,
phase, phase,
prompt: prompt?.trim(), prompt: prompt?.trim(),
toolMode,
scriptName: scriptName?.trim(), scriptName: scriptName?.trim(),
enabled, enabled,
defaultOn: defaultOn === true, defaultOn: defaultOn === true,
@@ -6298,7 +6310,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.patch("/workflow-steps/:id", async (req, res) => { router.patch("/workflow-steps/:id", async (req, res) => {
try { try {
const scopedStore = await getScopedStore(req); const scopedStore = await getScopedStore(req);
const { name, description, mode, phase, prompt, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body; const { name, description, mode, phase, prompt, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
if (name !== undefined) { if (name !== undefined) {
@@ -6336,6 +6348,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
updates.prompt = prompt; updates.prompt = prompt;
} }
if (toolMode !== undefined) {
if (toolMode !== "readonly" && toolMode !== "coding") {
res.status(400).json({ error: "toolMode must be 'readonly' or 'coding'" });
return;
}
updates.toolMode = toolMode;
}
if (scriptName !== undefined) { if (scriptName !== undefined) {
if (typeof scriptName !== "string") { if (typeof scriptName !== "string") {
res.status(400).json({ error: "scriptName must be a string" }); res.status(400).json({ error: "scriptName must be a string" });
@@ -6537,9 +6556,11 @@ Output ONLY the prompt text (no markdown, no explanations).`;
} }
const step = await scopedStore.createWorkflowStep({ const step = await scopedStore.createWorkflowStep({
templateId: template.id,
name: template.name, name: template.name,
description: template.description, description: template.description,
prompt: template.prompt, prompt: template.prompt,
toolMode: template.toolMode,
enabled: true, enabled: true,
}); });

View File

@@ -5474,6 +5474,168 @@ describe("Workflow Steps Execution", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
}); });
it("runs browser verification workflow steps with coding tools", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Browser task",
description: "Verify browser behavior",
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",
templateId: "browser-verification",
name: "Browser Verification",
description: "Verify with browser automation",
mode: "prompt",
toolMode: "coding",
prompt: "Use browser automation to verify the app.",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
let callIdx = 0;
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
callIdx++;
if (callIdx === 1) {
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 };
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Browser task",
description: "Verify browser behavior",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
const secondCall = mockedCreateHaiAgent.mock.calls[1];
expect(secondCall[0].tools).toBe("coding");
});
it("runs QA workflow steps with coding tools", async () => {
const store = createMockStore();
store.getTask.mockResolvedValue({
id: "FN-001",
title: "QA task",
description: "Verify tests pass",
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",
templateId: "qa-check",
name: "QA Check",
description: "Run tests and verify they pass",
mode: "prompt",
toolMode: "coding",
prompt: "Run the test suite and report results.",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
let callIdx = 0;
mockedCreateHaiAgent.mockImplementation((async (opts: any) => {
callIdx++;
if (callIdx === 1) {
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 };
}
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
state: {},
},
};
}) as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "QA task",
description: "Verify tests pass",
column: "in-progress",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-001"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(2);
const secondCall = mockedCreateHaiAgent.mock.calls[1];
expect(secondCall[0].tools).toBe("coding");
});
it("skips workflow steps with no prompt", async () => { it("skips workflow steps with no prompt", async () => {
const store = createMockStore(); const store = createMockStore();

View File

@@ -1902,6 +1902,7 @@ export class TaskExecutor {
worktreePath: string, worktreePath: string,
settings: Settings, settings: Settings,
): Promise<{ success: boolean; output?: string; error?: string }> { ): Promise<{ success: boolean; output?: string; error?: string }> {
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name} const systemPrompt = `You are a workflow step agent executing: ${workflowStep.name}
Task Context: Task Context:
@@ -1937,7 +1938,7 @@ If issues are found that need attention, describe them clearly.`;
const { session } = await createKbAgent({ const { session } = await createKbAgent({
cwd: worktreePath, cwd: worktreePath,
systemPrompt, systemPrompt,
tools: "readonly", tools: toolMode,
defaultProvider: stepProvider, defaultProvider: stepProvider,
defaultModelId: stepModelId, defaultModelId: stepModelId,
fallbackProvider: settings.fallbackProvider, fallbackProvider: settings.fallbackProvider,

View File

@@ -1539,6 +1539,7 @@ async function executePostMergePromptStep(
rootDir: string, rootDir: string,
settings: Settings, settings: Settings,
): Promise<{ success: boolean; output?: string; error?: string }> { ): Promise<{ success: boolean; output?: string; error?: string }> {
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
const systemPrompt = `You are a post-merge workflow step agent executing: ${workflowStep.name} const systemPrompt = `You are a post-merge workflow step agent executing: ${workflowStep.name}
Task Context: Task Context:
@@ -1567,7 +1568,7 @@ If issues are found that need attention, describe them clearly.`;
const { session } = await createKbAgent({ const { session } = await createKbAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt, systemPrompt,
tools: "readonly", tools: toolMode,
defaultProvider: stepProvider, defaultProvider: stepProvider,
defaultModelId: stepModelId, defaultModelId: stepModelId,
fallbackProvider: settings.fallbackProvider, fallbackProvider: settings.fallbackProvider,