feat(FN-1831): merge fusion/fn-1831
This commit is contained in:
795
packages/cli/src/commands/__tests__/daemon.test.ts
Normal file
795
packages/cli/src/commands/__tests__/daemon.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
525
packages/cli/src/commands/daemon.ts
Normal file
525
packages/cli/src/commands/daemon.ts
Normal 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");
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user