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:
@@ -33,7 +33,7 @@ Mission → Milestone → Slice → Feature → Task
|
||||
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
|
||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`
|
||||
- **Other tools** — `fn_web_fetch`, `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`
|
||||
- **Other tools** — `fn_web_fetch`, `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`, `fn_experiment_finalize`
|
||||
<!-- END: tool-categories -->
|
||||
- **Dashboard** — Use `/fn` command to start/stop the dashboard
|
||||
|
||||
|
||||
@@ -448,6 +448,18 @@ Cited-research pipeline: retry a failed run when lifecycle marks it retryable (n
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Research run ID |
|
||||
|
||||
### fn_experiment_finalize
|
||||
|
||||
Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `sessionId` | string | ✓ | Experiment session ID |
|
||||
| `integrationBranch` | string | — | Integration branch to compute merge-base against (default: main) |
|
||||
| `dryRun` | boolean | — | Preview plan only; do not create branches |
|
||||
| `planOverride` | unknown | — | Optional plan override payload |
|
||||
| `summary` | string | — | Optional finalize summary |
|
||||
|
||||
<!-- END: extension-tools -->
|
||||
## Dashboard Command
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
|
||||
| `fn_research_get` | Cited-research pipeline: get one run with structured findings and citations (not experiment-loop state). |
|
||||
| `fn_research_cancel` | Cited-research pipeline: cancel an in-flight run; terminal runs return INVALID_TRANSITION (does not control experiment loops). |
|
||||
| `fn_research_retry` | Cited-research pipeline: retry a failed run when lifecycle marks it retryable (not an autonomous experiment loop retry). |
|
||||
| `fn_experiment_finalize` | Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git. |
|
||||
| `fn_insight_list` | List persisted project insights with optional category/status filters. |
|
||||
| `fn_insight_show` | Show a single persisted insight by ID. |
|
||||
| `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. |
|
||||
|
||||
97
packages/cli/src/__tests__/experiment-finalize.test.ts
Normal file
97
packages/cli/src/__tests__/experiment-finalize.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeFile, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const previewPlan = vi.fn();
|
||||
const finalize = vi.fn();
|
||||
const init = vi.fn();
|
||||
|
||||
const mockErrors = vi.hoisted(() => ({
|
||||
CherryPickConflictError: class extends Error {
|
||||
code = "cherry_pick_conflict" as const;
|
||||
groupId = "g1";
|
||||
commit = "abc";
|
||||
stderr = "conflict";
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
createDatabase: vi.fn(() => ({ init })),
|
||||
ExperimentSessionStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
defaultGitOps: vi.fn(() => ({})),
|
||||
ExperimentFinalizeService: vi.fn(() => ({ previewPlan, finalize })),
|
||||
ExperimentFinalizeStateError: class extends Error { code = "state_error" as const; },
|
||||
ExperimentFinalizeNoKeptRunsError: class extends Error { code = "no_kept_runs" as const; },
|
||||
ExperimentFinalizePlanError: class extends Error { code = "plan_error" as const; },
|
||||
ExperimentFinalizeMergeBaseError: class extends Error { code = "merge_base_error" as const; },
|
||||
ExperimentFinalizeBranchExistsError: class extends Error { code = "branch_exists" as const; },
|
||||
ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickConflictError,
|
||||
}));
|
||||
|
||||
import { runExperimentFinalize } from "../commands/experiment-finalize.js";
|
||||
|
||||
describe("runExperimentFinalize", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
init.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("dry-run calls previewPlan and not finalize", async () => {
|
||||
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runExperimentFinalize({ sessionId: "EXP-1", dryRun: true });
|
||||
|
||||
expect(previewPlan).toHaveBeenCalledWith({ sessionId: "EXP-1", integrationBranch: undefined });
|
||||
expect(finalize).not.toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plan-file loads override and passes to finalize", async () => {
|
||||
finalize.mockResolvedValue({ sessionId: "EXP-1", branches: [] });
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "fn-4222-"));
|
||||
const planPath = join(tempDir, "plan.json");
|
||||
await writeFile(planPath, JSON.stringify({ groups: [{ runRecordIds: ["RUN-1"] }] }), "utf8");
|
||||
|
||||
await runExperimentFinalize({ sessionId: "EXP-1", planFile: planPath });
|
||||
|
||||
expect(finalize).toHaveBeenCalledWith(expect.objectContaining({ planOverride: { groups: [{ runRecordIds: ["RUN-1"] }] } }));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("cherry-pick conflict exits with code 6", async () => {
|
||||
finalize.mockRejectedValue(new mockErrors.CherryPickConflictError("conflict"));
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(runExperimentFinalize({ sessionId: "EXP-1" })).rejects.toThrow("exit:6");
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("json output is parseable", async () => {
|
||||
previewPlan.mockResolvedValue({ sessionId: "EXP-1", mergeBaseCommit: "mb", groups: [] });
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await runExperimentFinalize({ sessionId: "EXP-1", dryRun: true, json: true });
|
||||
|
||||
expect(() => JSON.parse((logSpy.mock.calls[0] ?? ["{}"])[0] as string)).not.toThrow();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("unexpected errors exit with code 1", async () => {
|
||||
finalize.mockRejectedValue(new Error("boom"));
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
|
||||
|
||||
await expect(runExperimentFinalize({ sessionId: "EXP-1" })).rejects.toThrow("exit:1");
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
120
packages/cli/src/__tests__/extension-experiment-finalize.test.ts
Normal file
120
packages/cli/src/__tests__/extension-experiment-finalize.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
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/core", () => ({
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
getExperimentSessionStore: vi.fn(() => ({})),
|
||||
})),
|
||||
COLUMNS: [],
|
||||
COLUMN_LABELS: {},
|
||||
validateNodeOverrideChange: vi.fn(),
|
||||
RESEARCH_RUN_STATUSES: [],
|
||||
isResearchExperimentalEnabled: vi.fn(() => true),
|
||||
resolveResearchSettings: vi.fn(() => ({})),
|
||||
canAgentTakeImplementationTaskForExplicitRouting: vi.fn(() => true),
|
||||
formatRoleMismatchReason: vi.fn(() => ""),
|
||||
resolveAgentProvisioningPolicy: vi.fn(() => ({ approvalMode: "auto" })),
|
||||
TASK_PRIORITIES: ["low", "normal", "high", "urgent"],
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
fetchWebContent: vi.fn(),
|
||||
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 kbExtension from "../extension.js";
|
||||
|
||||
describe("extension fn_experiment_finalize", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function getTool() {
|
||||
const tools = new Map<string, any>();
|
||||
kbExtension({
|
||||
registerTool(def: any) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand: vi.fn(),
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on: vi.fn(),
|
||||
} as any);
|
||||
return tools.get("fn_experiment_finalize");
|
||||
}
|
||||
|
||||
it("supports dry-run preview", async () => {
|
||||
const tool = getTool();
|
||||
previewPlanMock.mockResolvedValue({ sessionId: "EXP-1", groups: [], mergeBaseCommit: "abc" });
|
||||
|
||||
const result = await tool.execute("id", { sessionId: "EXP-1", dryRun: true }, undefined, undefined, { cwd: process.cwd() });
|
||||
|
||||
expect(previewPlanMock).toHaveBeenCalledWith({ sessionId: "EXP-1", integrationBranch: undefined });
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.details.plan.sessionId).toBe("EXP-1");
|
||||
});
|
||||
|
||||
it("supports finalize success", async () => {
|
||||
const tool = getTool();
|
||||
finalizeMock.mockResolvedValue({ sessionId: "EXP-2", branches: [{ name: "b1" }] });
|
||||
|
||||
const result = await tool.execute("id", { sessionId: "EXP-2", summary: "done" }, undefined, undefined, { cwd: process.cwd() });
|
||||
|
||||
expect(finalizeMock).toHaveBeenCalled();
|
||||
expect(result.content[0].text).toContain("Finalized EXP-2");
|
||||
});
|
||||
|
||||
it("surfaces no kept runs error", async () => {
|
||||
const tool = getTool();
|
||||
finalizeMock.mockRejectedValue(new mockErrors.NoKeptError("no kept"));
|
||||
|
||||
const result = await tool.execute("id", { sessionId: "EXP-3" }, undefined, undefined, { cwd: process.cwd() });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details.code).toBe("no_kept_runs");
|
||||
});
|
||||
|
||||
it("surfaces cherry-pick conflict details", async () => {
|
||||
const tool = getTool();
|
||||
finalizeMock.mockRejectedValue(new mockErrors.CherryPickError("conflict"));
|
||||
|
||||
const result = await tool.execute("id", { sessionId: "EXP-4" }, undefined, undefined, { cwd: process.cwd() });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.details).toMatchObject({ code: "cherry_pick_conflict", groupId: "g-1", commit: "abc", stderr: "conflict" });
|
||||
});
|
||||
|
||||
it("returns tool result contract", async () => {
|
||||
const tool = getTool();
|
||||
previewPlanMock.mockResolvedValue({ sessionId: "EXP-5", groups: [], mergeBaseCommit: "abc" });
|
||||
|
||||
const result = await tool.execute("id", { sessionId: "EXP-5", dryRun: true }, undefined, undefined, { cwd: process.cwd() });
|
||||
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
expect(typeof result.content[0].text).toBe("string");
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,7 @@ async function loadCommandHandlers() {
|
||||
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
|
||||
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
|
||||
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
|
||||
const { runExperimentFinalize } = await import("./commands/experiment-finalize.js");
|
||||
const { runUpdate } = await import("./commands/update.js");
|
||||
|
||||
return {
|
||||
@@ -235,6 +236,7 @@ async function loadCommandHandlers() {
|
||||
runResearchExport,
|
||||
runResearchCancel,
|
||||
runResearchRetry,
|
||||
runExperimentFinalize,
|
||||
runUpdate,
|
||||
runChatInteractive,
|
||||
};
|
||||
@@ -605,6 +607,7 @@ async function main() {
|
||||
runResearchExport,
|
||||
runResearchCancel,
|
||||
runResearchRetry,
|
||||
runExperimentFinalize,
|
||||
runUpdate,
|
||||
runChatInteractive,
|
||||
} = await loadCommandHandlers();
|
||||
@@ -916,6 +919,34 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "experiment": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "finalize": {
|
||||
const sessionId = args[2];
|
||||
if (!sessionId) {
|
||||
console.error("Usage: fn experiment finalize <sessionId> [--integration-branch <name>] [--dry-run] [--json] [--summary <text>] [--plan-file <path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runExperimentFinalize({
|
||||
sessionId,
|
||||
integrationBranch: getFlagValue(args, "--integration-branch") ?? "main",
|
||||
dryRun: args.includes("--dry-run"),
|
||||
json: args.includes("--json"),
|
||||
summary: getFlagValue(args, "--summary"),
|
||||
planFile: getFlagValue(args, "--plan-file"),
|
||||
projectName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: experiment ${subcommand || ""}`);
|
||||
console.log("Try: fn experiment finalize <session-id>");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "task": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
|
||||
127
packages/cli/src/commands/experiment-finalize.ts
Normal file
127
packages/cli/src/commands/experiment-finalize.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
createDatabase,
|
||||
ExperimentSessionStore,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
defaultGitOps,
|
||||
ExperimentFinalizeBranchExistsError,
|
||||
ExperimentFinalizeCherryPickConflictError,
|
||||
ExperimentFinalizeMergeBaseError,
|
||||
ExperimentFinalizeNoKeptRunsError,
|
||||
ExperimentFinalizePlanError,
|
||||
ExperimentFinalizeService,
|
||||
ExperimentFinalizeStateError,
|
||||
type FinalizePlanOverride,
|
||||
} from "@fusion/engine";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
interface ExperimentFinalizeOptions {
|
||||
sessionId: string;
|
||||
integrationBranch?: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
summary?: string;
|
||||
planFile?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
const EXIT_CODES = new Map<string, number>([
|
||||
["state_error", 2],
|
||||
["no_kept_runs", 3],
|
||||
["plan_error", 4],
|
||||
["merge_base_error", 5],
|
||||
["cherry_pick_conflict", 6],
|
||||
["branch_exists", 7],
|
||||
]);
|
||||
|
||||
function printJson(payload: unknown): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
function printPlan(plan: Awaited<ReturnType<ExperimentFinalizeService["previewPlan"]>>): void {
|
||||
console.log(`Session: ${plan.sessionId}`);
|
||||
console.log(`Merge base: ${plan.mergeBaseCommit}`);
|
||||
for (const group of plan.groups) {
|
||||
console.log(`- ${group.title} -> ${group.suggestedBranchName} (${group.commits.length} commits)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function parsePlanOverride(path: string): Promise<FinalizePlanOverride> {
|
||||
const content = await readFile(resolve(path), "utf8");
|
||||
return JSON.parse(content) as FinalizePlanOverride;
|
||||
}
|
||||
|
||||
function exitWithError(error: unknown): never {
|
||||
if (error instanceof ExperimentFinalizeCherryPickConflictError) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
console.error(JSON.stringify({ groupId: error.groupId, commit: error.commit, stderr: error.stderr }));
|
||||
process.exit(6);
|
||||
}
|
||||
const code = (error as { code?: string })?.code;
|
||||
if (code && EXIT_CODES.has(code)) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(EXIT_CODES.get(code)!);
|
||||
}
|
||||
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export async function runExperimentFinalize(options: ExperimentFinalizeOptions): Promise<void> {
|
||||
try {
|
||||
const project = options.projectName ? await resolveProject(options.projectName) : undefined;
|
||||
const projectRoot = project?.projectPath ?? process.cwd();
|
||||
const db = createDatabase(resolve(projectRoot, ".fusion"));
|
||||
await db.init();
|
||||
const store = new ExperimentSessionStore(db);
|
||||
const service = new ExperimentFinalizeService({
|
||||
store,
|
||||
git: defaultGitOps(projectRoot),
|
||||
});
|
||||
|
||||
const planOverride = options.planFile ? await parsePlanOverride(options.planFile) : undefined;
|
||||
|
||||
if (options.dryRun) {
|
||||
const plan = await service.previewPlan({
|
||||
sessionId: options.sessionId,
|
||||
integrationBranch: options.integrationBranch,
|
||||
});
|
||||
if (options.json) {
|
||||
printJson({ plan });
|
||||
} else {
|
||||
printPlan(plan);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await service.finalize({
|
||||
sessionId: options.sessionId,
|
||||
integrationBranch: options.integrationBranch,
|
||||
planOverride,
|
||||
summary: options.summary,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
printJson({ result });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Finalized session ${result.sessionId}`);
|
||||
for (const branch of result.branches) {
|
||||
console.log(`- ${branch.name} (${branch.tipCommit})`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ExperimentFinalizeStateError
|
||||
|| error instanceof ExperimentFinalizeNoKeptRunsError
|
||||
|| error instanceof ExperimentFinalizePlanError
|
||||
|| error instanceof ExperimentFinalizeMergeBaseError
|
||||
|| error instanceof ExperimentFinalizeBranchExistsError
|
||||
|| error instanceof ExperimentFinalizeCherryPickConflictError
|
||||
) {
|
||||
exitWithError(error);
|
||||
}
|
||||
exitWithError(error);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatRoleMismatchReason,
|
||||
resolveAgentProvisioningPolicy,
|
||||
TASK_PRIORITIES,
|
||||
type ExperimentSessionStore,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
getGhErrorMessage,
|
||||
@@ -28,7 +29,18 @@ import {
|
||||
isGhAvailable,
|
||||
runGhJsonAsync,
|
||||
} from "@fusion/core/gh-cli";
|
||||
import { fetchWebContent } from "@fusion/engine";
|
||||
import {
|
||||
defaultGitOps,
|
||||
ExperimentFinalizeBranchExistsError,
|
||||
ExperimentFinalizeCherryPickConflictError,
|
||||
ExperimentFinalizeMergeBaseError,
|
||||
ExperimentFinalizeNoKeptRunsError,
|
||||
ExperimentFinalizePlanError,
|
||||
ExperimentFinalizeService,
|
||||
ExperimentFinalizeStateError,
|
||||
type FinalizePlanOverride,
|
||||
fetchWebContent,
|
||||
} from "@fusion/engine";
|
||||
import { resolve, basename, extname, join } from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -1725,6 +1737,83 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_experiment_finalize",
|
||||
label: "fn: Finalize Experiment Session",
|
||||
description: "Group kept experiment runs into reviewable branches and finalize the session. Use dryRun=true to preview the plan without touching git.",
|
||||
parameters: Type.Object({
|
||||
sessionId: Type.String({ description: "Experiment session ID" }),
|
||||
integrationBranch: Type.Optional(Type.String({ description: "Integration branch to compute merge-base against (default: main)" })),
|
||||
dryRun: Type.Optional(Type.Boolean({ description: "Preview plan only; do not create branches" })),
|
||||
planOverride: Type.Optional(Type.Any({ description: "Optional plan override payload" })),
|
||||
summary: Type.Optional(Type.String({ description: "Optional finalize summary" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const sessionStore = (store as { getExperimentSessionStore?: () => ExperimentSessionStore }).getExperimentSessionStore?.();
|
||||
if (!sessionStore) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Experiment session store is unavailable in this project." }],
|
||||
isError: true,
|
||||
details: { code: "STORE_UNAVAILABLE" },
|
||||
};
|
||||
}
|
||||
const service = new ExperimentFinalizeService({
|
||||
store: sessionStore,
|
||||
git: defaultGitOps(resolveProjectRoot(ctx.cwd)),
|
||||
});
|
||||
|
||||
if (params.dryRun) {
|
||||
const plan = await service.previewPlan({
|
||||
sessionId: params.sessionId,
|
||||
integrationBranch: params.integrationBranch,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Finalize plan for ${plan.sessionId}: ${plan.groups.length} group(s), merge-base ${plan.mergeBaseCommit}.` }],
|
||||
details: { plan },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await service.finalize({
|
||||
sessionId: params.sessionId,
|
||||
integrationBranch: params.integrationBranch,
|
||||
planOverride: params.planOverride as FinalizePlanOverride | undefined,
|
||||
summary: params.summary,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Finalized ${result.sessionId}. Created ${result.branches.length} branch(es).` }],
|
||||
details: { result },
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ExperimentFinalizeStateError
|
||||
|| error instanceof ExperimentFinalizeNoKeptRunsError
|
||||
|| error instanceof ExperimentFinalizePlanError
|
||||
|| error instanceof ExperimentFinalizeMergeBaseError
|
||||
|| error instanceof ExperimentFinalizeBranchExistsError
|
||||
|| error instanceof ExperimentFinalizeCherryPickConflictError
|
||||
) {
|
||||
return {
|
||||
content: [{ type: "text", text: error.message }],
|
||||
isError: true,
|
||||
details: {
|
||||
code: error.code,
|
||||
...(error instanceof ExperimentFinalizeCherryPickConflictError
|
||||
? { groupId: error.groupId, commit: error.commit, stderr: error.stderr }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: error instanceof Error ? error.message : "Unknown experiment finalize error" }],
|
||||
isError: true,
|
||||
details: { code: "INTERNAL_ERROR" },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Insights Tools ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -21,6 +21,13 @@ function createGitMock(): GitOps {
|
||||
stashPush: vi.fn(),
|
||||
stashPop: vi.fn(),
|
||||
statusPorcelain: vi.fn(),
|
||||
mergeBase: vi.fn(),
|
||||
branchExists: vi.fn(),
|
||||
createBranch: vi.fn(),
|
||||
cherryPick: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
currentBranch: vi.fn(),
|
||||
deleteBranch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ function createGitMock(): GitOps {
|
||||
stashPush: vi.fn(),
|
||||
stashPop: vi.fn(),
|
||||
statusPorcelain: vi.fn(),
|
||||
mergeBase: vi.fn(),
|
||||
branchExists: vi.fn(),
|
||||
createBranch: vi.fn(),
|
||||
cherryPick: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
currentBranch: vi.fn(),
|
||||
deleteBranch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user