feat(FN-1225): add headless fn serve command with health checks
- Add a new serve command implementation that runs the dashboard server in headless mode and exposes a health endpoint - Register the serve command in CLI routing and extend bin command coverage for dispatch behavior - Add comprehensive serve command tests for startup flow, options handling, and health-check responses - Fix mission event ordering in MissionStore to keep health snapshots deterministic under concurrent updates - Include a changeset for @gsxdsm/fusion documenting the new fn serve capability
This commit is contained in:
459
packages/cli/src/commands/__tests__/serve.test.ts
Normal file
459
packages/cli/src/commands/__tests__/serve.test.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type ListenCall = {
|
||||
port: number;
|
||||
host?: string;
|
||||
server: {
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
address: ReturnType<typeof vi.fn>;
|
||||
once: (event: string, cb: (...args: unknown[]) => void) => void;
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => void;
|
||||
emit: (event: string, ...args: unknown[]) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const taskStores: any[] = [];
|
||||
const automationStores: any[] = [];
|
||||
const agentStores: any[] = [];
|
||||
const centralInstances: any[] = [];
|
||||
const triageInstances: any[] = [];
|
||||
const executorInstances: any[] = [];
|
||||
const schedulerInstances: any[] = [];
|
||||
const stuckDetectorInstances: any[] = [];
|
||||
const selfHealingInstances: any[] = [];
|
||||
const cronRunnerInstances: any[] = [];
|
||||
const missionAutopilotInstances: any[] = [];
|
||||
const notifierInstances: any[] = [];
|
||||
const listenCalls: ListenCall[] = [];
|
||||
|
||||
function createTaskStoreMock() {
|
||||
const emitter = new EventEmitter();
|
||||
const missionStore = {
|
||||
listMissions: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
return {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
watch: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn(),
|
||||
getFusionDir: vi.fn().mockReturnValue("/repo/.fusion"),
|
||||
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
recycleWorktrees: false,
|
||||
autoMerge: false,
|
||||
pollIntervalMs: 60_000,
|
||||
openrouterModelSync: false,
|
||||
}),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn(),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
}),
|
||||
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.off(event, handler);
|
||||
}),
|
||||
emit: emitter.emit.bind(emitter),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockServer(port: number) {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
close: vi.fn((cb?: () => void) => cb?.()),
|
||||
address: vi.fn(() => ({ port, family: "IPv4", address: "0.0.0.0" })),
|
||||
once: emitter.once.bind(emitter),
|
||||
on: emitter.on.bind(emitter),
|
||||
});
|
||||
}
|
||||
|
||||
const taskStoreCtor = vi.fn().mockImplementation(() => {
|
||||
const store = createTaskStoreMock();
|
||||
taskStores.push(store);
|
||||
return store;
|
||||
});
|
||||
|
||||
const automationStoreCtor = vi.fn().mockImplementation(() => {
|
||||
const automationStore = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
automationStores.push(automationStore);
|
||||
return automationStore;
|
||||
});
|
||||
|
||||
const agentStoreCtor = vi.fn().mockImplementation(() => {
|
||||
const agentStore = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
agentStores.push(agentStore);
|
||||
return agentStore;
|
||||
});
|
||||
|
||||
const centralCoreCtor = vi.fn().mockImplementation(() => {
|
||||
const instance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
centralInstances.push(instance);
|
||||
return instance;
|
||||
});
|
||||
|
||||
const createServerMock = vi.fn().mockImplementation(() => ({
|
||||
listen: vi.fn((port: number, host?: string) => {
|
||||
const actualPort = port === 0 ? 5050 : port;
|
||||
const server = createMockServer(actualPort);
|
||||
listenCalls.push({ port, host, server });
|
||||
queueMicrotask(() => {
|
||||
server.emit("listening");
|
||||
});
|
||||
return server;
|
||||
}),
|
||||
}));
|
||||
|
||||
const triageCtor = vi.fn().mockImplementation(() => {
|
||||
const triage = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
markStuckAborted: vi.fn(),
|
||||
};
|
||||
triageInstances.push(triage);
|
||||
return triage;
|
||||
});
|
||||
|
||||
const executorCtor = vi.fn().mockImplementation(() => {
|
||||
const executor = {
|
||||
resumeOrphaned: vi.fn().mockResolvedValue(undefined),
|
||||
markStuckAborted: vi.fn(),
|
||||
handleLoopDetected: vi.fn().mockResolvedValue(false),
|
||||
recoverCompletedTask: vi.fn().mockResolvedValue(false),
|
||||
getExecutingTaskIds: vi.fn().mockReturnValue(new Set()),
|
||||
};
|
||||
executorInstances.push(executor);
|
||||
return executor;
|
||||
});
|
||||
|
||||
const schedulerCtor = vi.fn().mockImplementation(() => {
|
||||
const scheduler = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
schedulerInstances.push(scheduler);
|
||||
return scheduler;
|
||||
});
|
||||
|
||||
const stuckDetectorCtor = vi.fn().mockImplementation(() => {
|
||||
const detector = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
checkNow: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
stuckDetectorInstances.push(detector);
|
||||
return detector;
|
||||
});
|
||||
|
||||
const selfHealingCtor = vi.fn().mockImplementation(() => {
|
||||
const manager = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
checkStuckBudget: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
selfHealingInstances.push(manager);
|
||||
return manager;
|
||||
});
|
||||
|
||||
const cronRunnerCtor = vi.fn().mockImplementation(() => {
|
||||
const cron = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
cronRunnerInstances.push(cron);
|
||||
return cron;
|
||||
});
|
||||
|
||||
const missionAutopilotCtor = vi.fn().mockImplementation(() => {
|
||||
const autopilot = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setScheduler: vi.fn(),
|
||||
};
|
||||
missionAutopilotInstances.push(autopilot);
|
||||
return autopilot;
|
||||
});
|
||||
|
||||
const notifierCtor = vi.fn().mockImplementation(() => {
|
||||
const notifier = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
notifierInstances.push(notifier);
|
||||
return notifier;
|
||||
});
|
||||
|
||||
const authStorage = {
|
||||
getApiKey: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const modelRegistry = {
|
||||
registerProvider: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
taskStores,
|
||||
automationStores,
|
||||
agentStores,
|
||||
centralInstances,
|
||||
triageInstances,
|
||||
executorInstances,
|
||||
schedulerInstances,
|
||||
stuckDetectorInstances,
|
||||
selfHealingInstances,
|
||||
cronRunnerInstances,
|
||||
missionAutopilotInstances,
|
||||
notifierInstances,
|
||||
listenCalls,
|
||||
taskStoreCtor,
|
||||
automationStoreCtor,
|
||||
agentStoreCtor,
|
||||
centralCoreCtor,
|
||||
createServerMock,
|
||||
triageCtor,
|
||||
executorCtor,
|
||||
schedulerCtor,
|
||||
stuckDetectorCtor,
|
||||
selfHealingCtor,
|
||||
cronRunnerCtor,
|
||||
missionAutopilotCtor,
|
||||
notifierCtor,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
reset() {
|
||||
taskStores.length = 0;
|
||||
automationStores.length = 0;
|
||||
agentStores.length = 0;
|
||||
centralInstances.length = 0;
|
||||
triageInstances.length = 0;
|
||||
executorInstances.length = 0;
|
||||
schedulerInstances.length = 0;
|
||||
stuckDetectorInstances.length = 0;
|
||||
selfHealingInstances.length = 0;
|
||||
cronRunnerInstances.length = 0;
|
||||
missionAutopilotInstances.length = 0;
|
||||
notifierInstances.length = 0;
|
||||
listenCalls.length = 0;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: mocks.taskStoreCtor,
|
||||
AutomationStore: mocks.automationStoreCtor,
|
||||
AgentStore: mocks.agentStoreCtor,
|
||||
CentralCore: mocks.centralCoreCtor,
|
||||
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
createServer: mocks.createServerMock,
|
||||
GitHubClient: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
TriageProcessor: mocks.triageCtor,
|
||||
TaskExecutor: mocks.executorCtor,
|
||||
Scheduler: mocks.schedulerCtor,
|
||||
AgentSemaphore: vi.fn().mockImplementation(() => ({
|
||||
run: (fn: () => Promise<unknown>) => fn(),
|
||||
})),
|
||||
WorktreePool: vi.fn().mockImplementation(() => ({
|
||||
rehydrate: vi.fn(),
|
||||
})),
|
||||
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
|
||||
UsageLimitPauser: vi.fn().mockImplementation(() => ({})),
|
||||
PRIORITY_MERGE: 100,
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
NtfyNotifier: mocks.notifierCtor,
|
||||
PrMonitor: vi.fn().mockImplementation(() => ({
|
||||
onNewComments: vi.fn(),
|
||||
})),
|
||||
PrCommentHandler: vi.fn().mockImplementation(() => ({
|
||||
handleNewComments: vi.fn(),
|
||||
createFollowUpTask: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
CronRunner: mocks.cronRunnerCtor,
|
||||
StuckTaskDetector: mocks.stuckDetectorCtor,
|
||||
SelfHealingManager: mocks.selfHealingCtor,
|
||||
MissionAutopilot: mocks.missionAutopilotCtor,
|
||||
createAiPromptExecutor: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok")),
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
create: vi.fn(() => mocks.authStorage),
|
||||
},
|
||||
DefaultPackageManager: vi.fn().mockImplementation(() => ({
|
||||
resolve: vi.fn().mockResolvedValue({ extensions: [] }),
|
||||
})),
|
||||
ModelRegistry: vi.fn().mockImplementation(() => mocks.modelRegistry),
|
||||
SettingsManager: {
|
||||
create: vi.fn(() => ({})),
|
||||
},
|
||||
discoverAndLoadExtensions: vi.fn().mockResolvedValue({
|
||||
runtime: { pendingProviderRegistrations: [] },
|
||||
errors: [],
|
||||
}),
|
||||
getAgentDir: vi.fn(() => "/mock-agent-dir"),
|
||||
createExtensionRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../dashboard.js", () => ({
|
||||
promptForPort: vi.fn(async (port: number) => port),
|
||||
getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"),
|
||||
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
|
||||
}));
|
||||
|
||||
const { runServe } = await import("../serve.js");
|
||||
|
||||
describe("runServe", () => {
|
||||
const originalCwd = process.cwd;
|
||||
const originalOn = process.on;
|
||||
const originalExit = process.exit;
|
||||
|
||||
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processOnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
const handlers = signalHandlers[signal];
|
||||
expect(handlers.length).toBeGreaterThan(0);
|
||||
handlers[handlers.length - 1]();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.reset();
|
||||
|
||||
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
||||
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
|
||||
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === "SIGINT" || event === "SIGTERM") {
|
||||
signalHandlers[event].push(listener);
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.on);
|
||||
process.exit = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
processOnSpy.mockRestore();
|
||||
process.cwd = originalCwd;
|
||||
process.on = originalOn;
|
||||
process.exit = originalExit;
|
||||
});
|
||||
|
||||
it("initializes stores, starts engine services, and creates a headless server", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
|
||||
expect(mocks.taskStores[0].init).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.taskStores[0].watch).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.automationStoreCtor).toHaveBeenCalledWith("/repo");
|
||||
expect(mocks.automationStores[0].init).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.agentStores[0].init).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mocks.createServerMock).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createServerMock.mock.calls[0][1]).toMatchObject({
|
||||
headless: true,
|
||||
});
|
||||
|
||||
expect(mocks.triageInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.schedulerInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.missionAutopilotInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.stuckDetectorInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.selfHealingInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.executorInstances[0].resumeOrphaned).toHaveBeenCalledTimes(1);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
|
||||
it("sets enginePaused when started with paused=true", async () => {
|
||||
await runServe(0, { paused: true });
|
||||
|
||||
expect(mocks.taskStores[0].updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
||||
|
||||
await triggerSignal("SIGTERM");
|
||||
});
|
||||
|
||||
it("updates the local node status online on startup and offline on shutdown", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
|
||||
expect(nodeCentral).toBeDefined();
|
||||
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "online" });
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
|
||||
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "offline" });
|
||||
});
|
||||
|
||||
it("stops engine services during shutdown", async () => {
|
||||
await runServe(4040, {});
|
||||
|
||||
const listenCall = mocks.listenCalls[0];
|
||||
expect(listenCall).toBeDefined();
|
||||
|
||||
await triggerSignal("SIGTERM");
|
||||
|
||||
expect(mocks.selfHealingInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.stuckDetectorInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.missionAutopilotInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.triageInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.schedulerInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.cronRunnerInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notifierInstances[0].stop).toHaveBeenCalledTimes(1);
|
||||
expect(listenCall.server.close).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.taskStores[0].close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("listens on 0.0.0.0 by default and respects a custom host", async () => {
|
||||
await runServe(3010, {});
|
||||
expect(mocks.listenCalls[0]).toMatchObject({
|
||||
port: 3010,
|
||||
host: "0.0.0.0",
|
||||
});
|
||||
await triggerSignal("SIGINT");
|
||||
|
||||
await runServe(3020, { host: "127.0.0.1" });
|
||||
expect(mocks.listenCalls[1]).toMatchObject({
|
||||
port: 3020,
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
await triggerSignal("SIGINT");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user