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:
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user