- 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
469 lines
18 KiB
TypeScript
469 lines
18 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import type { WorkflowStep, WorkflowStepInput } from "@kb/core";
|
|
import { fetchWorkflowSteps, createWorkflowStep, updateWorkflowStep, deleteWorkflowStep, refineWorkflowStepPrompt } from "../api";
|
|
import type { ToastType } from "../hooks/useToast";
|
|
import { X, Plus, Pencil, Trash2, Sparkles, Check, Loader2 } from "lucide-react";
|
|
|
|
interface WorkflowStepManagerProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
}
|
|
|
|
interface StepFormData {
|
|
name: string;
|
|
description: string;
|
|
prompt: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
const EMPTY_FORM: StepFormData = {
|
|
name: "",
|
|
description: "",
|
|
prompt: "",
|
|
enabled: true,
|
|
};
|
|
|
|
export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepManagerProps) {
|
|
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
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 loadSteps = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await fetchWorkflowSteps();
|
|
setSteps(data);
|
|
} catch (err: any) {
|
|
addToast(err.message || "Failed to load workflow steps", "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [addToast]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadSteps();
|
|
}
|
|
}, [isOpen, loadSteps]);
|
|
|
|
const handleCreate = useCallback(() => {
|
|
setIsCreating(true);
|
|
setEditingId(null);
|
|
setForm(EMPTY_FORM);
|
|
}, []);
|
|
|
|
const handleEdit = useCallback((step: WorkflowStep) => {
|
|
setEditingId(step.id);
|
|
setIsCreating(false);
|
|
setForm({
|
|
name: step.name,
|
|
description: step.description,
|
|
prompt: step.prompt,
|
|
enabled: step.enabled,
|
|
});
|
|
}, []);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
setEditingId(null);
|
|
setIsCreating(false);
|
|
setForm(EMPTY_FORM);
|
|
}, []);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!form.name.trim() || !form.description.trim()) {
|
|
addToast("Name and description are required", "error");
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
if (isCreating) {
|
|
const input: WorkflowStepInput = {
|
|
name: form.name.trim(),
|
|
description: form.description.trim(),
|
|
prompt: form.prompt.trim() || undefined,
|
|
enabled: form.enabled,
|
|
};
|
|
await createWorkflowStep(input);
|
|
addToast("Workflow step created", "success");
|
|
} else if (editingId) {
|
|
await updateWorkflowStep(editingId, {
|
|
name: form.name.trim(),
|
|
description: form.description.trim(),
|
|
prompt: form.prompt,
|
|
enabled: form.enabled,
|
|
});
|
|
addToast("Workflow step updated", "success");
|
|
}
|
|
|
|
setIsCreating(false);
|
|
setEditingId(null);
|
|
setForm(EMPTY_FORM);
|
|
await loadSteps();
|
|
} catch (err: any) {
|
|
addToast(err.message || "Failed to save workflow step", "error");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [form, isCreating, editingId, addToast, loadSteps]);
|
|
|
|
const handleDelete = useCallback(async (id: string) => {
|
|
try {
|
|
await deleteWorkflowStep(id);
|
|
addToast("Workflow step deleted", "success");
|
|
setDeleteConfirmId(null);
|
|
if (editingId === id) {
|
|
setEditingId(null);
|
|
setForm(EMPTY_FORM);
|
|
}
|
|
await loadSteps();
|
|
} catch (err: any) {
|
|
addToast(err.message || "Failed to delete workflow step", "error");
|
|
}
|
|
}, [editingId, addToast, loadSteps]);
|
|
|
|
const handleRefine = useCallback(async () => {
|
|
if (!editingId && !isCreating) return;
|
|
|
|
// For new steps being created, we need to save first then refine
|
|
if (isCreating) {
|
|
if (!form.name.trim() || !form.description.trim()) {
|
|
addToast("Name and description are required before refining", "error");
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const input: WorkflowStepInput = {
|
|
name: form.name.trim(),
|
|
description: form.description.trim(),
|
|
prompt: form.prompt.trim() || undefined,
|
|
enabled: form.enabled,
|
|
};
|
|
const created = await createWorkflowStep(input);
|
|
setIsCreating(false);
|
|
setEditingId(created.id);
|
|
|
|
// Now refine
|
|
setRefining(true);
|
|
const result = await refineWorkflowStepPrompt(created.id);
|
|
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
|
addToast("Prompt refined with AI", "success");
|
|
await loadSteps();
|
|
} catch (err: any) {
|
|
addToast(err.message || "Failed to refine prompt", "error");
|
|
} finally {
|
|
setSaving(false);
|
|
setRefining(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!editingId) return;
|
|
|
|
setRefining(true);
|
|
try {
|
|
const result = await refineWorkflowStepPrompt(editingId);
|
|
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
|
addToast("Prompt refined with AI", "success");
|
|
await loadSteps();
|
|
} catch (err: any) {
|
|
addToast(err.message || "Failed to refine prompt", "error");
|
|
} finally {
|
|
setRefining(false);
|
|
}
|
|
}, [editingId, isCreating, form, addToast, loadSteps]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const isEditing = isCreating || editingId !== null;
|
|
|
|
return (
|
|
<div className="modal-overlay" onClick={onClose} data-testid="workflow-step-manager">
|
|
<div
|
|
className="modal workflow-step-manager-modal"
|
|
onClick={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-label="Workflow Steps"
|
|
>
|
|
{/* Header */}
|
|
<div className="modal-header">
|
|
<h2>Workflow Steps</h2>
|
|
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="modal-body" style={{ padding: "16px", maxHeight: "70vh", overflowY: "auto" }}>
|
|
{loading ? (
|
|
<div style={{ textAlign: "center", padding: "32px", color: "var(--text-secondary)" }}>
|
|
Loading...
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Step list */}
|
|
{steps.length === 0 && !isEditing && (
|
|
<div
|
|
style={{
|
|
textAlign: "center",
|
|
padding: "32px",
|
|
color: "var(--text-secondary)",
|
|
fontSize: "14px",
|
|
}}
|
|
data-testid="empty-state"
|
|
>
|
|
No workflow steps defined. Create one to get started.
|
|
</div>
|
|
)}
|
|
|
|
{steps.length > 0 && !isEditing && (
|
|
<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={{ 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>
|
|
<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>
|
|
)}
|
|
|
|
{/* Edit / Create form */}
|
|
{isEditing && (
|
|
<div
|
|
style={{
|
|
padding: "16px",
|
|
border: "1px solid var(--border-primary)",
|
|
borderRadius: "8px",
|
|
background: "var(--bg-secondary)",
|
|
}}
|
|
data-testid="workflow-step-form"
|
|
>
|
|
<h3 style={{ margin: "0 0 12px", fontSize: "14px", fontWeight: 600 }}>
|
|
{isCreating ? "New Workflow Step" : "Edit Workflow Step"}
|
|
</h3>
|
|
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
{/* Name */}
|
|
<div>
|
|
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
|
Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
placeholder="e.g. Documentation Review"
|
|
style={{
|
|
width: "100%",
|
|
padding: "8px 12px",
|
|
borderRadius: "6px",
|
|
border: "1px solid var(--border-primary)",
|
|
background: "var(--bg-primary)",
|
|
color: "var(--text-primary)",
|
|
fontSize: "13px",
|
|
}}
|
|
data-testid="workflow-step-name"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
|
placeholder="Brief description of what this step does"
|
|
rows={2}
|
|
style={{
|
|
width: "100%",
|
|
padding: "8px 12px",
|
|
borderRadius: "6px",
|
|
border: "1px solid var(--border-primary)",
|
|
background: "var(--bg-primary)",
|
|
color: "var(--text-primary)",
|
|
fontSize: "13px",
|
|
resize: "vertical",
|
|
}}
|
|
data-testid="workflow-step-description"
|
|
/>
|
|
</div>
|
|
|
|
{/* Prompt */}
|
|
<div>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "4px" }}>
|
|
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
|
Agent Prompt
|
|
</label>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={handleRefine}
|
|
disabled={!form.description.trim() || refining}
|
|
title="Refine with AI"
|
|
aria-label="Refine prompt with AI"
|
|
style={{ fontSize: "12px", display: "flex", alignItems: "center", gap: "4px" }}
|
|
data-testid="refine-btn"
|
|
>
|
|
{refining ? <Loader2 size={12} className="spin" /> : <Sparkles size={12} />}
|
|
<span style={{ fontSize: "11px" }}>Refine with AI</span>
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
value={form.prompt}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, prompt: e.target.value }))}
|
|
placeholder="Leave empty to use AI refinement"
|
|
rows={6}
|
|
style={{
|
|
width: "100%",
|
|
padding: "8px 12px",
|
|
borderRadius: "6px",
|
|
border: "1px solid var(--border-primary)",
|
|
background: "var(--bg-primary)",
|
|
color: "var(--text-primary)",
|
|
fontSize: "13px",
|
|
fontFamily: "monospace",
|
|
resize: "vertical",
|
|
}}
|
|
data-testid="workflow-step-prompt"
|
|
/>
|
|
</div>
|
|
|
|
{/* Enabled toggle */}
|
|
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={form.enabled}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
|
|
data-testid="workflow-step-enabled"
|
|
/>
|
|
Enabled (available for selection on new tasks)
|
|
</label>
|
|
|
|
{/* Form actions */}
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "4px" }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={handleCancel}
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleSave}
|
|
disabled={saving || !form.name.trim() || !form.description.trim()}
|
|
data-testid="save-workflow-step"
|
|
>
|
|
{saving ? "Saving..." : isCreating ? "Create" : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
{!isEditing && !loading && (
|
|
<div className="modal-footer" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleCreate}
|
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
data-testid="add-workflow-step"
|
|
>
|
|
<Plus size={14} />
|
|
Add Workflow Step
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|