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:
5
.changeset/FN-4222-experiment-finalize.md
Normal file
5
.changeset/FN-4222-experiment-finalize.md
Normal 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.
|
||||||
56
docs/research/experiment-finalize.md
Normal file
56
docs/research/experiment-finalize.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
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 { 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) {
|
||||||
|
|||||||
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,
|
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({
|
||||||
|
|||||||
@@ -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 { 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));
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user