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:
@@ -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, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment } from "./types.js";
|
||||
export { AgentStore } from "./agent-store.js";
|
||||
export type { AgentStoreEvents } from "./agent-store.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
|
||||
@@ -4871,6 +4871,81 @@ Task with acceptance criteria
|
||||
|
||||
expect(task.enabledWorkflowSteps).toEqual(["WS-001", "WS-002"]);
|
||||
});
|
||||
|
||||
// ── Workflow Step Phase ──────────────────────────────────────────────
|
||||
|
||||
it("should default phase to 'pre-merge' when creating a workflow step", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Pre-merge Check",
|
||||
description: "Runs before merge",
|
||||
});
|
||||
|
||||
expect(ws.phase).toBe("pre-merge");
|
||||
});
|
||||
|
||||
it("should create a workflow step with explicit 'post-merge' phase", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Post-merge Notify",
|
||||
description: "Runs after merge",
|
||||
phase: "post-merge",
|
||||
});
|
||||
|
||||
expect(ws.phase).toBe("post-merge");
|
||||
});
|
||||
|
||||
it("should create a workflow step with explicit 'pre-merge' phase", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Pre-merge Gate",
|
||||
description: "Runs before merge",
|
||||
phase: "pre-merge",
|
||||
});
|
||||
|
||||
expect(ws.phase).toBe("pre-merge");
|
||||
});
|
||||
|
||||
it("should update a workflow step phase from pre-merge to post-merge", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Phase Switch",
|
||||
description: "Will switch phase",
|
||||
});
|
||||
|
||||
expect(ws.phase).toBe("pre-merge");
|
||||
|
||||
const updated = await store.updateWorkflowStep(ws.id, { phase: "post-merge" });
|
||||
expect(updated.phase).toBe("post-merge");
|
||||
});
|
||||
|
||||
it("should persist phase across list/get", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Phase Persist",
|
||||
description: "Check phase persistence",
|
||||
phase: "post-merge",
|
||||
});
|
||||
|
||||
const listed = await store.listWorkflowSteps();
|
||||
expect(listed[0].phase).toBe("post-merge");
|
||||
|
||||
const found = await store.getWorkflowStep(ws.id);
|
||||
expect(found!.phase).toBe("post-merge");
|
||||
});
|
||||
|
||||
it("should normalize legacy workflow steps without phase to pre-merge", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Legacy Step",
|
||||
description: "Pre-existing step",
|
||||
prompt: "Review the code.",
|
||||
});
|
||||
|
||||
// Simulate legacy data by removing phase from the stored step
|
||||
const config = await (store as any).readConfig();
|
||||
delete config.workflowSteps[0].phase;
|
||||
await (store as any).writeConfig(config);
|
||||
|
||||
// Re-read: phase should be undefined (legacy), but when used by engine
|
||||
// it should be treated as "pre-merge"
|
||||
const found = await store.getWorkflowStep(ws.id);
|
||||
expect(found!.phase).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Title Summarization Tests ────────────────────────────────────────────
|
||||
|
||||
@@ -2587,6 +2587,7 @@ ${stepsSection}`;
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
mode,
|
||||
phase: input.phase || "pre-merge",
|
||||
prompt: mode === "prompt" ? (input.prompt || "") : "",
|
||||
scriptName: mode === "script" ? input.scriptName : undefined,
|
||||
enabled: input.enabled !== undefined ? input.enabled : true,
|
||||
@@ -2662,6 +2663,7 @@ ${stepsSection}`;
|
||||
|
||||
if (updates.name !== undefined) step.name = updates.name;
|
||||
if (updates.description !== undefined) step.description = updates.description;
|
||||
if (updates.phase !== undefined) step.phase = updates.phase;
|
||||
if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt;
|
||||
if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName;
|
||||
if (updates.enabled !== undefined) step.enabled = updates.enabled;
|
||||
|
||||
183
packages/core/src/task-merge.test.ts
Normal file
183
packages/core/src/task-merge.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { StepStatus } from "./types.js";
|
||||
import { getTaskMergeBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
||||
|
||||
const baseTask = {
|
||||
column: "in-review" as const,
|
||||
paused: false,
|
||||
status: undefined as string | undefined,
|
||||
error: undefined as string | undefined,
|
||||
steps: [] as Array<{ name: string; status: StepStatus }>,
|
||||
workflowStepResults: undefined as any,
|
||||
};
|
||||
|
||||
describe("getTaskMergeBlocker", () => {
|
||||
it("returns undefined for a clean task in review", () => {
|
||||
expect(getTaskMergeBlocker(baseTask)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns reason when task is not in review", () => {
|
||||
expect(getTaskMergeBlocker({ ...baseTask, column: "todo" }))
|
||||
.toContain("must be in 'in-review'");
|
||||
});
|
||||
|
||||
it("returns reason when task is paused", () => {
|
||||
expect(getTaskMergeBlocker({ ...baseTask, paused: true }))
|
||||
.toBe("task is paused");
|
||||
});
|
||||
|
||||
it("returns reason when task has failed status", () => {
|
||||
expect(getTaskMergeBlocker({ ...baseTask, status: "failed" }))
|
||||
.toContain("failed");
|
||||
});
|
||||
|
||||
it("returns reason when task has incomplete steps", () => {
|
||||
expect(getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
steps: [{ name: "Step 1", status: "in-progress" }],
|
||||
})).toBe("task has incomplete steps");
|
||||
});
|
||||
|
||||
// ── Workflow Step Phase Awareness ──────────────────────────────────────
|
||||
|
||||
it("blocks merge when pre-merge workflow step has failed", () => {
|
||||
const result = getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Pre-merge Check",
|
||||
phase: "pre-merge",
|
||||
status: "failed",
|
||||
output: "Check failed",
|
||||
}],
|
||||
});
|
||||
expect(result).toContain("pre-merge workflow steps");
|
||||
});
|
||||
|
||||
it("blocks merge when legacy workflow step (no phase) has failed", () => {
|
||||
const result = getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Legacy Check",
|
||||
// phase is undefined → treated as pre-merge
|
||||
status: "failed",
|
||||
output: "Check failed",
|
||||
}],
|
||||
});
|
||||
expect(result).toContain("pre-merge workflow steps");
|
||||
});
|
||||
|
||||
it("does NOT block merge when only post-merge workflow step has failed", () => {
|
||||
const result = getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Post-merge Notify",
|
||||
phase: "post-merge",
|
||||
status: "failed",
|
||||
output: "Notification failed",
|
||||
}],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT block merge when pre-merge passed and post-merge failed", () => {
|
||||
const result = getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Pre-merge Check",
|
||||
phase: "pre-merge",
|
||||
status: "passed",
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Post-merge Notify",
|
||||
phase: "post-merge",
|
||||
status: "failed",
|
||||
output: "Failed",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks merge when pre-merge step is still pending", () => {
|
||||
const result = getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Pre-merge Check",
|
||||
phase: "pre-merge",
|
||||
status: "pending",
|
||||
}],
|
||||
});
|
||||
expect(result).toContain("pre-merge workflow steps");
|
||||
});
|
||||
|
||||
it("does NOT block merge when only post-merge step is pending", () => {
|
||||
const result = getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Post-merge Notify",
|
||||
phase: "post-merge",
|
||||
status: "pending",
|
||||
}],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows merge when all pre-merge steps passed regardless of post-merge status", () => {
|
||||
const result = getTaskMergeBlocker({
|
||||
...baseTask,
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Pre-merge Check",
|
||||
phase: "pre-merge",
|
||||
status: "passed",
|
||||
},
|
||||
{
|
||||
workflowStepId: "WS-002",
|
||||
workflowStepName: "Post-merge Verify",
|
||||
phase: "post-merge",
|
||||
status: "skipped",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTaskReadyForMerge", () => {
|
||||
it("returns true for a clean task in review", () => {
|
||||
expect(isTaskReadyForMerge(baseTask)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when pre-merge step failed", () => {
|
||||
expect(isTaskReadyForMerge({
|
||||
...baseTask,
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Check",
|
||||
phase: "pre-merge",
|
||||
status: "failed",
|
||||
}],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when only post-merge step failed", () => {
|
||||
expect(isTaskReadyForMerge({
|
||||
...baseTask,
|
||||
workflowStepResults: [{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Notify",
|
||||
phase: "post-merge",
|
||||
status: "failed",
|
||||
}],
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ const NON_TERMINAL_STEP_STATUSES = new Set([
|
||||
|
||||
const NON_TERMINAL_WORKFLOW_STATUSES = new Set<WorkflowStepResult["status"]>([
|
||||
"pending",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -40,10 +39,24 @@ export function getTaskMergeBlocker(
|
||||
return "task has incomplete steps";
|
||||
}
|
||||
|
||||
// Only pre-merge workflow step failures block merge.
|
||||
// Post-merge failures run after merge and do not block it.
|
||||
if (
|
||||
task.workflowStepResults?.some((result) => NON_TERMINAL_WORKFLOW_STATUSES.has(result.status))
|
||||
task.workflowStepResults?.some((result) => {
|
||||
const phase = result.phase || "pre-merge";
|
||||
return phase === "pre-merge" && NON_TERMINAL_WORKFLOW_STATUSES.has(result.status);
|
||||
})
|
||||
) {
|
||||
return "task has incomplete or failed workflow steps";
|
||||
return "task has incomplete or failed pre-merge workflow steps";
|
||||
}
|
||||
|
||||
if (
|
||||
task.workflowStepResults?.some((result) => {
|
||||
const phase = result.phase || "pre-merge";
|
||||
return phase === "pre-merge" && result.status === "failed";
|
||||
})
|
||||
) {
|
||||
return "task has failed pre-merge workflow steps";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface ModelPreset {
|
||||
/** Execution mode for a workflow step. */
|
||||
export type WorkflowStepMode = "prompt" | "script";
|
||||
|
||||
/** Lifecycle phase for workflow step execution. */
|
||||
export type WorkflowStepPhase = "pre-merge" | "post-merge";
|
||||
|
||||
export interface WorkflowStep {
|
||||
/** Unique identifier (e.g., "WS-001") */
|
||||
id: string;
|
||||
@@ -56,6 +59,8 @@ export interface WorkflowStep {
|
||||
description: string;
|
||||
/** Execution mode — "prompt" runs an AI agent, "script" runs a named project script */
|
||||
mode: WorkflowStepMode;
|
||||
/** Lifecycle phase — "pre-merge" runs before merge (default), "post-merge" runs after merge success */
|
||||
phase?: WorkflowStepPhase;
|
||||
/** Full agent prompt to execute when this step runs (used when mode is "prompt") */
|
||||
prompt: string;
|
||||
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
|
||||
@@ -88,6 +93,8 @@ export interface WorkflowStepInput {
|
||||
description: string;
|
||||
/** Execution mode — defaults to "prompt" if not specified */
|
||||
mode?: WorkflowStepMode;
|
||||
/** Lifecycle phase — defaults to "pre-merge" if not specified */
|
||||
phase?: WorkflowStepPhase;
|
||||
/** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */
|
||||
prompt?: string;
|
||||
/** Script name from project settings (required when mode is "script").
|
||||
@@ -110,6 +117,8 @@ export interface WorkflowStepResult {
|
||||
workflowStepId: string;
|
||||
/** Name of the workflow step at execution time */
|
||||
workflowStepName: string;
|
||||
/** Lifecycle phase at execution time */
|
||||
phase?: WorkflowStepPhase;
|
||||
/** Execution status */
|
||||
status: "passed" | "failed" | "skipped" | "pending";
|
||||
/** Output from the workflow step agent (findings, errors, etc.) */
|
||||
|
||||
Reference in New Issue
Block a user