feat(FN-1831): merge fusion/fn-1831

This commit is contained in:
gsxdsm
2026-04-15 04:01:02 -07:00
parent cd5d4551b6
commit bae5d1d812
9 changed files with 1713 additions and 0 deletions

View File

@@ -0,0 +1,795 @@
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 missionExecutionLoopInstances: any[] = [];
const notifierInstances: any[] = [];
const pluginStoreInstances: any[] = [];
const pluginLoaderInstances: any[] = [];
const projectEngineInstances: any[] = [];
const listenCalls: ListenCall[] = [];
// GlobalSettingsStore mock
let globalSettingsData: Record<string, unknown> = {};
const globalSettingsStoreInstance = {
getSettings: vi.fn().mockImplementation(() => Promise.resolve({ ...globalSettingsData })),
updateSettings: vi.fn().mockImplementation((settings: Record<string, unknown>) => {
globalSettingsData = { ...globalSettingsData, ...settings };
return Promise.resolve();
}),
};
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),
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
};
}
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" }),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
),
listProjects: vi.fn().mockResolvedValue([
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
updateNode: vi.fn().mockResolvedValue(undefined),
startDiscovery: vi.fn().mockResolvedValue({}),
stopDiscovery: vi.fn(),
};
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 missionExecutionLoopCtor = vi.fn().mockImplementation(() => {
const loop = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
processTaskOutcome: vi.fn().mockResolvedValue(undefined),
recoverActiveMissions: vi.fn().mockResolvedValue(undefined),
};
missionExecutionLoopInstances.push(loop);
return loop;
});
const notifierCtor = vi.fn().mockImplementation(() => {
const notifier = {
start: vi.fn(),
stop: vi.fn(),
};
notifierInstances.push(notifier);
return notifier;
});
const pluginStoreCtor = vi.fn().mockImplementation(() => {
const pluginStore = {
init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
unregisterPlugin: vi.fn(),
updatePluginState: vi.fn(),
};
pluginStoreInstances.push(pluginStore);
return pluginStore;
});
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
};
pluginLoaderInstances.push(pluginLoader);
return pluginLoader;
});
const authStorage = {
getApiKey: vi.fn().mockResolvedValue(undefined),
reload: vi.fn(),
getOAuthProviders: vi.fn().mockReturnValue([]),
hasAuth: vi.fn().mockReturnValue(false),
login: vi.fn(),
logout: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
get: vi.fn(),
};
const modelRegistry = {
getAll: vi.fn().mockReturnValue([]),
registerProvider: vi.fn(),
refresh: vi.fn(),
};
const agentSemaphoreCtor = vi.fn().mockImplementation(() => ({
_active: 0,
run: (fn: () => Promise<unknown>) => fn(),
}));
const heartbeatMonitorCtor = vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
startRun: vi.fn().mockResolvedValue({ id: "run-1" }),
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
stopRun: vi.fn().mockResolvedValue(undefined),
}));
const heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
registerAgent: vi.fn(),
getRegisteredAgents: vi.fn().mockReturnValue([]),
}));
const createAiPromptExecutorMock = vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok"));
const syncInsightExtractionAutomationMock = vi.fn().mockResolvedValue(undefined);
const processAndAuditInsightExtractionMock = vi.fn().mockResolvedValue({
generatedAt: new Date().toISOString(),
health: "healthy",
checks: [],
workingMemory: { exists: true, size: 100, sectionCount: 2 },
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
pruning: { applied: false },
});
const projectEngineCtor = vi.fn().mockImplementation((runtimeConfig: { workingDirectory: string }, _centralCore: unknown, options: { onInsightRunProcessed?: unknown }) => {
const store = taskStoreCtor(runtimeConfig.workingDirectory);
const automationStore = automationStoreCtor(runtimeConfig.workingDirectory);
const agentStore = agentStoreCtor();
const semaphore = agentSemaphoreCtor();
const heartbeatMonitor = heartbeatMonitorCtor({});
const heartbeatTriggerScheduler = heartbeatTriggerSchedulerCtor(agentStore, vi.fn(), store);
const missionAutopilot = missionAutopilotCtor();
const missionExecutionLoop = missionExecutionLoopCtor();
const triage = triageCtor(store, undefined, { semaphore });
const executor = executorCtor(store, undefined, { semaphore });
const scheduler = schedulerCtor(store, { semaphore });
const stuckDetector = stuckDetectorCtor();
const selfHealing = selfHealingCtor();
const cronRunner = cronRunnerCtor(store, automationStore, {
onScheduleRunProcessed: options.onInsightRunProcessed,
});
const notifier = notifierCtor();
const engine = {
start: vi.fn(async () => {
await store.init();
await automationStore.init();
await agentStore.init();
const settings = await store.getSettings();
try {
await syncInsightExtractionAutomationMock(automationStore, settings);
} catch (err) {
console.error(`[memory-audit] Failed to sync insight extraction: ${err instanceof Error ? err.message : String(err)}`);
}
triage.start();
scheduler.start();
missionAutopilot.start();
stuckDetector.start();
selfHealing.start();
cronRunner.start();
notifier.start();
heartbeatMonitor.start();
heartbeatTriggerScheduler.start();
await executor.resumeOrphaned();
await createAiPromptExecutorMock(runtimeConfig.workingDirectory);
}),
stop: vi.fn(async () => {
selfHealing.stop();
stuckDetector.stop();
missionAutopilot.stop();
triage.stop();
scheduler.stop();
cronRunner.stop();
notifier.stop();
heartbeatMonitor.stop();
heartbeatTriggerScheduler.stop();
}),
getTaskStore: vi.fn(() => store),
getAutomationStore: vi.fn(() => automationStore),
getRuntime: vi.fn(() => ({
getHeartbeatMonitor: () => heartbeatMonitor,
getMissionAutopilot: () => missionAutopilot,
getMissionExecutionLoop: () => missionExecutionLoop,
})),
onMerge: vi.fn().mockResolvedValue(undefined),
};
projectEngineInstances.push(engine);
return engine;
});
return {
taskStores,
automationStores,
agentStores,
centralInstances,
triageInstances,
executorInstances,
schedulerInstances,
stuckDetectorInstances,
selfHealingInstances,
cronRunnerInstances,
missionAutopilotInstances,
missionExecutionLoopInstances,
notifierInstances,
projectEngineInstances,
listenCalls,
globalSettingsStoreInstance,
globalSettingsData,
taskStoreCtor,
automationStoreCtor,
agentStoreCtor,
centralCoreCtor,
createServerMock,
triageCtor,
executorCtor,
schedulerCtor,
stuckDetectorCtor,
selfHealingCtor,
cronRunnerCtor,
missionAutopilotCtor,
missionExecutionLoopCtor,
notifierCtor,
pluginStoreCtor,
pluginLoaderCtor,
projectEngineCtor,
agentSemaphoreCtor,
heartbeatMonitorCtor,
heartbeatTriggerSchedulerCtor,
createAiPromptExecutorMock,
syncInsightExtractionAutomationMock,
processAndAuditInsightExtractionMock,
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;
missionExecutionLoopInstances.length = 0;
notifierInstances.length = 0;
pluginStoreInstances.length = 0;
pluginLoaderInstances.length = 0;
projectEngineInstances.length = 0;
listenCalls.length = 0;
globalSettingsData = {};
syncInsightExtractionAutomationMock.mockReset();
syncInsightExtractionAutomationMock.mockResolvedValue(undefined);
processAndAuditInsightExtractionMock.mockClear();
createAiPromptExecutorMock.mockClear();
},
};
});
vi.mock("@fusion/core", () => ({
TaskStore: mocks.taskStoreCtor,
AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor,
CentralCore: mocks.centralCoreCtor,
PluginStore: mocks.pluginStoreCtor,
PluginLoader: mocks.pluginLoaderCtor,
GlobalSettingsStore: vi.fn().mockImplementation(() => mocks.globalSettingsStoreInstance),
resolveGlobalDir: vi.fn().mockReturnValue("/home/user/.pi/fusion"),
DaemonTokenManager: vi.fn().mockImplementation(() => ({
getToken: vi.fn().mockImplementation(() => Promise.resolve(mocks.globalSettingsData.daemonToken as string | undefined)),
generateToken: vi.fn().mockImplementation(() => {
const token = "fn_a1b2c3d4e5f6789012345678901234ab";
mocks.globalSettingsData.daemonToken = token;
return Promise.resolve(token);
}),
})),
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: mocks.processAndAuditInsightExtractionMock,
}));
vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServerMock,
GitHubClient: vi.fn().mockImplementation(() => ({})),
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
}));
vi.mock("@fusion/engine", () => ({
ProjectEngine: mocks.projectEngineCtor,
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
const engines = new Map<string, any>();
return {
startAll: vi.fn(async () => {
const projects = await centralCore.listProjects();
for (const project of projects) {
const engine = mocks.projectEngineCtor(
{ projectId: project.id, workingDirectory: project.path, isolationMode: "in-process", maxConcurrent: 4, maxWorktrees: 10 },
centralCore,
{ ...options, projectId: project.id },
);
await engine.start();
engines.set(project.id, engine);
}
}),
getEngine: vi.fn((id: string) => engines.get(id)),
getAllEngines: vi.fn(() => engines),
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
has: vi.fn((id: string) => engines.has(id)),
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
stopAll: vi.fn(async () => {
for (const engine of engines.values()) await engine.stop();
engines.clear();
}),
onProjectAccessed: vi.fn(),
startReconciliation: vi.fn(),
};
}),
PeerExchangeService: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
})),
TriageProcessor: mocks.triageCtor,
TaskExecutor: mocks.executorCtor,
Scheduler: mocks.schedulerCtor,
AgentSemaphore: mocks.agentSemaphoreCtor,
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,
MissionExecutionLoop: mocks.missionExecutionLoopCtor,
createAiPromptExecutor: mocks.createAiPromptExecutorMock,
HeartbeatMonitor: mocks.heartbeatMonitorCtor,
HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor,
}));
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("../task-lifecycle.js", () => ({
getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"),
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
}));
const { runDaemon } = await import("../daemon.js");
describe("runDaemon", () => {
const originalCwd = process.cwd;
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.exit = originalExit;
});
it("initializes stores, starts engine services, and creates a headless server with daemon auth", async () => {
await runDaemon({});
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
expect(mocks.taskStores[0].init).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].watch).toHaveBeenCalledTimes(1);
expect(mocks.createServerMock).toHaveBeenCalledTimes(1);
const serverOptions = mocks.createServerMock.mock.calls[0][1];
expect(serverOptions).toMatchObject({
headless: true,
});
// Verify daemon token was passed
expect(serverOptions.daemon).toBeDefined();
expect(typeof serverOptions.daemon.token).toBe("string");
expect(serverOptions.daemon.token.startsWith("fn_")).toBe(true);
expect(mocks.triageInstances[0].start).toHaveBeenCalledTimes(1);
expect(mocks.schedulerInstances[0].start).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("passes provided token to createServer daemon option", async () => {
const providedToken = "fn_custom_token_1234567890123456";
await runDaemon({ token: providedToken });
expect(mocks.createServerMock).toHaveBeenCalledTimes(1);
const serverOptions = mocks.createServerMock.mock.calls[0][1];
expect(serverOptions.daemon).toBeDefined();
expect(serverOptions.daemon.token).toBe(providedToken);
await triggerSignal("SIGINT");
});
it("generates a token when none exists", async () => {
// No existing token in mock data - clear it first
mocks.globalSettingsData = {};
await runDaemon({});
expect(mocks.createServerMock).toHaveBeenCalledTimes(1);
const serverOptions = mocks.createServerMock.mock.calls[0][1];
expect(serverOptions.daemon).toBeDefined();
expect(serverOptions.daemon.token).toMatch(/^fn_[a-f0-9]{32}$/);
await triggerSignal("SIGINT");
});
it("prints banner with full token at startup", async () => {
const providedToken = "fn_fulltoken12345678901234567890";
await runDaemon({ token: providedToken });
// Banner should contain the full token
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(providedToken));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Fusion Daemon"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("bearer token required"));
await triggerSignal("SIGINT");
});
it("passes daemon token option with enginePaused when paused=true", async () => {
await runDaemon({ paused: true });
expect(mocks.taskStores[0].updateSettings).toHaveBeenCalledWith({ enginePaused: true });
expect(mocks.createServerMock).toHaveBeenCalledTimes(1);
const serverOptions = mocks.createServerMock.mock.calls[0][1];
expect(serverOptions.daemon).toBeDefined();
expect(serverOptions.daemon.token).toBeDefined();
await triggerSignal("SIGINT");
});
it("listens on port 0 for random assignment by default", async () => {
await runDaemon({});
expect(mocks.listenCalls[0]).toMatchObject({
port: 0,
host: "0.0.0.0",
});
await triggerSignal("SIGINT");
});
it("respects custom port and host options", async () => {
await runDaemon({ port: 8080, host: "127.0.0.1" });
expect(mocks.listenCalls[0]).toMatchObject({
port: 8080,
host: "127.0.0.1",
});
await triggerSignal("SIGINT");
});
it("stops engine services during shutdown", async () => {
await runDaemon({});
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(listenCall.server.close).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].close).toHaveBeenCalledTimes(1);
});
});
describe("runDaemon --token-only mode", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let processExitSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processExitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
processExitSpy.mockRestore();
cwdSpy.mockRestore();
});
it("generates and prints token, then exits", async () => {
// Clear any existing token
mocks.globalSettingsData = {};
// Expect process.exit(0) to be called
await expect(runDaemon({ tokenOnly: true })).rejects.toThrow("process.exit:0");
// Should print the generated token
expect(logSpy).toHaveBeenCalled();
const tokenCall = logSpy.mock.calls.find((call) =>
typeof call[0] === "string" && call[0].startsWith("fn_")
);
expect(tokenCall).toBeDefined();
expect((tokenCall as string[])[0]).toMatch(/^fn_[a-f0-9]{32}$/);
// Should exit with code 0
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it("prints existing token without generating new one", async () => {
const existingToken = "fn_existingtoken1234567890123456";
mocks.globalSettingsData.daemonToken = existingToken;
// Expect process.exit(0) to be called
await expect(runDaemon({ tokenOnly: true })).rejects.toThrow("process.exit:0");
// Should print the existing token
expect(logSpy).toHaveBeenCalledWith(existingToken);
// Should exit with code 0
expect(processExitSpy).toHaveBeenCalledWith(0);
});
});

View File

@@ -0,0 +1,525 @@
/**
* Fusion Daemon command - API server with bearer token authentication.
*
* ⚠️ ARCHITECTURAL BOUNDARY: This module must NOT import from ./dashboard.js.
*
* The daemon command runs independently of the dashboard UI with secure
* bearer token authentication. Shared task lifecycle helpers are imported
* from ./task-lifecycle.js, and interactive port prompts from ./port-prompt.js.
*/
import type { AddressInfo } from "node:net";
import {
CentralCore,
PluginStore,
PluginLoader,
getTaskMergeBlocker,
INSIGHT_EXTRACTION_SCHEDULE_NAME,
processAndAuditInsightExtraction,
DaemonTokenManager,
GlobalSettingsStore,
resolveGlobalDir,
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath } from "@fusion/dashboard";
import { ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import {
AuthStorage,
DefaultPackageManager,
ModelRegistry,
discoverAndLoadExtensions,
getAgentDir,
createExtensionRuntime,
} from "@mariozechner/pi-coding-agent";
import {
getMergeStrategy,
processPullRequestMergeTask,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
let daemonDbHealthCheck: (() => boolean) | null = null;
/**
* Format bytes to human-readable string
*/
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
}
/**
* Format milliseconds to human-readable uptime string
*/
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d${hours % 24}h`;
if (hours > 0) return `${hours}h${minutes % 60}m`;
if (minutes > 0) return `${minutes}m${seconds % 60}s`;
return `${seconds}s`;
}
/**
* Get and log current process diagnostics (memory, handles, requests)
* @param dbHealthCheck - Optional function to check database health
*/
function logDiagnostics(dbHealthCheck?: () => boolean): void {
const mem = process.memoryUsage();
const uptime = Date.now() - daemonStartTime;
let handleCount = -1;
let requestCount = -1;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleCount = (process as any)._getActiveHandles?.()?.length ?? -1;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
requestCount = (process as any)._getActiveRequests?.()?.length ?? -1;
} catch {
// Ignore errors if these internal APIs are not available
}
let dbHealth = "unknown";
if (dbHealthCheck) {
try {
dbHealth = dbHealthCheck() ? "ok" : "failed";
} catch {
dbHealth = "error";
}
}
const logLine = `[daemon] diagnostics: uptime=${formatUptime(uptime)} ` +
`rss=${formatBytes(mem.rss)} heap=${formatBytes(mem.heapUsed)}/${formatBytes(mem.heapTotal)} ` +
`external=${formatBytes(mem.external)} arrayBuffers=${formatBytes(mem.arrayBuffers)} ` +
`handles=${handleCount} requests=${requestCount} db=${dbHealth}`;
console.log(logLine);
}
/**
* Mask a token for display, showing only first 3 and last 4 characters.
*/
function maskToken(token: string): string {
if (token.length <= 10) {
return "***";
}
return `${token.slice(0, 6)}...${token.slice(-4)}`;
}
export interface DaemonOptions {
/** Port to listen on (default: 0 for random port) */
port?: number;
/** Host to bind to (default: 0.0.0.0) */
host?: string;
/** Specific token to use (generated if not provided) */
token?: string;
/** Start with engine paused */
paused?: boolean;
/** Interactive port selection */
interactive?: boolean;
/** Just print/generate token without starting server */
tokenOnly?: boolean;
}
export async function runDaemon(opts: DaemonOptions = {}) {
daemonStartTime = Date.now();
// ── Token management ──────────────────────────────────────────────
//
// Token-only mode: just generate/print token and exit
//
if (opts.tokenOnly) {
const globalDir = resolveGlobalDir();
const settingsStore = new GlobalSettingsStore(globalDir);
const tokenManager = new DaemonTokenManager(settingsStore);
try {
// Try to get existing token, or generate a new one
let token = await tokenManager.getToken();
if (!token) {
token = await tokenManager.generateToken();
}
console.log(token);
} catch (err) {
console.error(`Error managing daemon token: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
process.exit(0);
return;
}
// For server mode, we need to start the engine
// Get or generate token
let daemonToken: string;
if (opts.token) {
daemonToken = opts.token;
} else {
const globalDir = resolveGlobalDir();
const settingsStore = new GlobalSettingsStore(globalDir);
const tokenManager = new DaemonTokenManager(settingsStore);
// Check for token in environment (fallback)
const envToken = process.env.FUSION_DAEMON_TOKEN;
if (envToken) {
daemonToken = envToken;
} else {
// Get or create token
try {
const existing = await tokenManager.getToken();
daemonToken = existing ?? await tokenManager.generateToken();
} catch (err) {
console.error(`Error managing daemon token: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}
}
}
let selectedPort = opts.port ?? 0;
if (opts.interactive) {
try {
selectedPort = await promptForPort(selectedPort);
} catch (err: any) {
if (err.message === "Interactive prompt cancelled") {
console.log("Cancelled — exiting");
process.exit(0);
}
throw err;
}
}
const selectedHost = opts.host ?? "0.0.0.0";
const cwd = process.cwd();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
let ntfyProjectId: string | undefined;
let sharedCentralCore: CentralCore | null = null;
try {
sharedCentralCore = new CentralCore();
await sharedCentralCore.init();
const registered = await sharedCentralCore.getProjectByPath(cwd);
if (registered) {
ntfyProjectId = registered.id;
}
} catch {
// Central DB unavailable or project not registered — backward compatible
}
// ── ProjectEngineManager: uniform engine lifecycle for all projects ──
const githubClient = new GitHubClient(process.env.GITHUB_TOKEN);
// Post-run callback for memory insight extraction processing
const onMemoryInsightRunProcessed = async (
schedule: ScheduledTask,
result: AutomationRunResult,
): Promise<void> => {
if (schedule.name !== INSIGHT_EXTRACTION_SCHEDULE_NAME) {
return;
}
const stepResults = result.stepResults ?? [];
const aiStep = stepResults.find(
(sr) => sr.stepName === "Extract Memory Insights and Prune" || sr.stepName === "Extract Memory Insights",
);
if (!aiStep) {
return;
}
try {
const auditReport = await processAndAuditInsightExtraction(cwd, {
rawResponse: aiStep.output ?? "",
stepSuccess: aiStep.success,
runAt: result.startedAt,
error: aiStep.error,
});
const pruneStatus = auditReport.pruning.applied
? ` | Pruned: ${auditReport.pruning.originalSize}${auditReport.pruning.newSize} chars`
: ` | Pruning: ${auditReport.pruning.reason}`;
console.log(
`[memory-audit] ✓ Audit complete — Health: ${auditReport.health}, ` +
`Insights: ${auditReport.insightsMemory.insightCount}${pruneStatus}`,
);
} catch (err) {
console.error(
`[memory-audit] ✗ Failed to process insight extraction: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
if (!sharedCentralCore) {
sharedCentralCore = new CentralCore();
try {
await sharedCentralCore.init();
} catch {
// Non-fatal — engine uses fallback defaults
}
}
const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
getTaskMergeBlocker,
onInsightRunProcessed: onMemoryInsightRunProcessed as any,
});
await engineManager.startAll();
engineManager.startReconciliation();
// ── PeerExchangeService: gossip protocol for mesh peer discovery ──────
let peerExchangeService: PeerExchangeService | null = null;
if (sharedCentralCore) {
peerExchangeService = new PeerExchangeService(sharedCentralCore);
try {
peerExchangeService.start();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[daemon] Failed to start peer exchange service: ${message}`);
}
}
// 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");
process.exit(1);
return;
}
const store = cwdEngine.getTaskStore();
await store.watch();
// Set up database health check for diagnostics
daemonDbHealthCheck = () => store.healthCheck();
if (opts.paused) {
await store.updateSettings({ enginePaused: true });
console.log("[engine] Starting in paused mode — automation disabled");
}
// ── PluginStore: plugin installation management ─────────────────────
const pluginStore = new PluginStore(store.getFusionDir());
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
const pluginLoader = new PluginLoader({
pluginStore,
taskStore: store,
});
// 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();
const authStorage = AuthStorage.create();
const modelRegistry = new ModelRegistry(authStorage);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
try {
const agentDir = getAgentDir();
packageManager = new DefaultPackageManager({
cwd,
agentDir,
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as any,
});
const resolvedPaths = await packageManager.resolve();
const packageExtensionPaths = resolvedPaths.extensions
.filter((r) => r.enabled)
.map((r) => r.path);
const extensionsResult = await discoverAndLoadExtensions(
packageExtensionPaths,
cwd,
undefined,
);
for (const { path, error } of extensionsResult.errors) {
console.log(`[extensions] Failed to load ${path}: ${error}`);
}
for (const {
name,
config,
extensionPath,
} of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(
`[extensions] Failed to register provider from ${extensionPath}: ${message}`,
);
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`[extensions] Failed to discover extensions: ${message}`);
createExtensionRuntime();
modelRegistry.refresh();
}
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// ── Skills adapter for skills discovery and execution toggling ─────────────
const skillsAdapter = packageManager
? createSkillsAdapter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
packageManager: packageManager as any,
getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir),
})
: undefined;
// Diagnostic interval
setInterval(() => {
logDiagnostics(daemonDbHealthCheck ?? undefined);
}, DIAGNOSTIC_INTERVAL_MS).unref?.();
const app = createServer(store, {
engine: cwdEngine,
engineManager,
onMerge: (taskId) => cwdEngine.onMerge(taskId),
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,
missionAutopilot,
missionExecutionLoop,
heartbeatMonitor: heartbeatMonitor
? {
rootDir: cwd,
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),
}
: undefined,
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
headless: true,
daemon: { token: daemonToken },
skillsAdapter,
});
const server = app.listen(selectedPort, selectedHost);
await new Promise<void>((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
const actualPort = (server.address() as AddressInfo).port;
// ── CentralCore: node registration ────────────────────────────────────
let centralCore: CentralCore | null = sharedCentralCore;
if (!centralCore) {
try {
centralCore = new CentralCore();
await centralCore.init();
} catch {
centralCore = null;
}
}
let localNodeId: string | undefined;
try {
if (centralCore) {
const nodes = await centralCore.listNodes();
const localNode = nodes.find((node) => node.type === "local");
if (localNode) {
localNodeId = localNode.id;
await centralCore.updateNode(localNode.id, { status: "online" });
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[daemon] Failed to set local node online: ${message}`);
}
// Print startup banner with full token (shown once at startup)
console.log();
console.log(` Fusion Daemon`);
console.log(` ────────────────────────`);
console.log(` → http://${selectedHost}:${actualPort}`);
console.log();
console.log(` Token: ${daemonToken}`);
console.log();
console.log(` Health: GET /api/health`);
console.log(` API: /api/* (bearer token required)`);
console.log(` AI engine: ✓ active`);
console.log(` Press Ctrl+C to stop`);
console.log();
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
// Stop all project engines uniformly
await engineManager.stopAll();
// Stop peer exchange service
if (peerExchangeService) {
try {
await peerExchangeService.stop();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[daemon] Failed to stop peer exchange service: ${message}`);
}
}
if (centralCore && localNodeId) {
try {
await centralCore.updateNode(localNodeId, { status: "offline" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[daemon] Failed to set local node offline: ${message}`);
}
}
if (centralCore) {
await centralCore.close().catch(() => {
// best-effort
});
centralCore = null;
}
try {
server.close();
} catch {
// best-effort
}
store.close();
process.exit(0);
};
process.on("SIGINT", () => {
void shutdown();
});
process.on("SIGTERM", () => {
void shutdown();
});
// Ignore SIGHUP so the daemon survives SSH session disconnects
process.on("SIGHUP", () => {
console.log("[daemon] Received SIGHUP (terminal disconnected) — ignoring");
});
}