feat(KB-268): add workflow step templates

- Define 5 built-in workflow step templates (Documentation Review, QA Check, Security Audit, Performance Review, Accessibility Check)
- Add API endpoints GET /api/workflow-step-templates and POST /api/workflow-step-templates/:id/create
- Add templates tab to Workflow Step Manager with one-click add functionality
- Include high-quality agent prompts for each template category
- Add changeset for the new feature
This commit is contained in:
gsxdsm
2026-03-31 06:10:14 -07:00
parent de094990a9
commit 88ee7c2216
8 changed files with 837 additions and 102 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, WorkflowStep, WorkflowStepInput } from "./types.js";
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, WORKFLOW_STEP_TEMPLATES } 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, WorkflowStepTemplate } from "./types.js";
export { TaskStore } from "./store.js";
export { GlobalSettingsStore } from "./global-settings.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -66,6 +66,154 @@ export interface WorkflowStepInput {
enabled?: boolean;
}
/** A built-in workflow step template for one-click creation. */
export interface WorkflowStepTemplate {
/** Unique template identifier (e.g., "documentation-review") */
id: string;
/** Display name (e.g., "Documentation Review") */
name: string;
/** Short description for UI */
description: string;
/** Full agent prompt template */
prompt: string;
/** Grouping category (e.g., "Quality", "Security") */
category: string;
/** Optional icon identifier for UI (e.g., "file-text", "shield") */
icon?: string;
}
/** Built-in workflow step templates available for one-click creation. */
export const WORKFLOW_STEP_TEMPLATES: WorkflowStepTemplate[] = [
{
id: "documentation-review",
name: "Documentation Review",
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
category: "Quality",
icon: "file-text",
prompt: `You are a documentation reviewer. Review the completed task and verify documentation quality.
Review Criteria:
1. All new public functions, classes, and modules have JSDoc comments or equivalent documentation
2. Complex logic has inline comments explaining the "why" not just the "what"
3. README files are updated if the task changes user-facing behavior
4. CHANGELOG or release notes are considered for significant changes
5. Type definitions are documented for public APIs
Files to Review:
- Review all files modified in the task worktree
- Focus on public API surface area
- Check test files for test documentation
Output Requirements:
- If documentation is adequate: call task_done() with success status
- If documentation is missing: list specific files and functions that need documentation using task_log()
- Provide specific suggestions for what documentation should be added`,
},
{
id: "qa-check",
name: "QA Check",
description: "Run tests and verify they pass, check for obvious bugs",
category: "Quality",
icon: "check-circle",
prompt: `You are a QA tester. Verify the task implementation by running tests and checking for bugs.
Test Execution:
1. Run the project's test suite (use pnpm test, npm test, or the configured test command)
2. Verify all tests pass
3. If tests fail, analyze whether failures are related to the task changes
Code Review:
1. Review the changes for obvious bugs or edge cases
2. Check error handling is appropriate
3. Verify input validation is present where needed
4. Look for common issues: null pointer risks, off-by-one errors, race conditions
Output Requirements:
- If all tests pass and no bugs found: call task_done() with success status
- If tests fail: provide detailed failure information via task_log()
- If bugs are found: describe the bug, affected files, and suggested fix via task_log()`,
},
{
id: "security-audit",
name: "Security Audit",
description: "Check for common security vulnerabilities and anti-patterns",
category: "Security",
icon: "shield",
prompt: `You are a security auditor. Review the task changes for common security vulnerabilities.
Security Checklist:
1. **Injection vulnerabilities** — Check for SQL injection, command injection, XSS via unsanitized user input
2. **Secrets and credentials** — Ensure no hardcoded passwords, API keys, tokens, or private keys
3. **Unsafe eval** — Check for eval(), new Function(), or similar dangerous patterns
4. **Path traversal** — Verify file path handling prevents directory traversal attacks
5. **Insecure deserialization** — Check for unsafe parsing of untrusted data
6. **Authentication/Authorization** — Verify access controls are properly implemented
7. **Dependency risks** — Note any new dependencies that might have known vulnerabilities
Files to Review:
- All modified files in the task
- Configuration files that might contain secrets
- Areas handling user input or external data
Output Requirements:
- If no security issues found: call task_done() with success status
- If issues found: describe each vulnerability with specific file paths, line numbers, and severity via task_log()
- Provide remediation suggestions for each issue`,
},
{
id: "performance-review",
name: "Performance Review",
description: "Check for performance anti-patterns and optimization opportunities",
category: "Quality",
icon: "zap",
prompt: `You are a performance reviewer. Analyze the task changes for performance implications.
Performance Checklist:
1. **Algorithmic complexity** — Check for O(n²) or worse patterns that could bottleneck
2. **N+1 queries** — Look for database queries in loops
3. **Memory leaks** — Check for unclosed resources, event listeners, or accumulating caches
4. **Unnecessary re-renders** — For UI code, check for inefficient React/Angular/Vue patterns
5. **Bundle size** — Note if large dependencies are added unnecessarily
6. **Async patterns** — Verify proper use of async/await, Promise.all for parallel work
7. **Caching opportunities** — Identify where caching could improve performance
Files to Review:
- All modified files, focusing on hot paths and frequently executed code
- Database query files
- API endpoints and route handlers
Output Requirements:
- If performance is acceptable: call task_done() with success status
- If issues found: describe each issue with specific file paths and suggested optimizations via task_log()`,
},
{
id: "accessibility-check",
name: "Accessibility Check",
description: "Verify UI changes meet accessibility standards (WCAG 2.1)",
category: "Quality",
icon: "eye",
prompt: `You are an accessibility reviewer. Check UI changes for WCAG 2.1 compliance.
Accessibility Checklist:
1. **Keyboard navigation** — Ensure all interactive elements are keyboard accessible
2. **ARIA labels** — Check that screen reader announcements are appropriate
3. **Color contrast** — Verify text meets minimum contrast ratios (4.5:1 for normal text)
4. **Focus indicators** — Ensure visible focus states for keyboard navigation
5. **Alt text** — Check that images have meaningful alternative text
6. **Form labels** — Verify all inputs have associated labels
7. **Semantic HTML** — Check that proper HTML elements are used (buttons not divs)
Files to Review:
- Modified UI components
- CSS/styling changes
- New HTML templates or JSX
Output Requirements:
- If accessibility requirements are met: call task_done() with success status
- If issues found: describe each issue with specific file paths, WCAG guideline references, and remediation steps via task_log()`,
},
];
export interface PrInfo {
url: string;
number: number;

View File

@@ -1229,6 +1229,23 @@ export function refineWorkflowStepPrompt(id: string): Promise<{ prompt: string;
});
}
// ── Workflow Step Templates ──────────────────────────────────────────────
/** Re-export WorkflowStepTemplate type from core */
export type { WorkflowStepTemplate } from "@kb/core";
/** Fetch all built-in workflow step templates */
export function fetchWorkflowStepTemplates(): Promise<{ templates: import("@kb/core").WorkflowStepTemplate[] }> {
return api<{ templates: import("@kb/core").WorkflowStepTemplate[] }>("/workflow-step-templates");
}
/** Create a workflow step from a built-in template */
export function createWorkflowStepFromTemplate(templateId: string): Promise<WorkflowStep> {
return api<WorkflowStep>(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, {
method: "POST",
});
}
// ── AI Text Refinement API ────────────────────────────────────────────
/** Refinement types for AI text refinement */

View File

@@ -1,8 +1,32 @@
import { useState, useEffect, useCallback } from "react";
import type { WorkflowStep, WorkflowStepInput } from "@kb/core";
import { fetchWorkflowSteps, createWorkflowStep, updateWorkflowStep, deleteWorkflowStep, refineWorkflowStepPrompt } from "../api";
import {
fetchWorkflowSteps,
createWorkflowStep,
updateWorkflowStep,
deleteWorkflowStep,
refineWorkflowStepPrompt,
fetchWorkflowStepTemplates,
createWorkflowStepFromTemplate,
type WorkflowStepTemplate,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import { X, Plus, Pencil, Trash2, Sparkles, Check, Loader2 } from "lucide-react";
import {
X,
Plus,
Pencil,
Trash2,
Sparkles,
Check,
Loader2,
FileText,
CheckCircle,
Shield,
Zap,
Eye,
LayoutGrid,
BookOpen,
} from "lucide-react";
interface WorkflowStepManagerProps {
isOpen: boolean;
@@ -17,6 +41,8 @@ interface StepFormData {
enabled: boolean;
}
type TabId = "my-steps" | "templates";
const EMPTY_FORM: StepFormData = {
name: "",
description: "",
@@ -24,15 +50,49 @@ const EMPTY_FORM: StepFormData = {
enabled: true,
};
/** Map template icon names to Lucide components */
function getTemplateIcon(iconName: string | undefined) {
switch (iconName) {
case "file-text":
return FileText;
case "check-circle":
return CheckCircle;
case "shield":
return Shield;
case "zap":
return Zap;
case "eye":
return Eye;
default:
return CheckCircle;
}
}
/** Get category badge colors */
function getCategoryColors(category: string): { bg: string; text: string } {
switch (category.toLowerCase()) {
case "quality":
return { bg: "rgba(59, 130, 246, 0.15)", text: "#3b82f6" };
case "security":
return { bg: "rgba(239, 68, 68, 0.15)", text: "#ef4444" };
default:
return { bg: "var(--bg-tertiary)", text: "var(--text-secondary)" };
}
}
export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepManagerProps) {
const [steps, setSteps] = useState<WorkflowStep[]>([]);
const [templates, setTemplates] = useState<WorkflowStepTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [templatesLoading, setTemplatesLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabId>("my-steps");
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [form, setForm] = useState<StepFormData>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [refining, setRefining] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [addingTemplateId, setAddingTemplateId] = useState<string | null>(null);
const loadSteps = useCallback(async () => {
try {
@@ -46,11 +106,24 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
}
}, [addToast]);
const loadTemplates = useCallback(async () => {
try {
setTemplatesLoading(true);
const response = await fetchWorkflowStepTemplates();
setTemplates(response.templates);
} catch (err: any) {
addToast(err.message || "Failed to load templates", "error");
} finally {
setTemplatesLoading(false);
}
}, [addToast]);
useEffect(() => {
if (isOpen) {
loadSteps();
loadTemplates();
}
}, [isOpen, loadSteps]);
}, [isOpen, loadSteps, loadTemplates]);
const handleCreate = useCallback(() => {
setIsCreating(true);
@@ -180,6 +253,25 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
}
}, [editingId, isCreating, form, addToast, loadSteps]);
const handleAddTemplate = useCallback(async (template: WorkflowStepTemplate) => {
setAddingTemplateId(template.id);
try {
await createWorkflowStepFromTemplate(template.id);
addToast(`Added ${template.name} workflow step`, "success");
await loadSteps();
// Switch to "My Workflow Steps" tab to show the newly added step
setActiveTab("my-steps");
} catch (err: any) {
if (err.message?.includes("already exists")) {
addToast(`A workflow step named '${template.name}' already exists`, "error");
} else {
addToast(err.message || "Failed to add workflow step from template", "error");
}
} finally {
setAddingTemplateId(null);
}
}, [addToast, loadSteps]);
if (!isOpen) return null;
const isEditing = isCreating || editingId !== null;
@@ -207,107 +299,300 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
</div>
) : (
<>
{/* Step list */}
{steps.length === 0 && !isEditing && (
{/* Tab Navigation */}
{!isEditing && (
<div
style={{
textAlign: "center",
padding: "32px",
color: "var(--text-secondary)",
fontSize: "14px",
display: "flex",
gap: "8px",
marginBottom: "16px",
borderBottom: "1px solid var(--border-primary)",
paddingBottom: "8px",
}}
data-testid="empty-state"
>
No workflow steps defined. Create one to get started.
<button
className={`btn ${activeTab === "my-steps" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("my-steps")}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "13px",
padding: "6px 12px",
}}
data-testid="tab-my-steps"
>
<BookOpen size={14} />
My Workflow Steps ({steps.length})
</button>
<button
className={`btn ${activeTab === "templates" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("templates")}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "13px",
padding: "6px 12px",
}}
data-testid="tab-templates"
>
<LayoutGrid size={14} />
Templates ({templates.length})
</button>
</div>
)}
{steps.length > 0 && !isEditing && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{steps.map((step) => (
{/* My Workflow Steps Tab */}
{activeTab === "my-steps" && !isEditing && (
<>
{steps.length === 0 && (
<div
key={step.id}
className="workflow-step-card"
data-testid={`workflow-step-${step.id}`}
style={{
padding: "12px 16px",
border: "1px solid var(--border-primary)",
borderRadius: "8px",
background: "var(--bg-secondary)",
textAlign: "center",
padding: "32px",
color: "var(--text-secondary)",
fontSize: "14px",
}}
data-testid="empty-state"
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
<span style={{ fontWeight: 600, fontSize: "14px" }}>{step.name}</span>
<span
style={{
fontSize: "11px",
padding: "2px 6px",
borderRadius: "4px",
background: step.enabled ? "var(--status-success-bg, rgba(34, 197, 94, 0.15))" : "var(--bg-tertiary)",
color: step.enabled ? "var(--status-success, #22c55e)" : "var(--text-secondary)",
}}
>
{step.enabled ? "Enabled" : "Disabled"}
</span>
</div>
No workflow steps defined. Create one to get started, or add one from the Templates tab.
</div>
)}
{steps.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{steps.map((step) => (
<div
key={step.id}
className="workflow-step-card"
data-testid={`workflow-step-${step.id}`}
style={{
padding: "12px 16px",
border: "1px solid var(--border-primary)",
borderRadius: "8px",
background: "var(--bg-secondary)",
}}
>
<div
style={{
fontSize: "12px",
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
{step.description}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "4px",
}}
>
<span style={{ fontWeight: 600, fontSize: "14px" }}>{step.name}</span>
<span
style={{
fontSize: "11px",
padding: "2px 6px",
borderRadius: "4px",
background: step.enabled
? "var(--status-success-bg, rgba(34, 197, 94, 0.15))"
: "var(--bg-tertiary)",
color: step.enabled
? "var(--status-success, #22c55e)"
: "var(--text-secondary)",
}}
>
{step.enabled ? "Enabled" : "Disabled"}
</span>
</div>
<div
style={{
fontSize: "12px",
color: "var(--text-secondary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{step.description}
</div>
</div>
<div
style={{
display: "flex",
gap: "4px",
marginLeft: "8px",
flexShrink: 0,
}}
>
<button
className="btn-icon"
onClick={() => handleEdit(step)}
title="Edit"
aria-label={`Edit ${step.name}`}
>
<Pencil size={14} />
</button>
{deleteConfirmId === step.id ? (
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<button
className="btn-icon"
onClick={() => handleDelete(step.id)}
title="Confirm delete"
aria-label={`Confirm delete ${step.name}`}
style={{ color: "var(--status-error, #ef4444)" }}
>
<Check size={14} />
</button>
<button
className="btn-icon"
onClick={() => setDeleteConfirmId(null)}
title="Cancel delete"
aria-label="Cancel delete"
>
<X size={14} />
</button>
</div>
) : (
<button
className="btn-icon"
onClick={() => setDeleteConfirmId(step.id)}
title="Delete"
aria-label={`Delete ${step.name}`}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</div>
<div style={{ display: "flex", gap: "4px", marginLeft: "8px", flexShrink: 0 }}>
<button
className="btn-icon"
onClick={() => handleEdit(step)}
title="Edit"
aria-label={`Edit ${step.name}`}
>
<Pencil size={14} />
</button>
{deleteConfirmId === step.id ? (
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<button
className="btn-icon"
onClick={() => handleDelete(step.id)}
title="Confirm delete"
aria-label={`Confirm delete ${step.name}`}
style={{ color: "var(--status-error, #ef4444)" }}
>
<Check size={14} />
</button>
<button
className="btn-icon"
onClick={() => setDeleteConfirmId(null)}
title="Cancel delete"
aria-label="Cancel delete"
>
<X size={14} />
</button>
</div>
) : (
<button
className="btn-icon"
onClick={() => setDeleteConfirmId(step.id)}
title="Delete"
aria-label={`Delete ${step.name}`}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
</div>
))}
</div>
)}
</>
)}
{/* Templates Tab */}
{activeTab === "templates" && !isEditing && (
<>
{templatesLoading ? (
<div style={{ textAlign: "center", padding: "32px", color: "var(--text-secondary)" }}>
<Loader2 size={24} className="spin" style={{ margin: "0 auto 8px" }} />
Loading templates...
</div>
) : templates.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "32px",
color: "var(--text-secondary)",
fontSize: "14px",
}}
data-testid="no-templates-state"
>
No templates available.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{templates.map((template) => {
const IconComponent = getTemplateIcon(template.icon);
const categoryColors = getCategoryColors(template.category);
const isAdding = addingTemplateId === template.id;
return (
<div
key={template.id}
data-testid={`template-${template.id}`}
style={{
padding: "16px",
border: "1px solid var(--border-primary)",
borderRadius: "8px",
background: "var(--bg-secondary)",
}}
>
<div style={{ display: "flex", gap: "12px", alignItems: "flex-start" }}>
{/* Icon */}
<div
style={{
padding: "8px",
borderRadius: "6px",
background: "var(--bg-tertiary)",
color: "var(--text-primary)",
flexShrink: 0,
}}
>
<IconComponent size={20} />
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "4px",
}}
>
<span style={{ fontWeight: 600, fontSize: "14px" }}>
{template.name}
</span>
<span
style={{
fontSize: "11px",
padding: "2px 6px",
borderRadius: "4px",
background: categoryColors.bg,
color: categoryColors.text,
}}
>
{template.category}
</span>
</div>
<div
style={{
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "8px",
}}
>
{template.description}
</div>
<button
className="btn btn-primary"
onClick={() => handleAddTemplate(template)}
disabled={isAdding}
style={{
fontSize: "12px",
padding: "4px 12px",
display: "flex",
alignItems: "center",
gap: "4px",
}}
data-testid={`add-template-${template.id}`}
>
{isAdding ? (
<>
<Loader2 size={12} className="spin" />
Adding...
</>
) : (
<>
<Plus size={12} />
Add
</>
)}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</>
)}
{/* Edit / Create form */}
@@ -328,7 +613,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Name */}
<div>
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
<label
style={{
display: "block",
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
}}
>
Name
</label>
<input
@@ -351,7 +643,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
{/* Description */}
<div>
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
<label
style={{
display: "block",
fontSize: "12px",
color: "var(--text-secondary)",
marginBottom: "4px",
}}
>
Description
</label>
<textarea
@@ -375,7 +674,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
{/* Prompt */}
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "4px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "4px",
}}
>
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
Agent Prompt
</label>
@@ -385,10 +691,19 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
disabled={!form.description.trim() || refining}
title="Refine with AI"
aria-label="Refine prompt with AI"
style={{ fontSize: "12px", display: "flex", alignItems: "center", gap: "4px" }}
style={{
fontSize: "12px",
display: "flex",
alignItems: "center",
gap: "4px",
}}
data-testid="refine-btn"
>
{refining ? <Loader2 size={12} className="spin" /> : <Sparkles size={12} />}
{refining ? (
<Loader2 size={12} className="spin" />
) : (
<Sparkles size={12} />
)}
<span style={{ fontSize: "11px" }}>Refine with AI</span>
</button>
</div>
@@ -413,7 +728,15 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
</div>
{/* Enabled toggle */}
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "13px",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={form.enabled}
@@ -424,12 +747,15 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
</label>
{/* Form actions */}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "4px" }}>
<button
className="btn btn-secondary"
onClick={handleCancel}
disabled={saving}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: "8px",
marginTop: "4px",
}}
>
<button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>
Cancel
</button>
<button
@@ -449,8 +775,11 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
</div>
{/* Footer */}
{!isEditing && !loading && (
<div className="modal-footer" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}>
{!isEditing && (
<div
className="modal-footer"
style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}
>
<button
className="btn btn-primary"
onClick={handleCreate}

View File

@@ -5001,3 +5001,143 @@ describe("POST /workflow-steps/:id/refine", () => {
expect(store.updateWorkflowStep).toHaveBeenCalled();
});
});
// ── Workflow Step Template Tests ──────────────────────────────────────────
describe("GET /workflow-step-templates", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns all built-in templates", async () => {
const res = await GET(buildApp(), "/api/workflow-step-templates");
expect(res.status).toBe(200);
expect(res.body.templates).toBeDefined();
expect(Array.isArray(res.body.templates)).toBe(true);
expect(res.body.templates.length).toBeGreaterThanOrEqual(5);
// Check that templates have required fields
for (const template of res.body.templates) {
expect(template.id).toBeDefined();
expect(template.name).toBeDefined();
expect(template.description).toBeDefined();
expect(template.category).toBeDefined();
expect(template.prompt).toBeDefined();
}
});
it("includes expected template IDs", async () => {
const res = await GET(buildApp(), "/api/workflow-step-templates");
expect(res.status).toBe(200);
const ids = res.body.templates.map((t: { id: string }) => t.id);
expect(ids).toContain("documentation-review");
expect(ids).toContain("qa-check");
expect(ids).toContain("security-audit");
expect(ids).toContain("performance-review");
expect(ids).toContain("accessibility-check");
});
});
describe("POST /workflow-step-templates/:id/create", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("creates workflow step from template", async () => {
const created = {
id: "WS-001",
name: "Documentation Review",
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
prompt: expect.stringContaining("documentation reviewer"),
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
};
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(201);
expect(res.body.id).toBe("WS-001");
expect(res.body.name).toBe("Documentation Review");
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Documentation Review",
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
prompt: expect.stringContaining("documentation reviewer"),
enabled: true,
});
});
it("creates workflow step from qa-check template", async () => {
const created = {
id: "WS-002",
name: "QA Check",
description: "Run tests and verify they pass, check for obvious bugs",
prompt: expect.stringContaining("QA tester"),
enabled: true,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
};
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/qa-check/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(201);
expect(res.body.name).toBe("QA Check");
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "QA Check",
description: "Run tests and verify they pass, check for obvious bugs",
prompt: expect.stringContaining("QA tester"),
enabled: true,
});
});
it("returns 404 for non-existent template", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/nonexistent/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 409 when workflow step with same name already exists", async () => {
const existingSteps = [
{ id: "WS-001", name: "Documentation Review", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
];
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce(existingSteps);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("already exists");
});
});

View File

@@ -4715,6 +4715,57 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
// ── Workflow Step Templates ───────────────────────────────────────────
/**
* GET /api/workflow-step-templates
* List all built-in workflow step templates.
* Returns: { templates: WorkflowStepTemplate[] }
*/
router.get("/workflow-step-templates", async (_req, res) => {
try {
const { WORKFLOW_STEP_TEMPLATES } = await import("@kb/core");
res.json({ templates: WORKFLOW_STEP_TEMPLATES });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/workflow-step-templates/:id/create
* Create a workflow step from a built-in template.
* Returns: WorkflowStep
*/
router.post("/workflow-step-templates/:id/create", async (req, res) => {
try {
const { WORKFLOW_STEP_TEMPLATES } = await import("@kb/core");
const template = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === req.params.id);
if (!template) {
res.status(404).json({ error: `Template '${req.params.id}' not found` });
return;
}
// Check for name conflicts with existing workflow steps
const existing = await store.listWorkflowSteps();
if (existing.some((ws) => ws.name.toLowerCase() === template.name.toLowerCase())) {
res.status(409).json({ error: `A workflow step named '${template.name}' already exists` });
return;
}
const step = await store.createWorkflowStep({
name: template.name,
description: template.description,
prompt: template.prompt,
enabled: true,
});
res.status(201).json(step);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
return router;
}