Merge upstream/main — v0.28.1 → v0.29.0 (721 commits)
This commit is contained in:
99
packages/cli/src/__tests__/experiment-finalize.test.ts
Normal file
99
packages/cli/src/__tests__/experiment-finalize.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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 getExperimentSessionStore = 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", () => ({
|
||||
TaskStore: vi.fn(() => ({ init, getExperimentSessionStore })),
|
||||
}));
|
||||
|
||||
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);
|
||||
getExperimentSessionStore.mockReturnValue({});
|
||||
});
|
||||
|
||||
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(getExperimentSessionStore).toHaveBeenCalled();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -202,6 +202,7 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
"fn_feature_add",
|
||||
"fn_slice_activate",
|
||||
"fn_feature_link_task",
|
||||
"fn_feature_update",
|
||||
"fn_agent_stop",
|
||||
"fn_agent_start",
|
||||
"fn_agent_create",
|
||||
@@ -1373,6 +1374,292 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_feature_update", () => {
|
||||
it("patches title, description, and acceptanceCriteria", async () => {
|
||||
const missionTool = api.tools.get("fn_mission_create")!;
|
||||
const milestoneTool = api.tools.get("fn_milestone_add")!;
|
||||
const sliceTool = api.tools.get("fn_slice_add")!;
|
||||
const featureTool = api.tools.get("fn_feature_add")!;
|
||||
const updateTool = api.tools.get("fn_feature_update")!;
|
||||
|
||||
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const milestone = await milestoneTool.execute(
|
||||
"ms1",
|
||||
{ missionId: mission.details.missionId, title: "Milestone" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const slice = await sliceTool.execute(
|
||||
"sl1",
|
||||
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const feature = await featureTool.execute(
|
||||
"f1",
|
||||
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const result = await updateTool.execute(
|
||||
"fu1",
|
||||
{
|
||||
id: feature.details.featureId,
|
||||
title: "Updated Feature",
|
||||
description: "Updated description",
|
||||
acceptanceCriteria: "AC new",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
|
||||
|
||||
expect(result.content[0].text).toContain("Updated");
|
||||
expect(persisted?.title).toBe("Updated Feature");
|
||||
expect(persisted?.description).toBe("Updated description");
|
||||
expect(persisted?.acceptanceCriteria).toBe("AC new");
|
||||
});
|
||||
|
||||
it("partial patch preserves untouched fields", async () => {
|
||||
const missionTool = api.tools.get("fn_mission_create")!;
|
||||
const milestoneTool = api.tools.get("fn_milestone_add")!;
|
||||
const sliceTool = api.tools.get("fn_slice_add")!;
|
||||
const featureTool = api.tools.get("fn_feature_add")!;
|
||||
const updateTool = api.tools.get("fn_feature_update")!;
|
||||
|
||||
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const milestone = await milestoneTool.execute(
|
||||
"ms1",
|
||||
{ missionId: mission.details.missionId, title: "Milestone" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const slice = await sliceTool.execute(
|
||||
"sl1",
|
||||
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const feature = await featureTool.execute(
|
||||
"f1",
|
||||
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
await updateTool.execute(
|
||||
"fu2",
|
||||
{ id: feature.details.featureId, acceptanceCriteria: "AC patched" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
|
||||
|
||||
expect(persisted?.title).toBe("Feature");
|
||||
expect(persisted?.description).toBe("Original");
|
||||
expect(persisted?.acceptanceCriteria).toBe("AC patched");
|
||||
});
|
||||
|
||||
it("preserves slice ordering", async () => {
|
||||
const missionTool = api.tools.get("fn_mission_create")!;
|
||||
const milestoneTool = api.tools.get("fn_milestone_add")!;
|
||||
const sliceTool = api.tools.get("fn_slice_add")!;
|
||||
const featureTool = api.tools.get("fn_feature_add")!;
|
||||
const updateTool = api.tools.get("fn_feature_update")!;
|
||||
|
||||
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const milestone = await milestoneTool.execute(
|
||||
"ms1",
|
||||
{ missionId: mission.details.missionId, title: "Milestone" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const slice = await sliceTool.execute(
|
||||
"sl1",
|
||||
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const first = await featureTool.execute(
|
||||
"f1",
|
||||
{ sliceId: slice.details.sliceId, title: "F1" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const second = await featureTool.execute(
|
||||
"f2",
|
||||
{ sliceId: slice.details.sliceId, title: "F2" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const third = await featureTool.execute(
|
||||
"f3",
|
||||
{ sliceId: slice.details.sliceId, title: "F3" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
await updateTool.execute(
|
||||
"fu3",
|
||||
{ id: second.details.featureId, title: "F2 Updated" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const features = store.getMissionStore().listFeatures(slice.details.sliceId);
|
||||
|
||||
expect(features.map((featureItem) => featureItem.id)).toEqual([
|
||||
first.details.featureId,
|
||||
second.details.featureId,
|
||||
third.details.featureId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves linked task association", async () => {
|
||||
const missionTool = api.tools.get("fn_mission_create")!;
|
||||
const milestoneTool = api.tools.get("fn_milestone_add")!;
|
||||
const sliceTool = api.tools.get("fn_slice_add")!;
|
||||
const featureTool = api.tools.get("fn_feature_add")!;
|
||||
const createTaskTool = api.tools.get("fn_task_create")!;
|
||||
const linkTool = api.tools.get("fn_feature_link_task")!;
|
||||
const updateTool = api.tools.get("fn_feature_update")!;
|
||||
|
||||
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const milestone = await milestoneTool.execute(
|
||||
"ms1",
|
||||
{ missionId: mission.details.missionId, title: "Milestone" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const slice = await sliceTool.execute(
|
||||
"sl1",
|
||||
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const feature = await featureTool.execute(
|
||||
"f1",
|
||||
{ sliceId: slice.details.sliceId, title: "Feature" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const taskResult = await createTaskTool.execute(
|
||||
"t1",
|
||||
{ description: "Task for feature" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
await linkTool.execute(
|
||||
"l1",
|
||||
{ featureId: feature.details.featureId, taskId: taskResult.details.taskId },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
await updateTool.execute(
|
||||
"fu4",
|
||||
{ id: feature.details.featureId, title: "Updated Feature" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
|
||||
|
||||
expect(persisted?.taskId).toBe(taskResult.details.taskId);
|
||||
expect(persisted?.status).toBe("triaged");
|
||||
});
|
||||
|
||||
it("returns error when feature not found", async () => {
|
||||
const updateTool = api.tools.get("fn_feature_update")!;
|
||||
|
||||
const result = await updateTool.execute(
|
||||
"fu5",
|
||||
{ id: "F-999", title: "Updated" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("Feature F-999 not found");
|
||||
});
|
||||
|
||||
it("returns error when no fields supplied", async () => {
|
||||
const missionTool = api.tools.get("fn_mission_create")!;
|
||||
const milestoneTool = api.tools.get("fn_milestone_add")!;
|
||||
const sliceTool = api.tools.get("fn_slice_add")!;
|
||||
const featureTool = api.tools.get("fn_feature_add")!;
|
||||
const updateTool = api.tools.get("fn_feature_update")!;
|
||||
|
||||
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
|
||||
const milestone = await milestoneTool.execute(
|
||||
"ms1",
|
||||
{ missionId: mission.details.missionId, title: "Milestone" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const slice = await sliceTool.execute(
|
||||
"sl1",
|
||||
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
const feature = await featureTool.execute(
|
||||
"f1",
|
||||
{ sliceId: slice.details.sliceId, title: "Feature" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const result = await updateTool.execute(
|
||||
"fu6",
|
||||
{ id: feature.details.featureId },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain("No fields to update");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub import tools", () => {
|
||||
it("fn_task_import_github requires gh auth", async () => {
|
||||
const tool = api.tools.get("fn_task_import_github")!;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createRequire } from "node:module";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { Readable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// @ts-expect-error -- Bun-only global; undefined in Node
|
||||
@@ -133,10 +134,12 @@ async function loadCommandHandlers() {
|
||||
const { runAgentImport } = await import("./commands/agent-import.js");
|
||||
const { runAgentExport } = await import("./commands/agent-export.js");
|
||||
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
|
||||
const { runChatInteractive } = await import("./commands/chat.js");
|
||||
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js");
|
||||
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 {
|
||||
@@ -233,7 +236,9 @@ async function loadCommandHandlers() {
|
||||
runResearchExport,
|
||||
runResearchCancel,
|
||||
runResearchRetry,
|
||||
runExperimentFinalize,
|
||||
runUpdate,
|
||||
runChatInteractive,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,10 +252,10 @@ Usage:
|
||||
fn dashboard --paused Start with automation paused
|
||||
fn dashboard --dev Start web UI only (no AI engine)
|
||||
fn dashboard --interactive Start with interactive port selection
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--no-auto-register]
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--project <id|name>] [--no-auto-register]
|
||||
Start Fusion as a headless node (API + engine, no UI)
|
||||
Auto-registers cwd project on first run (use --no-auto-register to disable)
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--no-auto-register]
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--project <id|name>] [--no-auto-register]
|
||||
Start Fusion daemon (API + engine, auth required)
|
||||
fn desktop Launch the Fusion desktop app (Electron)
|
||||
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
|
||||
@@ -342,6 +347,8 @@ Usage:
|
||||
fn message send <agent-id> <msg> Send a message to an agent
|
||||
fn message read <id> Read a specific message
|
||||
fn message delete <id> Delete a message
|
||||
fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
|
||||
Interactive or one-shot chat with an agent
|
||||
fn backup --create Create a database backup immediately
|
||||
fn backup --list List all database backups
|
||||
fn backup --restore <file> Restore database from a backup file
|
||||
@@ -394,6 +401,11 @@ Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv,
|
||||
`.trim();
|
||||
|
||||
function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string } {
|
||||
const command = argv[0];
|
||||
if (command === "serve" || command === "daemon") {
|
||||
return { cleanedArgs: [...argv] };
|
||||
}
|
||||
|
||||
const cleanedArgs: string[] = [];
|
||||
let projectName: string | undefined;
|
||||
|
||||
@@ -595,7 +607,9 @@ async function main() {
|
||||
runResearchExport,
|
||||
runResearchCancel,
|
||||
runResearchRetry,
|
||||
runExperimentFinalize,
|
||||
runUpdate,
|
||||
runChatInteractive,
|
||||
} = await loadCommandHandlers();
|
||||
|
||||
try {
|
||||
@@ -646,8 +660,9 @@ async function main() {
|
||||
const hostIdx = args.indexOf("--host");
|
||||
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
|
||||
const daemon = args.includes("--daemon");
|
||||
const project = getFlagValue(args, "--project");
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runServe(port, { paused, interactive, host, daemon, noAutoRegister });
|
||||
await runServe(port, { paused, interactive, host, daemon, project, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -663,8 +678,9 @@ async function main() {
|
||||
const tokenIdx = args.indexOf("--token");
|
||||
const token = tokenIdx !== -1 && tokenIdx + 1 < args.length ? args[tokenIdx + 1] : undefined;
|
||||
const tokenOnly = args.includes("--token-only");
|
||||
const project = getFlagValue(args, "--project");
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly, noAutoRegister });
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly, project, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -903,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) {
|
||||
@@ -1483,6 +1527,45 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat": {
|
||||
const usage = "Usage: fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]";
|
||||
const agentId = args[1];
|
||||
if (!agentId) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pollIdx = args.indexOf("--poll-ms");
|
||||
const pollMs = pollIdx !== -1 && pollIdx + 1 < args.length
|
||||
? Number.parseInt(args[pollIdx + 1] ?? "", 10)
|
||||
: undefined;
|
||||
|
||||
if (pollIdx !== -1 && (!Number.isFinite(pollMs) || (pollMs ?? 0) <= 0)) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filteredArgs = args.slice(2).filter((arg, idx, arr) => {
|
||||
if (arg === "--once" || arg === "--non-interactive" || arg === "--poll-ms") return false;
|
||||
if (idx > 0 && arr[idx - 1] === "--poll-ms") return false;
|
||||
return true;
|
||||
});
|
||||
const contentArg = filteredArgs.join(" ").trim();
|
||||
const once = args.includes("--once") || contentArg.length > 0;
|
||||
const nonInteractive = args.includes("--non-interactive") || contentArg.length > 0;
|
||||
const input = contentArg ? Readable.from(contentArg) : process.stdin;
|
||||
|
||||
const code = await runChatInteractive(agentId, {
|
||||
project: projectName,
|
||||
once,
|
||||
nonInteractive,
|
||||
pollIntervalMs: pollMs,
|
||||
input,
|
||||
});
|
||||
process.exit(code);
|
||||
break;
|
||||
}
|
||||
|
||||
case "plugin": {
|
||||
const sub = args[1];
|
||||
switch (sub) {
|
||||
|
||||
259
packages/cli/src/commands/__tests__/chat.test.ts
Normal file
259
packages/cli/src/commands/__tests__/chat.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
|
||||
import { AgentStore, MessageStore, createDatabase } from "@fusion/core";
|
||||
|
||||
const mockResolveProject = vi.fn();
|
||||
|
||||
vi.mock("../../project-context.js", () => ({
|
||||
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
||||
}));
|
||||
|
||||
import { runChatInteractive } from "../chat.js";
|
||||
|
||||
function streamToString(stream: PassThrough): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let text = "";
|
||||
stream.on("data", (chunk) => {
|
||||
text += chunk.toString();
|
||||
});
|
||||
stream.on("end", () => resolve(text));
|
||||
});
|
||||
}
|
||||
|
||||
describe("runChatInteractive", () => {
|
||||
let projectDir: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = mkdtempSync(join(tmpdir(), "fn-chat-"));
|
||||
mockResolveProject.mockResolvedValue({
|
||||
projectId: "proj-1",
|
||||
projectPath: projectDir,
|
||||
projectName: "proj-1",
|
||||
isRegistered: true,
|
||||
store: {},
|
||||
});
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: join(projectDir, ".fusion") });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Chat Agent",
|
||||
role: "executor",
|
||||
reportsTo: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function sendAgentReply(content: string, toId = "cli"): Promise<void> {
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const messageStore = new MessageStore(db);
|
||||
messageStore.sendMessage({
|
||||
fromId: agentId,
|
||||
fromType: "agent",
|
||||
toId,
|
||||
toType: "user",
|
||||
content,
|
||||
type: "agent-to-user",
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
it("sends a line as a user-to-agent message with wakeRecipient metadata", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
input.write("hello\n");
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
const code = await runPromise;
|
||||
output.end();
|
||||
await outputPromise;
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const outbox = store.getOutbox("cli", "user", { limit: 20 });
|
||||
db.close();
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(outbox[0]).toMatchObject({
|
||||
fromId: "cli",
|
||||
toId: agentId,
|
||||
type: "user-to-agent",
|
||||
content: "hello",
|
||||
metadata: { wakeRecipient: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 1 for unknown agent and writes no message", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const code = await runChatInteractive("agent-does-not-exist", {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input: Readable.from("hi"),
|
||||
});
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const outbox = store.getOutbox("cli", "user", { limit: 20 });
|
||||
db.close();
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Agent agent-does-not-exist not found");
|
||||
expect(outbox).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("prints existing conversation tail on start", async () => {
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
store.sendMessage({
|
||||
fromId: "cli",
|
||||
fromType: "user",
|
||||
toId: agentId,
|
||||
toType: "agent",
|
||||
content: "first",
|
||||
type: "user-to-agent",
|
||||
});
|
||||
store.sendMessage({
|
||||
fromId: agentId,
|
||||
fromType: "agent",
|
||||
toId: "cli",
|
||||
toType: "user",
|
||||
content: "second",
|
||||
type: "agent-to-user",
|
||||
});
|
||||
db.close();
|
||||
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
await runPromise;
|
||||
output.end();
|
||||
const outputText = await outputPromise;
|
||||
expect(outputText).toContain("first");
|
||||
expect(outputText).toContain("second");
|
||||
});
|
||||
|
||||
it("/exit ends loop cleanly", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
await expect(runPromise).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("poll loop prints new replies and marks them read", async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
const runPromise = runChatInteractive(agentId, { input, output, pollIntervalMs: 10 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await sendAgentReply("async reply");
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
input.write("/exit\n");
|
||||
input.end();
|
||||
|
||||
await runPromise;
|
||||
output.end();
|
||||
const outputText = await outputPromise;
|
||||
expect(outputText).toContain("async reply");
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const inbox = store.getInbox("cli", "user", { limit: 20 });
|
||||
const reply = inbox.find((msg) => msg.content === "async reply");
|
||||
db.close();
|
||||
|
||||
expect(reply?.read).toBe(true);
|
||||
});
|
||||
|
||||
it("--once sends and waits for one reply", async () => {
|
||||
const output = new PassThrough();
|
||||
const outputPromise = streamToString(output);
|
||||
|
||||
setTimeout(() => {
|
||||
void sendAgentReply("reply once");
|
||||
}, 50);
|
||||
|
||||
const code = await runChatInteractive(agentId, {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input: Readable.from("one-shot"),
|
||||
output,
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
|
||||
output.end();
|
||||
const outputText = await outputPromise;
|
||||
expect(code).toBe(0);
|
||||
expect(outputText).toContain(`you → ${agentId}: one-shot`);
|
||||
expect(outputText).toContain("reply once");
|
||||
});
|
||||
|
||||
it("--once exits with timeout note when no reply arrives", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const input = new PassThrough();
|
||||
input.end("ping");
|
||||
|
||||
const code = await runChatInteractive(agentId, {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input,
|
||||
output: new PassThrough(),
|
||||
pollIntervalMs: 1000,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith("No reply within 30s");
|
||||
}, 40_000);
|
||||
|
||||
it("refuses oversized messages", async () => {
|
||||
const oversized = "x".repeat(8193);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const code = await runChatInteractive(agentId, {
|
||||
once: true,
|
||||
nonInteractive: true,
|
||||
input: Readable.from(oversized),
|
||||
output: new PassThrough(),
|
||||
pollIntervalMs: 5,
|
||||
});
|
||||
|
||||
const db = createDatabase(join(projectDir, ".fusion"));
|
||||
db.init();
|
||||
const store = new MessageStore(db);
|
||||
const outbox = store.getOutbox("cli", "user", { limit: 20 });
|
||||
db.close();
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Message too long; max 8192 chars");
|
||||
expect(outbox).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,7 @@ const mocks = vi.hoisted(() => {
|
||||
Promise.resolve(projects.find((project) => project.id === id) ?? null),
|
||||
),
|
||||
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
|
||||
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
@@ -412,6 +413,8 @@ const mocks = vi.hoisted(() => {
|
||||
heartbeatTriggerScheduler.stop();
|
||||
}),
|
||||
getTaskStore: vi.fn(() => store),
|
||||
getProjectId: vi.fn(() => runtimeConfig.projectId),
|
||||
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
|
||||
getAutomationStore: vi.fn(() => automationStore),
|
||||
getRuntime: vi.fn(() => ({
|
||||
getHeartbeatMonitor: () => heartbeatMonitor,
|
||||
@@ -847,7 +850,7 @@ describe("runDaemon", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("--no-auto-register preserves legacy exit behavior", async () => {
|
||||
it("--no-auto-register falls back to existing started engines", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-no-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
|
||||
@@ -858,8 +861,12 @@ describe("runDaemon", () => {
|
||||
instance.registerProject.mock.calls,
|
||||
);
|
||||
expect(registrationCalls).toHaveLength(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith("[daemon] No engine started for the current project — exiting");
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
expect(process.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[daemon] HTTP layer bound to project")
|
||||
);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
rmSync(freshCwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ const mocks = vi.hoisted(() => {
|
||||
Promise.resolve(projects.find((project) => project.id === id) ?? null),
|
||||
),
|
||||
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
|
||||
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
@@ -461,6 +462,8 @@ const mocks = vi.hoisted(() => {
|
||||
heartbeatTriggerScheduler.stop();
|
||||
}),
|
||||
getTaskStore: vi.fn(() => store),
|
||||
getProjectId: vi.fn(() => runtimeConfig.projectId),
|
||||
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
|
||||
getAutomationStore: vi.fn(() => automationStore),
|
||||
getRuntime: vi.fn(() => ({
|
||||
getHeartbeatMonitor: () => heartbeatMonitor,
|
||||
@@ -1964,7 +1967,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("--no-auto-register preserves legacy exit behavior", async () => {
|
||||
it("--no-auto-register falls back to existing started engines", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -1979,10 +1982,15 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
|
||||
logPrefix: "serve",
|
||||
autoRegister: false,
|
||||
}));
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] No engine started for the current project")
|
||||
expect(process.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] No engines started")
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] HTTP layer bound to project")
|
||||
);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
|
||||
251
packages/cli/src/commands/chat.ts
Normal file
251
packages/cli/src/commands/chat.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { AgentStore } from "@fusion/core";
|
||||
import type { Message } from "@fusion/core";
|
||||
import { createMessageStore, formatParticipant, formatTime, CLI_USER_ID } from "./message.js";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 8192;
|
||||
const DEFAULT_POLL_MS = 1000;
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
export interface ChatInteractiveOptions {
|
||||
project?: string;
|
||||
pollIntervalMs?: number;
|
||||
once?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
input?: NodeJS.ReadableStream;
|
||||
output?: NodeJS.WritableStream;
|
||||
}
|
||||
|
||||
async function getProjectPath(projectName?: string): Promise<string> {
|
||||
if (projectName) {
|
||||
const context = await resolveProject(projectName);
|
||||
return context.projectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await resolveProject(undefined);
|
||||
return context.projectPath;
|
||||
} catch {
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
|
||||
async function createAgentStore(projectName?: string): Promise<AgentStore> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const store = new AgentStore({ rootDir: `${projectPath}/.fusion` });
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
function parsePollMs(options: ChatInteractiveOptions): number {
|
||||
const envValue = process.env.FUSION_CHAT_POLL_MS;
|
||||
const envPollMs = envValue ? Number.parseInt(envValue, 10) : Number.NaN;
|
||||
const candidate = options.pollIntervalMs ?? (Number.isFinite(envPollMs) ? envPollMs : DEFAULT_POLL_MS);
|
||||
return Number.isFinite(candidate) && candidate > 0 ? candidate : DEFAULT_POLL_MS;
|
||||
}
|
||||
|
||||
function printMessage(output: NodeJS.WritableStream, message: Message): void {
|
||||
const fromLabel = formatParticipant(message.fromId, message.fromType);
|
||||
const time = formatTime(message.createdAt);
|
||||
output.write(`${fromLabel} — ${time}\n`);
|
||||
output.write(`${message.content}\n\n`);
|
||||
}
|
||||
|
||||
function printConversationTail(output: NodeJS.WritableStream, messages: Message[]): void {
|
||||
if (messages.length === 0) {
|
||||
output.write("\nNo messages yet.\n\n");
|
||||
return;
|
||||
}
|
||||
|
||||
output.write("\nRecent conversation:\n\n");
|
||||
for (const message of messages) {
|
||||
printMessage(output, message);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForReply(
|
||||
messageStore: Awaited<ReturnType<typeof createMessageStore>>["store"],
|
||||
agentId: string,
|
||||
printedIds: Set<string>,
|
||||
output: NodeJS.WritableStream,
|
||||
pollIntervalMs: number,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const inbox = messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
|
||||
for (const message of inbox.slice().reverse()) {
|
||||
if (message.fromId !== agentId || message.fromType !== "agent") continue;
|
||||
if (printedIds.has(message.id)) continue;
|
||||
printedIds.add(message.id);
|
||||
printMessage(output, message);
|
||||
messageStore.markAsRead(message.id);
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runChatInteractive(agentId: string, options: ChatInteractiveOptions = {}): Promise<number> {
|
||||
const output = options.output ?? process.stdout;
|
||||
const input = options.input ?? process.stdin;
|
||||
const pollIntervalMs = parsePollMs(options);
|
||||
|
||||
const agentStore = await createAgentStore(options.project);
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
console.error(`Agent ${agentId} not found`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { store: messageStore, db } = await createMessageStore(options.project);
|
||||
const printedIds = new Set<string>();
|
||||
|
||||
const conversation = messageStore.getConversation(
|
||||
{ id: CLI_USER_ID, type: "user" },
|
||||
{ id: agentId, type: "agent" },
|
||||
);
|
||||
const tail = conversation.slice(-HISTORY_LIMIT);
|
||||
for (const message of tail) printedIds.add(message.id);
|
||||
|
||||
output.write(`Chat with Agent ${agentId} — type /exit or Ctrl-C to quit, /help for commands\n`);
|
||||
output.write("Replies appear when this project's engine is running (fn dashboard or fn serve).\n");
|
||||
printConversationTail(output, tail);
|
||||
|
||||
const runOnce = options.once === true;
|
||||
try {
|
||||
if (runOnce) {
|
||||
const content = await readSingleMessage(input, output, options.nonInteractive);
|
||||
if (!content.trim()) return 0;
|
||||
|
||||
if (content.length > MAX_MESSAGE_LENGTH) {
|
||||
console.error(`Message too long; max ${MAX_MESSAGE_LENGTH} chars`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
messageStore.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId: agentId,
|
||||
toType: "agent",
|
||||
content,
|
||||
type: "user-to-agent",
|
||||
metadata: { wakeRecipient: true },
|
||||
});
|
||||
|
||||
output.write(`you → ${agentId}: ${content}\n`);
|
||||
const timeoutMs = Math.max(pollIntervalMs * 10, 30_000);
|
||||
const replied = await waitForReply(messageStore, agentId, printedIds, output, pollIntervalMs, timeoutMs);
|
||||
if (!replied) {
|
||||
console.error(`No reply within ${Math.ceil(timeoutMs / 1000)}s`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const poller = (async () => {
|
||||
while (!abortController.signal.aborted) {
|
||||
const inbox = messageStore.getInbox(CLI_USER_ID, "user", { limit: 50 });
|
||||
for (const message of inbox.slice().reverse()) {
|
||||
if (message.fromId !== agentId || message.fromType !== "agent") continue;
|
||||
if (printedIds.has(message.id)) continue;
|
||||
printedIds.add(message.id);
|
||||
printMessage(output, message);
|
||||
messageStore.markAsRead(message.id);
|
||||
}
|
||||
await sleep(pollIntervalMs, abortController.signal);
|
||||
}
|
||||
})().catch(() => undefined);
|
||||
|
||||
const rl = createInterface({ input, output });
|
||||
rl.on("close", () => abortController.abort());
|
||||
|
||||
while (true) {
|
||||
let line: string;
|
||||
try {
|
||||
line = (await rl.question("> ")).trim();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!line) continue;
|
||||
if (line === "/exit" || line === "/quit") break;
|
||||
if (line === "/help") {
|
||||
output.write("Commands: /help, /history, /clear, /exit, /quit\n");
|
||||
continue;
|
||||
}
|
||||
if (line === "/history") {
|
||||
const history = messageStore.getConversation(
|
||||
{ id: CLI_USER_ID, type: "user" },
|
||||
{ id: agentId, type: "agent" },
|
||||
).slice(-HISTORY_LIMIT);
|
||||
for (const message of history) printedIds.add(message.id);
|
||||
printConversationTail(output, history);
|
||||
continue;
|
||||
}
|
||||
if (line === "/clear") {
|
||||
output.write("\x1b[2J\x1b[H");
|
||||
continue;
|
||||
}
|
||||
if (line.length > MAX_MESSAGE_LENGTH) {
|
||||
console.error(`Message too long; max ${MAX_MESSAGE_LENGTH} chars`);
|
||||
continue;
|
||||
}
|
||||
|
||||
messageStore.sendMessage({
|
||||
fromId: CLI_USER_ID,
|
||||
fromType: "user",
|
||||
toId: agentId,
|
||||
toType: "agent",
|
||||
content: line,
|
||||
type: "user-to-agent",
|
||||
metadata: { wakeRecipient: true },
|
||||
});
|
||||
output.write(`you → ${agentId}: ${line}\n`);
|
||||
}
|
||||
|
||||
abortController.abort();
|
||||
rl.close();
|
||||
await poller;
|
||||
return 0;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readSingleMessage(
|
||||
input: NodeJS.ReadableStream,
|
||||
output: NodeJS.WritableStream,
|
||||
nonInteractive?: boolean,
|
||||
): Promise<string> {
|
||||
if (nonInteractive) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of input) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8").trimEnd();
|
||||
}
|
||||
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
return await rl.question("");
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
@@ -168,8 +168,10 @@ export interface DaemonOptions {
|
||||
interactive?: boolean;
|
||||
/** Just print/generate token without starting server */
|
||||
tokenOnly?: boolean;
|
||||
/** Disable cwd auto-registration and preserve legacy strict behavior */
|
||||
/** Disable cwd auto-registration */
|
||||
noAutoRegister?: boolean;
|
||||
/** Preferred primary project (id or name). */
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
@@ -355,14 +357,72 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the cwd project's engine and store for the HTTP layer
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (!cwdEngine) {
|
||||
console.error("[daemon] No engine started for the current project — exiting");
|
||||
const startedEngines = [...engineManager.getAllEngines().values()];
|
||||
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
|
||||
|
||||
const resolvePrimaryEngine = async (): Promise<{
|
||||
engine: (typeof startedEngines)[number];
|
||||
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
|
||||
} | null> => {
|
||||
if (opts.project) {
|
||||
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
|
||||
if (byId) {
|
||||
return { engine: byId, source: "cli-flag" };
|
||||
}
|
||||
|
||||
const projectMatch = projects.find((project) => project.name === opts.project);
|
||||
if (projectMatch) {
|
||||
const byName = engineManager.getEngine(projectMatch.id);
|
||||
if (byName) {
|
||||
return { engine: byName, source: "cli-flag" };
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[daemon] --project "${opts.project}" did not match any started engine`);
|
||||
process.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
|
||||
if (defaultProjectId) {
|
||||
const defaultEngine = engineManager.getEngine(defaultProjectId);
|
||||
if (defaultEngine) {
|
||||
return { engine: defaultEngine, source: "default-setting" };
|
||||
}
|
||||
console.warn(`[daemon] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
|
||||
}
|
||||
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (cwdEngine) {
|
||||
return { engine: cwdEngine, source: "cwd" };
|
||||
}
|
||||
|
||||
const fallback = startedEngines[0];
|
||||
if (!fallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { engine: fallback, source: "fallback" };
|
||||
};
|
||||
|
||||
const primarySelection = await resolvePrimaryEngine();
|
||||
if (!primarySelection) {
|
||||
console.error("[daemon] No engines started — registry empty or all engines failed to start. Exiting.");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const store = cwdEngine.getTaskStore();
|
||||
|
||||
const primaryEngine = primarySelection.engine;
|
||||
const primaryProjectId = primaryEngine.getProjectId();
|
||||
ntfyProjectId = primaryProjectId;
|
||||
const primaryProject = projects.find((project) => project.id === primaryProjectId);
|
||||
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
|
||||
const primaryCwd = primaryEngine.getWorkingDirectory();
|
||||
console.log(
|
||||
`[daemon] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
|
||||
);
|
||||
|
||||
const store = primaryEngine.getTaskStore();
|
||||
|
||||
await store.watch();
|
||||
|
||||
@@ -417,11 +477,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get subsystems from the cwd engine for the HTTP layer
|
||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = cwdEngine.getAutomationStore();
|
||||
// Get subsystems from the primary engine for the HTTP layer
|
||||
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = primaryEngine.getAutomationStore();
|
||||
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
||||
@@ -438,9 +498,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
try {
|
||||
const agentDir = getPackageManagerAgentDir();
|
||||
packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
cwd: primaryCwd,
|
||||
agentDir,
|
||||
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
|
||||
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
|
||||
});
|
||||
const resolvedPaths = await packageManager.resolve();
|
||||
const packageExtensionPaths = resolvedPaths.extensions
|
||||
@@ -515,14 +575,14 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
setHostExtensionPaths(selfExtensionPaths);
|
||||
|
||||
const reconciledExtensionPaths = reconcileClaudeCliPaths(
|
||||
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
|
||||
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(primaryCwd), ...packageExtensionPaths, ...claudeCliPaths],
|
||||
claudeCliPaths[0] ?? null,
|
||||
);
|
||||
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
[...reconciledExtensionPaths, ...droidCliPaths, ...llamaCppPaths],
|
||||
cwd,
|
||||
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
primaryCwd,
|
||||
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
);
|
||||
|
||||
for (const { path, error } of extensionsResult.errors) {
|
||||
@@ -575,10 +635,10 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
}, DIAGNOSTIC_INTERVAL_MS).unref?.();
|
||||
|
||||
const app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engine: primaryEngine,
|
||||
engineManager,
|
||||
centralCore: sharedCentralCore ?? undefined,
|
||||
onMerge: (taskId) => cwdEngine.onMerge(taskId),
|
||||
onMerge: (taskId) => primaryEngine.onMerge(taskId),
|
||||
authStorage: dashboardAuthStorage,
|
||||
modelRegistry,
|
||||
automationStore,
|
||||
@@ -586,7 +646,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
missionExecutionLoop,
|
||||
heartbeatMonitor: heartbeatMonitor
|
||||
? {
|
||||
rootDir: cwd,
|
||||
rootDir: primaryCwd,
|
||||
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
|
||||
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
|
||||
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),
|
||||
|
||||
124
packages/cli/src/commands/experiment-finalize.ts
Normal file
124
packages/cli/src/commands/experiment-finalize.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { TaskStore } 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 taskStore = new TaskStore(projectRoot);
|
||||
await taskStore.init();
|
||||
const sessionStore = taskStore.getExperimentSessionStore();
|
||||
const service = new ExperimentFinalizeService({
|
||||
store: sessionStore,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ async function getProjectPath(projectName?: string): Promise<string> {
|
||||
* Create a MessageStore for the given project.
|
||||
* Returns both the store and database for proper cleanup.
|
||||
*/
|
||||
async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: Database }> {
|
||||
export async function createMessageStore(projectName?: string): Promise<{ store: MessageStore; db: Database }> {
|
||||
const projectPath = await getProjectPath(projectName);
|
||||
const fusionDir = projectPath + "/.fusion";
|
||||
const db = createDatabase(fusionDir);
|
||||
@@ -34,7 +34,7 @@ async function createMessageStore(projectName?: string): Promise<{ store: Messag
|
||||
}
|
||||
|
||||
/** User ID for CLI-originated messages */
|
||||
const CLI_USER_ID = "cli";
|
||||
export const CLI_USER_ID = "cli";
|
||||
|
||||
/**
|
||||
* List inbox messages.
|
||||
@@ -211,7 +211,7 @@ export async function runAgentMailbox(agentId: string, projectName?: string): Pr
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatParticipant(id: string, type: ParticipantType): string {
|
||||
export function formatParticipant(id: string, type: ParticipantType): string {
|
||||
switch (type) {
|
||||
case "agent": return `Agent ${id}`;
|
||||
case "user": return id === "cli" ? "You (CLI)" : id === "dashboard" ? "You (Dashboard)" : `User ${id}`;
|
||||
@@ -219,7 +219,7 @@ function formatParticipant(id: string, type: ParticipantType): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
export function formatTime(ts: string): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
|
||||
@@ -224,7 +224,7 @@ function ensureProcessDiagnostics(): void {
|
||||
|
||||
export async function runServe(
|
||||
port: number,
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean } = {},
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean; project?: string } = {},
|
||||
) {
|
||||
serveStartTime = Date.now();
|
||||
ensureProcessDiagnostics();
|
||||
@@ -399,15 +399,72 @@ export async function runServe(
|
||||
}
|
||||
}
|
||||
|
||||
// Get the cwd project's engine and store for the HTTP layer.
|
||||
// serve.ts needs a store for plugin setup, diagnostics, and the server.
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (!cwdEngine) {
|
||||
console.error("[serve] No engine started for the current project — exiting");
|
||||
const startedEngines = [...engineManager.getAllEngines().values()];
|
||||
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
|
||||
|
||||
const resolvePrimaryEngine = async (): Promise<{
|
||||
engine: (typeof startedEngines)[number];
|
||||
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
|
||||
} | null> => {
|
||||
if (opts.project) {
|
||||
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
|
||||
if (byId) {
|
||||
return { engine: byId, source: "cli-flag" };
|
||||
}
|
||||
|
||||
const projectMatch = projects.find((project) => project.name === opts.project);
|
||||
if (projectMatch) {
|
||||
const byName = engineManager.getEngine(projectMatch.id);
|
||||
if (byName) {
|
||||
return { engine: byName, source: "cli-flag" };
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[serve] --project "${opts.project}" did not match any started engine`);
|
||||
process.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
|
||||
if (defaultProjectId) {
|
||||
const defaultEngine = engineManager.getEngine(defaultProjectId);
|
||||
if (defaultEngine) {
|
||||
return { engine: defaultEngine, source: "default-setting" };
|
||||
}
|
||||
console.warn(`[serve] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
|
||||
}
|
||||
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (cwdEngine) {
|
||||
return { engine: cwdEngine, source: "cwd" };
|
||||
}
|
||||
|
||||
const fallback = startedEngines[0];
|
||||
if (!fallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { engine: fallback, source: "fallback" };
|
||||
};
|
||||
|
||||
const primarySelection = await resolvePrimaryEngine();
|
||||
if (!primarySelection) {
|
||||
console.error("[serve] No engines started — registry empty or all engines failed to start. Exiting.");
|
||||
process.exit(1);
|
||||
return; // unreachable in production, but needed for test mocks
|
||||
return;
|
||||
}
|
||||
const store = cwdEngine.getTaskStore();
|
||||
|
||||
const primaryEngine = primarySelection.engine;
|
||||
const primaryProjectId = primaryEngine.getProjectId();
|
||||
ntfyProjectId = primaryProjectId;
|
||||
const primaryProject = projects.find((project) => project.id === primaryProjectId);
|
||||
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
|
||||
const primaryCwd = primaryEngine.getWorkingDirectory();
|
||||
console.log(
|
||||
`[serve] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
|
||||
);
|
||||
|
||||
const store = primaryEngine.getTaskStore();
|
||||
|
||||
// InProcessRuntime does not call store.watch() — do it here so SSE events
|
||||
// and file-watcher triggers are active for the HTTP layer.
|
||||
@@ -503,11 +560,11 @@ export async function runServe(
|
||||
);
|
||||
}
|
||||
|
||||
// Get subsystems from the cwd engine for the HTTP layer
|
||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = cwdEngine.getAutomationStore();
|
||||
// Get subsystems from the primary engine for the HTTP layer
|
||||
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = primaryEngine.getAutomationStore();
|
||||
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
||||
@@ -524,9 +581,9 @@ export async function runServe(
|
||||
try {
|
||||
const agentDir = getPackageManagerAgentDir();
|
||||
packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
cwd: primaryCwd,
|
||||
agentDir,
|
||||
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
|
||||
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
|
||||
});
|
||||
const resolvedPaths = await packageManager.resolve();
|
||||
const packageExtensionPaths = resolvedPaths.extensions
|
||||
@@ -602,14 +659,14 @@ export async function runServe(
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
[
|
||||
...selfExtensionPaths,
|
||||
...getEnabledPiExtensionPaths(cwd),
|
||||
...getEnabledPiExtensionPaths(primaryCwd),
|
||||
...packageExtensionPaths,
|
||||
...claudeCliPaths,
|
||||
...droidCliPaths,
|
||||
...llamaCppPaths,
|
||||
],
|
||||
cwd,
|
||||
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
primaryCwd,
|
||||
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
);
|
||||
|
||||
for (const { path, error } of extensionsResult.errors) {
|
||||
@@ -715,10 +772,10 @@ export async function runServe(
|
||||
: undefined;
|
||||
|
||||
const app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engine: primaryEngine,
|
||||
engineManager,
|
||||
centralCore: sharedCentralCore ?? undefined,
|
||||
onMerge: (taskId) => cwdEngine.onMerge(taskId),
|
||||
onMerge: (taskId) => primaryEngine.onMerge(taskId),
|
||||
authStorage: dashboardAuthStorage,
|
||||
modelRegistry,
|
||||
automationStore,
|
||||
@@ -726,7 +783,7 @@ export async function runServe(
|
||||
missionExecutionLoop,
|
||||
heartbeatMonitor: heartbeatMonitor
|
||||
? {
|
||||
rootDir: cwd,
|
||||
rootDir: primaryCwd,
|
||||
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
|
||||
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
|
||||
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),
|
||||
|
||||
@@ -28,7 +28,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 +1736,76 @@ 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.getExperimentSessionStore();
|
||||
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({
|
||||
@@ -2446,6 +2527,83 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_feature_update ─────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_feature_update",
|
||||
label: "fn: Update Feature",
|
||||
description:
|
||||
"Update an existing feature's title, description, or acceptance criteria. " +
|
||||
"Partial patches leave untouched fields intact.",
|
||||
promptSnippet: "Update an existing mission feature",
|
||||
promptGuidelines: [
|
||||
"Use to revise acceptance criteria after reconciliation without re-creating the feature",
|
||||
"Slice ordering and linked tasks are preserved",
|
||||
"Provide only the fields you want to change",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Feature ID to update (e.g., F-001)" }),
|
||||
title: Type.Optional(Type.String({ description: "Updated feature title" })),
|
||||
description: Type.Optional(Type.String({ description: "Updated feature description" })),
|
||||
acceptanceCriteria: Type.Optional(
|
||||
Type.String({ description: "Updated acceptance criteria for completing the feature" })
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const missionStore = store.getMissionStore();
|
||||
|
||||
const existingFeature = missionStore.getFeature(params.id);
|
||||
if (!existingFeature) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Feature ${params.id} not found` }],
|
||||
isError: true,
|
||||
details: { error: "Feature not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const updates: { title?: string; description?: string; acceptanceCriteria?: string } = {};
|
||||
|
||||
if ("title" in params) {
|
||||
updates.title = params.title?.trim();
|
||||
}
|
||||
if ("description" in params) {
|
||||
updates.description = params.description?.trim();
|
||||
}
|
||||
if ("acceptanceCriteria" in params) {
|
||||
updates.acceptanceCriteria = params.acceptanceCriteria?.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No fields to update (provide at least one of: title, description, acceptanceCriteria)",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: { error: "No fields to update" },
|
||||
};
|
||||
}
|
||||
|
||||
const feature = missionStore.updateFeature(params.id, updates);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Updated ${feature.id}: "${feature.title}"` }],
|
||||
details: {
|
||||
featureId: feature.id,
|
||||
sliceId: feature.sliceId,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
acceptanceCriteria: feature.acceptanceCriteria,
|
||||
status: feature.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_agent_stop ─────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
Reference in New Issue
Block a user