feat(FN-837): add workflow step phase support with pre-merge and post-merge execution

- Add phase field to workflow step definitions (pre-merge vs post-merge) with persistence and API
- Execute pre-merge steps in executor before merge; post-merge steps in merger after successful merge
- Pre-merge failures block merge and keep task in in-review; post-merge failures are logged only
- Expose phase controls and phase-aware results in the dashboard UI
- Add changeset for the published @gsxdsm/fusion package
This commit is contained in:
gsxdsm
2026-04-04 17:36:18 -07:00
parent c16c8b9965
commit 9a8d68fce8
22 changed files with 1493 additions and 83 deletions

View File

@@ -647,7 +647,26 @@ export function TaskForm({
style={{ marginTop: "2px" }}
/>
<div>
<span style={{ fontWeight: 500, fontSize: "13px" }}>{step.name}</span>
<span style={{ fontWeight: 500, fontSize: "13px" }}>
{step.name}
<span
style={{
marginLeft: "6px",
fontSize: "11px",
padding: "1px 6px",
borderRadius: "4px",
background: (step.phase || "pre-merge") === "post-merge"
? "rgba(139, 92, 246, 0.15)"
: "rgba(59, 130, 246, 0.15)",
color: (step.phase || "pre-merge") === "post-merge"
? "#8b5cf6"
: "#3b82f6",
}}
data-testid={`workflow-step-phase-${step.id}`}
>
{(step.phase || "pre-merge") === "post-merge" ? "Post-merge" : "Pre-merge"}
</span>
</span>
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
{step.description}
</div>

View File

@@ -8,6 +8,7 @@ describe("WorkflowResultsTab", () => {
{
workflowStepId: "WS-001",
workflowStepName: "QA Check",
phase: "pre-merge",
status: "passed",
output: "All tests passed successfully.",
startedAt: "2026-03-31T10:00:00Z",
@@ -16,6 +17,7 @@ describe("WorkflowResultsTab", () => {
{
workflowStepId: "WS-002",
workflowStepName: "Security Audit",
phase: "pre-merge",
status: "failed",
output: "Found 2 security issues in auth.ts",
startedAt: "2026-03-31T10:02:35Z",
@@ -24,6 +26,7 @@ describe("WorkflowResultsTab", () => {
{
workflowStepId: "WS-003",
workflowStepName: "Documentation Review",
phase: "post-merge",
status: "skipped",
output: undefined,
startedAt: undefined,
@@ -32,6 +35,7 @@ describe("WorkflowResultsTab", () => {
{
workflowStepId: "WS-004",
workflowStepName: "Performance Check",
phase: "post-merge",
status: "pending",
output: undefined,
startedAt: "2026-03-31T10:03:20Z",
@@ -125,6 +129,7 @@ describe("WorkflowResultsTab", () => {
{
workflowStepId: "WS-005",
workflowStepName: "Simple Check",
phase: "pre-merge",
status: "passed",
output: "Done",
},
@@ -135,4 +140,31 @@ describe("WorkflowResultsTab", () => {
expect(screen.getByText("Simple Check")).toBeInTheDocument();
// Should not crash without timestamps
});
it("displays phase badges for each result", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
// Pre-merge results (WS-001, WS-002)
expect(screen.getByTestId("workflow-result-phase-WS-001")).toHaveTextContent("Pre-merge");
expect(screen.getByTestId("workflow-result-phase-WS-002")).toHaveTextContent("Pre-merge");
// Post-merge results (WS-003, WS-004)
expect(screen.getByTestId("workflow-result-phase-WS-003")).toHaveTextContent("Post-merge");
expect(screen.getByTestId("workflow-result-phase-WS-004")).toHaveTextContent("Post-merge");
});
it("defaults to Pre-merge phase badge when phase is undefined", () => {
const resultsWithoutPhase: WorkflowStepResult[] = [
{
workflowStepId: "WS-005",
workflowStepName: "Legacy Check",
status: "passed",
output: "Done",
},
];
render(<WorkflowResultsTab taskId="FN-001" results={resultsWithoutPhase} />);
expect(screen.getByTestId("workflow-result-phase-WS-005")).toHaveTextContent("Pre-merge");
});
});

View File

@@ -70,7 +70,7 @@ export function WorkflowResultsTab({ taskId, results, loading }: WorkflowResults
<div className="workflow-results-empty" data-testid="workflow-results-empty">
<p>No workflow steps have run yet.</p>
<p className="workflow-results-empty-hint">
Workflow steps will execute after the main task implementation completes.
Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.
</p>
</div>
);
@@ -78,52 +78,75 @@ export function WorkflowResultsTab({ taskId, results, loading }: WorkflowResults
return (
<div className="workflow-results-list" data-testid="workflow-results-list">
{results.map((result, index) => (
<div
key={`${result.workflowStepId}-${index}`}
className={`workflow-result-item workflow-result-item--${result.status}`}
data-testid={`workflow-result-item-${result.workflowStepId}`}
>
<div className="workflow-result-header">
<div className="workflow-result-name">{result.workflowStepName}</div>
<span
className={`workflow-result-badge workflow-result-badge--${result.status}`}
style={{
backgroundColor: getStatusColor(result.status),
color: result.status === "skipped" ? "var(--text-muted)" : "#fff",
}}
data-testid={`workflow-result-badge-${result.workflowStepId}`}
>
{getStatusLabel(result.status)}
</span>
</div>
<div className="workflow-result-meta">
{result.startedAt && (
<span className="workflow-result-timestamp">
Started: {formatTimestamp(result.startedAt)}
</span>
)}
{result.completedAt && (
<span className="workflow-result-duration">
{formatDuration(result.startedAt, result.completedAt)}
</span>
)}
</div>
{result.output && (
<div className="workflow-result-output-section">
<div className="workflow-result-output-label">Output:</div>
<pre
className="workflow-result-output"
data-testid={`workflow-result-output-${result.workflowStepId}`}
{results.map((result, index) => {
const phase = result.phase || "pre-merge";
return (
<div
key={`${result.workflowStepId}-${index}`}
className={`workflow-result-item workflow-result-item--${result.status}`}
data-testid={`workflow-result-item-${result.workflowStepId}`}
>
<div className="workflow-result-header">
<div className="workflow-result-name">
{result.workflowStepName}
<span
className={`workflow-result-phase-badge workflow-result-phase-badge--${phase}`}
data-testid={`workflow-result-phase-${result.workflowStepId}`}
style={{
marginLeft: "8px",
fontSize: "11px",
padding: "1px 6px",
borderRadius: "4px",
background: phase === "post-merge"
? "rgba(139, 92, 246, 0.15)"
: "rgba(59, 130, 246, 0.15)",
color: phase === "post-merge"
? "#8b5cf6"
: "#3b82f6",
}}
>
{phase === "post-merge" ? "Post-merge" : "Pre-merge"}
</span>
</div>
<span
className={`workflow-result-badge workflow-result-badge--${result.status}`}
style={{
backgroundColor: getStatusColor(result.status),
color: result.status === "skipped" ? "var(--text-muted)" : "#fff",
}}
data-testid={`workflow-result-badge-${result.workflowStepId}`}
>
{result.output}
</pre>
{getStatusLabel(result.status)}
</span>
</div>
)}
</div>
))}
<div className="workflow-result-meta">
{result.startedAt && (
<span className="workflow-result-timestamp">
Started: {formatTimestamp(result.startedAt)}
</span>
)}
{result.completedAt && (
<span className="workflow-result-duration">
{formatDuration(result.startedAt, result.completedAt)}
</span>
)}
</div>
{result.output && (
<div className="workflow-result-output-section">
<div className="workflow-result-output-label">Output:</div>
<pre
className="workflow-result-output"
data-testid={`workflow-result-output-${result.workflowStepId}`}
>
{result.output}
</pre>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode } from "@fusion/core";
import type { WorkflowStep, WorkflowStepInput, WorkflowStepMode, WorkflowStepPhase } from "@fusion/core";
import {
fetchWorkflowSteps,
createWorkflowStep,
@@ -45,6 +45,7 @@ interface StepFormData {
name: string;
description: string;
mode: WorkflowStepMode;
phase: WorkflowStepPhase;
prompt: string;
scriptName: string;
enabled: boolean;
@@ -58,6 +59,7 @@ const EMPTY_FORM: StepFormData = {
name: "",
description: "",
mode: "prompt",
phase: "pre-merge" as WorkflowStepPhase,
prompt: "",
scriptName: "",
enabled: true,
@@ -188,6 +190,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
name: step.name,
description: step.description,
mode: step.mode || "prompt",
phase: step.phase || "pre-merge",
prompt: step.prompt,
scriptName: step.scriptName || "",
enabled: step.enabled,
@@ -222,6 +225,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
name: form.name.trim(),
description: form.description.trim(),
mode: form.mode,
phase: form.phase,
prompt: form.mode === "prompt" ? (form.prompt.trim() || undefined) : undefined,
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
enabled: form.enabled,
@@ -234,6 +238,7 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
name: form.name.trim(),
description: form.description.trim(),
mode: form.mode,
phase: form.phase,
prompt: form.mode === "prompt" ? form.prompt : "",
scriptName: form.mode === "script" ? form.scriptName.trim() : undefined,
enabled: form.enabled,
@@ -421,6 +426,9 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
<span className={`wfm-badge ${(step.mode || "prompt") === "script" ? "wfm-badge-script" : "wfm-badge-prompt"}`}>
{(step.mode || "prompt") === "script" ? "Script" : "AI Prompt"}
</span>
<span className={`wfm-badge ${(step.phase || "pre-merge") === "post-merge" ? "wfm-badge-post-merge" : "wfm-badge-pre-merge"}`}>
{(step.phase || "pre-merge") === "post-merge" ? "Post-merge" : "Pre-merge"}
</span>
</div>
<div className="wfm-step-card-desc">
{step.description}
@@ -604,6 +612,32 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
</div>
</div>
{/* Phase Selector */}
<div className="wfm-field">
<label>Execution Phase</label>
<div className="wfm-mode-selector" data-testid="workflow-step-phase-selector">
<button
className={`btn ${form.phase === "pre-merge" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
onClick={() => setForm((prev) => ({ ...prev, phase: "pre-merge" }))}
data-testid="phase-pre-merge"
>
Pre-merge
</button>
<button
className={`btn ${form.phase === "post-merge" ? "btn-primary" : "btn-secondary"} wfm-mode-btn`}
onClick={() => setForm((prev) => ({ ...prev, phase: "post-merge" }))}
data-testid="phase-post-merge"
>
Post-merge
</button>
</div>
<div className="wfm-field-hint">
{form.phase === "pre-merge"
? "Runs before merge — can block merge on failure"
: "Runs after merge success — failures are logged but do not block"}
</div>
</div>
{/* Prompt (AI mode only) */}
{form.mode === "prompt" && (
<div className="wfm-field">

View File

@@ -738,4 +738,32 @@ describe("TaskForm workflow step reordering (FN-836)", () => {
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-003"]);
});
it("shows phase badge for workflow steps with phase info", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", phase: "pre-merge", enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Post-merge Notify", description: "Notify team", prompt: "Notify", phase: "post-merge", enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: [] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge");
expect(screen.getByTestId("workflow-step-phase-WS-002")).toHaveTextContent("Post-merge");
});
});
it("shows Pre-merge phase badge for legacy steps without phase", async () => {
const { fetchWorkflowSteps } = await import("../../api");
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "Legacy Check", description: "No phase field", prompt: "Check", enabled: true, createdAt: "", updatedAt: "" },
]);
renderTaskForm({ selectedWorkflowSteps: [] });
await waitFor(() => {
expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge");
});
});
});

View File

@@ -187,6 +187,7 @@ describe("WorkflowStepManager", () => {
name: "New Step",
description: "New description",
mode: "prompt",
phase: "pre-merge",
prompt: undefined,
scriptName: undefined,
enabled: true,
@@ -297,19 +298,19 @@ describe("WorkflowStepManager", () => {
});
});
it("shows AI Prompt mode badge for prompt steps", async () => {
it("shows AI Prompt mode badge and Pre-merge phase badge for legacy prompt steps", async () => {
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getAllByText("AI Prompt")).toHaveLength(2);
// Legacy steps without phase default to Pre-merge
expect(screen.queryAllByText("Pre-merge")).toHaveLength(2);
});
});
it("shows Script mode badge for script steps", async () => {
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "" },
{ ...mockSteps[0], mode: "script" as const, scriptName: "test", prompt: "", phase: "pre-merge" },
]);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
@@ -319,6 +320,20 @@ describe("WorkflowStepManager", () => {
});
});
it("shows Pre-merge and Post-merge phase badges for steps with explicit phases", async () => {
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ ...mockSteps[0], phase: "pre-merge" as const },
{ ...mockSteps[1], phase: "post-merge" as const },
]);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Pre-merge")).toBeInTheDocument();
expect(screen.getByText("Post-merge")).toBeInTheDocument();
});
});
it("shows mode selector when creating a new step", async () => {
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([]);
@@ -405,6 +420,76 @@ describe("WorkflowStepManager", () => {
const saveBtn = screen.getByTestId("save-workflow-step") as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
});
it("submits new workflow step with post-merge phase", async () => {
vi.mocked(fetchWorkflowSteps)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("add-workflow-step")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("add-workflow-step"));
const nameInput = screen.getByTestId("workflow-step-name");
const descInput = screen.getByTestId("workflow-step-description");
fireEvent.change(nameInput, { target: { value: "Post Step" } });
fireEvent.change(descInput, { target: { value: "Post desc" } });
// Default phase is pre-merge; switch to post-merge
fireEvent.click(screen.getByTestId("phase-post-merge"));
fireEvent.click(screen.getByTestId("save-workflow-step"));
await waitFor(() => {
expect(createWorkflowStep).toHaveBeenCalledWith({
name: "Post Step",
description: "Post desc",
mode: "prompt",
phase: "post-merge",
prompt: undefined,
scriptName: undefined,
enabled: true,
}, undefined);
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
});
});
it("edits existing workflow step phase from pre-merge to post-merge", async () => {
vi.mocked(fetchWorkflowSteps).mockReset();
vi.mocked(fetchWorkflowSteps)
.mockResolvedValueOnce(mockSteps)
.mockResolvedValueOnce(mockSteps);
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
// Wait for steps to render (not just the fetch call)
await waitFor(() => {
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
});
// Click edit button for the first step
const editBtn = screen.getByLabelText("Edit Documentation Review");
fireEvent.click(editBtn);
expect(screen.getByTestId("workflow-step-form")).toBeInTheDocument();
// Switch phase to post-merge
fireEvent.click(screen.getByTestId("phase-post-merge"));
fireEvent.click(screen.getByTestId("save-workflow-step"));
await waitFor(() => {
expect(updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
phase: "post-merge",
}), undefined);
expect(addToast).toHaveBeenCalledWith("Workflow step updated", "success");
});
});
});
describe("WorkflowStepManager theme class structure", () => {
@@ -517,7 +602,7 @@ describe("WorkflowStepManager theme class structure", () => {
fireEvent.click(screen.getByTestId("add-workflow-step"));
expect(container.querySelector(".wfm-mode-selector")).toBeInTheDocument();
expect(container.querySelectorAll(".wfm-mode-btn")).toHaveLength(2);
expect(container.querySelectorAll(".wfm-mode-btn")).toHaveLength(4); // 2 mode + 2 phase
});
it("uses wfm-prompt-textarea class for the prompt textarea", async () => {