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,5 @@
---
"@runfusion/fusion": patch
---
Add experiment session finalize workflow across engine, CLI, extension, and dashboard API. The workflow previews and finalizes kept experiment runs into reviewable branches from merge-base, with typed error mapping for CLI/API consumers and rollback on partial branch creation failures.

View File

@@ -0,0 +1,56 @@
# Experiment Session Finalize Workflow
## Motivation
FN-4136 identified a gap versus upstream `autoresearch-finalize`: Fusion had no workflow to split kept experiment commits into reviewable branches from a shared merge-base.
## Plan model
Finalize builds groups of kept run records, each with ordered commits and a suggested branch name. Branch names default to:
`experiment/<session-id>/<slug>-<index>`
Override schema:
```json
{
"groups": [
{
"id": "segment:1",
"title": "Latency fixes",
"suggestedBranchName": "experiment/exp-1/latency-fixes-1",
"runRecordIds": ["RUN-2", "RUN-5"]
}
]
}
```
## Algorithm
1. Resolve merge-base from session baseline ref and integration branch.
2. Build default plan from kept runs.
3. Apply optional override and validate run IDs / branch uniqueness.
4. For each group: create branch at merge-base, checkout, cherry-pick commits in order.
5. Restore original ref.
6. Append `finalize` session record and move session to `finalized`.
7. On failure, rollback created branches and restore original ref; session remains `finalizing`.
## Error taxonomy
| Error code | CLI exit | HTTP status |
|---|---:|---:|
| `state_error` | 2 | 409 |
| `no_kept_runs` | 3 | 409 |
| `plan_error` | 4 | 400 |
| `merge_base_error` | 5 | 422 |
| `cherry_pick_conflict` | 6 | 422 |
| `branch_exists` | 7 | 409 |
| unknown/untyped | 1 | 500 |
## Recovery
If finalize fails after session status moves to `finalizing`:
1. Inspect and delete leftover branches (`git branch -D <name>`).
2. Restore session status manually via store update to `active`.
3. Re-run finalize (optionally with a plan override).
## Follow-ups
- FN-4221: dashboard finalize UI wiring.
- FN-4223: naming clarifications around research/experiment command surfaces.
- Upstream reference: `autoresearch-finalize` branch-split semantics.

View File

@@ -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` - **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` - **Skills tools** — `fn_skills_search`, `fn_skills_install`
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show` - **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 --> <!-- END: tool-categories -->
- **Dashboard** — Use `/fn` command to start/stop the dashboard - **Dashboard** — Use `/fn` command to start/stop the dashboard

View File

@@ -448,6 +448,18 @@ Cited-research pipeline: retry a failed run when lifecycle marks it retryable (n
|-----------|------|----------|-------------| |-----------|------|----------|-------------|
| `id` | string | ✓ | Research run ID | | `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 --> <!-- END: extension-tools -->
## Dashboard Command ## Dashboard Command

View File

@@ -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_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_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_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_list` | List persisted project insights with optional category/status filters. |
| `fn_insight_show` | Show a single persisted insight by ID. | | `fn_insight_show` | Show a single persisted insight by ID. |
| `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. | | `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. |

View 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();
});
});

View 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");
});
});

View File

@@ -139,6 +139,7 @@ async function loadCommandHandlers() {
const { runPluginCreate } = await import("./commands/plugin-scaffold.js"); const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js"); const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.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"); const { runUpdate } = await import("./commands/update.js");
return { return {
@@ -235,6 +236,7 @@ async function loadCommandHandlers() {
runResearchExport, runResearchExport,
runResearchCancel, runResearchCancel,
runResearchRetry, runResearchRetry,
runExperimentFinalize,
runUpdate, runUpdate,
runChatInteractive, runChatInteractive,
}; };
@@ -605,6 +607,7 @@ async function main() {
runResearchExport, runResearchExport,
runResearchCancel, runResearchCancel,
runResearchRetry, runResearchRetry,
runExperimentFinalize,
runUpdate, runUpdate,
runChatInteractive, runChatInteractive,
} = await loadCommandHandlers(); } = await loadCommandHandlers();
@@ -916,6 +919,34 @@ async function main() {
break; 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": { case "task": {
const subcommand = args[1]; const subcommand = args[1];
switch (subcommand) { switch (subcommand) {

View 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);
}
}

View File

@@ -21,6 +21,7 @@ import {
formatRoleMismatchReason, formatRoleMismatchReason,
resolveAgentProvisioningPolicy, resolveAgentProvisioningPolicy,
TASK_PRIORITIES, TASK_PRIORITIES,
type ExperimentSessionStore,
} from "@fusion/core"; } from "@fusion/core";
import { import {
getGhErrorMessage, getGhErrorMessage,
@@ -28,7 +29,18 @@ import {
isGhAvailable, isGhAvailable,
runGhJsonAsync, runGhJsonAsync,
} from "@fusion/core/gh-cli"; } 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 { resolve, basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs"; 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 ────────────────────────────────────────────── // ── Insights Tools ──────────────────────────────────────────────
pi.registerTool({ pi.registerTool({

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

View File

@@ -21,6 +21,13 @@ function createGitMock(): GitOps {
stashPush: vi.fn(), stashPush: vi.fn(),
stashPop: vi.fn(), stashPop: vi.fn(),
statusPorcelain: 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(),
}; };
} }

View File

@@ -53,6 +53,13 @@ function createGitMock(): GitOps {
stashPush: vi.fn(), stashPush: vi.fn(),
stashPop: vi.fn(), stashPop: vi.fn(),
statusPorcelain: 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(),
}; };
} }