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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
88
packages/dashboard/src/experiment-routes.ts
Normal file
88
packages/dashboard/src/experiment-routes.ts
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user