feat(FN-4222): add experiment-finalize CLI command and API route

Implements the `experiment-finalize` CLI command (FN-4222) and its companion dashboard API route, wiring the feature through the pi extension as a new callable tool. Includes the command implementation, extension tooling, API integration, and corresponding test coverage.

Fusion-Task-Id: FN-4222
This commit is contained in:
Fusion
2026-05-14 04:20:59 -07:00
committed by gsxdsm
parent 25234edc97
commit 81b1eae394
15 changed files with 732 additions and 2 deletions

View File

@@ -0,0 +1,88 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import { get as performGet, request as performRequest } from "../test-request.js";
const previewPlanMock = vi.hoisted(() => vi.fn());
const finalizeMock = vi.hoisted(() => vi.fn());
const mockErrors = vi.hoisted(() => ({
StateError: class extends Error { code = "state_error" as const; },
NoKeptError: class extends Error { code = "no_kept_runs" as const; },
PlanError: class extends Error { code = "plan_error" as const; },
MergeBaseError: class extends Error { code = "merge_base_error" as const; },
BranchExistsError: class extends Error { code = "branch_exists" as const; },
CherryPickError: class extends Error {
code = "cherry_pick_conflict" as const;
groupId = "g-1";
commit = "abc";
stderr = "conflict";
},
}));
vi.mock("@fusion/engine", () => ({
defaultGitOps: vi.fn(() => ({})),
ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
ExperimentFinalizeStateError: mockErrors.StateError,
ExperimentFinalizeNoKeptRunsError: mockErrors.NoKeptError,
ExperimentFinalizePlanError: mockErrors.PlanError,
ExperimentFinalizeMergeBaseError: mockErrors.MergeBaseError,
ExperimentFinalizeBranchExistsError: mockErrors.BranchExistsError,
ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickError,
}));
import { createExperimentRouter } from "../experiment-routes.js";
function appWithRouter() {
const app = express();
app.use(express.json());
app.use(createExperimentRouter({ getRootDir: () => process.cwd(), getExperimentSessionStore: () => ({}) } as any));
return app;
}
describe("experiment finalize routes", () => {
it("returns plan success", async () => {
previewPlanMock.mockResolvedValue({ sessionId: "EXP-1", groups: [], mergeBaseCommit: "mb" });
const response = await performGet(appWithRouter(), "/EXP-1/finalize/plan");
expect(response.status).toBe(200);
expect(response.body.plan.sessionId).toBe("EXP-1");
});
it("returns finalize success", async () => {
finalizeMock.mockResolvedValue({ sessionId: "EXP-1", branches: [] });
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", JSON.stringify({ summary: "done" }), { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(response.body.result.sessionId).toBe("EXP-1");
});
it("maps 404 for missing session", async () => {
previewPlanMock.mockRejectedValue(new mockErrors.StateError("session not found: EXP-x"));
const response = await performGet(appWithRouter(), "/EXP-x/finalize/plan");
expect(response.status).toBe(404);
});
it("maps plan error to 400", async () => {
finalizeMock.mockRejectedValue(new mockErrors.PlanError("bad plan"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(400);
});
it("maps merge-base error to 422", async () => {
finalizeMock.mockRejectedValue(new mockErrors.MergeBaseError("no merge base"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(422);
});
it("maps branch exists to 409", async () => {
finalizeMock.mockRejectedValue(new mockErrors.BranchExistsError("exists"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(409);
});
it("maps cherry-pick conflict details to 422", async () => {
finalizeMock.mockRejectedValue(new mockErrors.CherryPickError("conflict"));
const response = await performRequest(appWithRouter(), "POST", "/EXP-1/finalize", "{}", { "content-type": "application/json" });
expect(response.status).toBe(422);
expect(response.body.details).toMatchObject({ code: "cherry_pick_conflict", groupId: "g-1", commit: "abc", stderr: "conflict" });
});
});

View File

@@ -0,0 +1,88 @@
import { Router } from "express";
import type { ExperimentSessionStore, TaskStore } from "@fusion/core";
import {
defaultGitOps,
ExperimentFinalizeBranchExistsError,
ExperimentFinalizeCherryPickConflictError,
ExperimentFinalizeMergeBaseError,
ExperimentFinalizeNoKeptRunsError,
ExperimentFinalizePlanError,
ExperimentFinalizeService,
ExperimentFinalizeStateError,
} from "@fusion/engine";
import { ApiError, catchHandler, notFound } from "./api-error.js";
function rethrowAsApiError(error: unknown, fallback = "Failed to finalize experiment session"): never {
if (error instanceof ApiError) throw error;
if (error instanceof ExperimentFinalizeStateError || error instanceof ExperimentFinalizeNoKeptRunsError) {
throw new ApiError(409, error.message, { code: error.code });
}
if (error instanceof ExperimentFinalizePlanError) {
throw new ApiError(400, error.message, { code: error.code });
}
if (error instanceof ExperimentFinalizeMergeBaseError) {
throw new ApiError(422, error.message, { code: error.code });
}
if (error instanceof ExperimentFinalizeBranchExistsError) {
throw new ApiError(409, error.message, { code: error.code });
}
if (error instanceof ExperimentFinalizeCherryPickConflictError) {
throw new ApiError(422, error.message, {
code: error.code,
groupId: error.groupId,
commit: error.commit,
stderr: error.stderr,
});
}
if (error instanceof Error) throw new ApiError(500, error.message);
throw new ApiError(500, fallback);
}
export function createExperimentRouter(store: TaskStore): Router {
const router = Router();
const sessionStore = (store as { getExperimentSessionStore?: () => ExperimentSessionStore }).getExperimentSessionStore?.();
if (!sessionStore) {
return router;
}
const service = new ExperimentFinalizeService({
store: sessionStore,
git: defaultGitOps(store.getRootDir()),
});
router.get("/:id/finalize/plan", catchHandler(async (req, res) => {
try {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const plan = await service.previewPlan({
sessionId,
integrationBranch: typeof req.query.integrationBranch === "string" ? req.query.integrationBranch : undefined,
});
res.status(200).json({ plan });
} catch (error) {
if (error instanceof ExperimentFinalizeStateError && /not found/i.test(error.message)) {
throw notFound(error.message);
}
rethrowAsApiError(error, "Failed to preview finalize plan");
}
}));
router.post("/:id/finalize", catchHandler(async (req, res) => {
try {
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const result = await service.finalize({
sessionId,
integrationBranch: typeof req.body?.integrationBranch === "string" ? req.body.integrationBranch : undefined,
planOverride: req.body?.planOverride,
summary: typeof req.body?.summary === "string" ? req.body.summary : undefined,
});
res.status(200).json({ result });
} catch (error) {
if (error instanceof ExperimentFinalizeStateError && /not found/i.test(error.message)) {
throw notFound(error.message);
}
rethrowAsApiError(error);
}
}));
return router;
}

View File

@@ -5,6 +5,7 @@ import { createMissionRouter } from "../mission-routes.js";
import { createInsightsRouter } from "../insights-routes.js";
import { createEvalsRouter } from "../evals-routes.js";
import { createResearchRouter } from "../research-routes.js";
import { createExperimentRouter } from "../experiment-routes.js";
import { createTodoRouter } from "../todo-routes.js";
import { createRoadmapCompatibilityRouter } from "../roadmap-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js";
@@ -37,6 +38,7 @@ export function registerIntegratedRouters({
router.use("/insights", createInsightsRouter(store));
router.use("/evals", createEvalsRouter(store));
router.use("/research", createResearchRouter(store));
router.use("/experiments", createExperimentRouter(store));
router.use("/todos", createTodoRouter(store));
router.use("/roadmaps", createRoadmapCompatibilityRouter(store));
router.use("/stash-recovery", createStashRecoveryRouter(store));