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 bc900a1a8c
commit c85c3e4ecd
22 changed files with 1493 additions and 83 deletions

View File

@@ -6262,6 +6262,7 @@ describe("POST /workflow-steps", () => {
name: "Docs",
description: "Check docs",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
@@ -6316,6 +6317,7 @@ describe("POST /workflow-steps", () => {
name: "Security",
description: "Security audit",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
@@ -6362,6 +6364,7 @@ describe("POST /workflow-steps", () => {
name: "Docs",
description: "Check docs",
mode: "prompt",
phase: undefined,
prompt: undefined,
scriptName: undefined,
enabled: undefined,
@@ -6389,6 +6392,7 @@ describe("POST /workflow-steps", () => {
name: "Run Tests",
description: "Execute tests",
mode: "script",
phase: undefined,
prompt: undefined,
scriptName: "test",
enabled: undefined,
@@ -6432,6 +6436,39 @@ describe("POST /workflow-steps", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode must be");
});
it("creates a workflow step with 'post-merge' phase", async () => {
const created = { id: "WS-001", name: "Post Merge", description: "After merge", mode: "prompt", phase: "post-merge", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Post Merge",
description: "After merge",
phase: "post-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(store.createWorkflowStep).toHaveBeenCalledWith({
name: "Post Merge",
description: "After merge",
mode: "prompt",
phase: "post-merge",
prompt: undefined,
scriptName: undefined,
enabled: undefined,
});
});
it("returns 400 for invalid phase value", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/workflow-steps", JSON.stringify({
name: "Test",
description: "Test",
phase: "during-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("phase must be");
});
});
describe("PATCH /workflow-steps/:id", () => {
@@ -6554,6 +6591,36 @@ describe("PATCH /workflow-steps/:id", () => {
expect(res.status).toBe(400);
expect(res.body.error).toContain("scriptName is required when mode is 'script'");
});
it("updates a workflow step phase", async () => {
const updated = { id: "WS-001", name: "Post Merge", description: "After merge", phase: "post-merge", createdAt: "2026-01-01", updatedAt: "2026-01-02" };
(store.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated);
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Pre Merge", description: "Before merge", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
phase: "post-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
phase: "post-merge",
}));
});
it("returns 400 for invalid phase value on update", async () => {
(store.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: "WS-001", name: "Test", description: "Test", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const res = await REQUEST(buildApp(), "PATCH", "/api/workflow-steps/WS-001", JSON.stringify({
phase: "during-merge",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("phase must be");
});
});
describe("DELETE /workflow-steps/:id", () => {

View File

@@ -5988,7 +5988,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.post("/workflow-steps", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { name, description, mode, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
const { name, description, mode, phase, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" });
@@ -6006,6 +6006,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
// Validate phase
if (phase !== undefined && phase !== "pre-merge" && phase !== "post-merge") {
res.status(400).json({ error: "phase must be 'pre-merge' or 'post-merge'" });
return;
}
if (prompt !== undefined && typeof prompt !== "string") {
res.status(400).json({ error: "prompt must be a string" });
return;
@@ -6047,6 +6053,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
name: name.trim(),
description: description.trim(),
mode: resolvedMode,
phase,
prompt: prompt?.trim(),
scriptName: scriptName?.trim(),
enabled,
@@ -6069,7 +6076,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.patch("/workflow-steps/:id", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const { name, description, mode, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
const { name, description, mode, phase, prompt, scriptName, enabled, modelProvider, modelId } = req.body;
const updates: Record<string, unknown> = {};
if (name !== undefined) {
@@ -6093,6 +6100,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
updates.mode = mode;
}
if (phase !== undefined) {
if (phase !== "pre-merge" && phase !== "post-merge") {
res.status(400).json({ error: "phase must be 'pre-merge' or 'post-merge'" });
return;
}
updates.phase = phase;
}
if (prompt !== undefined) {
if (typeof prompt !== "string") {
res.status(400).json({ error: "prompt must be a string" });