feat(FN-3049): stabilize engine imports and add agent onboarding modal

Merges FN-3049 to replace all runtime dynamic imports of `@fusion/engine` with static imports that esbuild can inline into the CLI bundle, preventing the known regression where the published `npm i -g @runfusion/fusion` silently failed at the import site. Also ships FN-3024, adding an experimental a

Fusion-Task-Id: FN-3049
This commit is contained in:
Fusion
2026-05-01 08:58:38 -07:00
committed by gsxdsm
parent f586ceca40
commit 7011831423
9 changed files with 146 additions and 52 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Replace dashboard runtime dynamic `@fusion/engine` imports with bundler-safe static imports and add regression coverage to prevent reintroduction. This avoids npm-installed runtime failures caused by non-static engine imports that cannot be safely inlined during bundling.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const DYNAMIC_ENGINE_IMPORT_PATTERN = /await\s+import\((\/\*.*?\*\/\s*)?"@fusion\/engine"\)/g;
function collectTsFiles(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
if (entry === "__tests__") {
continue;
}
files.push(...collectTsFiles(fullPath));
continue;
}
if (!fullPath.endsWith(".ts") && !fullPath.endsWith(".tsx")) {
continue;
}
if (/\.test\.tsx?$/.test(fullPath)) {
continue;
}
files.push(fullPath);
}
return files;
}
describe("FN-3049 regression: runtime engine imports stay bundler-safe", () => {
it("blocks dynamic await import('@fusion/engine') in dashboard/cli runtime source", () => {
const testDir = dirname(fileURLToPath(import.meta.url));
const dashboardSrcDir = join(testDir, "..");
const cliSrcDir = join(testDir, "..", "..", "..", "cli", "src");
const filesToAudit = [...collectTsFiles(dashboardSrcDir), ...collectTsFiles(cliSrcDir)];
const offenders: string[] = [];
for (const filePath of filesToAudit) {
const content = readFileSync(filePath, "utf8");
if (DYNAMIC_ENGINE_IMPORT_PATTERN.test(content)) {
offenders.push(relative(dashboardSrcDir, filePath));
}
DYNAMIC_ENGINE_IMPORT_PATTERN.lastIndex = 0;
}
expect(offenders).toEqual([]);
});
});

View File

@@ -35,6 +35,7 @@ import { get as performGet, request as performRequest } from "../test-request.js
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js"; import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js"; import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
import * as updateCheckModule from "../update-check.js"; import * as updateCheckModule from "../update-check.js";
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
// Mock @fusion/core for gh CLI auth checks // Mock @fusion/core for gh CLI auth checks
const mockCentralListProjects = vi.fn().mockResolvedValue([]); const mockCentralListProjects = vi.fn().mockResolvedValue([]);
@@ -18730,6 +18731,15 @@ describe("Agent Reflection routes", () => {
return app; return app;
} }
afterEach(async () => {
const engine = await import("@fusion/engine");
const reflectionService =
"AgentReflectionService" in engine && typeof engine.AgentReflectionService === "function"
? (engine.AgentReflectionService as unknown as Parameters<typeof __setAgentReflectionServiceForTests>[0])
: undefined;
__setAgentReflectionServiceForTests(reflectionService);
});
describe("GET /api/agents/:id/reflections", () => { describe("GET /api/agents/:id/reflections", () => {
it("returns 200 for valid agent (uses real stores)", async () => { it("returns 200 for valid agent (uses real stores)", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflections`); const res = await GET(buildApp(), `/api/agents/${agentId}/reflections`);
@@ -18780,6 +18790,7 @@ describe("Agent Reflection routes", () => {
it("returns 500 with a clear message when reflection generation returns null", async () => { it("returns 500 with a clear message when reflection generation returns null", async () => {
const engine = await import("@fusion/engine"); const engine = await import("@fusion/engine");
__setAgentReflectionServiceForTests(engine.AgentReflectionService);
const generateReflectionSpy = vi const generateReflectionSpy = vi
.spyOn(engine.AgentReflectionService.prototype, "generateReflection") .spyOn(engine.AgentReflectionService.prototype, "generateReflection")
.mockResolvedValueOnce(null); .mockResolvedValueOnce(null);
@@ -18826,6 +18837,13 @@ describe("Agent Reflection routes", () => {
}); });
describe("GET /api/agents/:id/reflection-context", () => { describe("GET /api/agents/:id/reflection-context", () => {
it("returns 503 when reflection service binding is unavailable", async () => {
__setAgentReflectionServiceForTests(undefined);
const res = await GET(buildApp(), `/api/agents/${agentId}/reflection-context`);
expect(res.status).toBe(503);
expect(res.body.error).toContain("Reflection service not available");
});
it("returns 200 when reflection context is available, otherwise 500/503", async () => { it("returns 200 when reflection context is available, otherwise 500/503", async () => {
const res = await GET(buildApp(), `/api/agents/${agentId}/reflection-context`); const res = await GET(buildApp(), `/api/agents/${agentId}/reflection-context`);

View File

@@ -27,6 +27,7 @@ import { join, resolve, relative } from "node:path";
import { SessionEventBuffer } from "./sse-buffer.js"; import { SessionEventBuffer } from "./sse-buffer.js";
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import * as engineModule from "@fusion/engine";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type AgentResult = any; type AgentResult = any;
@@ -100,13 +101,8 @@ async function ensureEngineReady(): Promise<void> {
return; return;
} }
try { if ("buildAgentChatPrompt" in engineModule && typeof engineModule.buildAgentChatPrompt === "function") {
const engine = await import("@fusion/engine"); buildAgentChatPromptFn = engineModule.buildAgentChatPrompt;
if ("buildAgentChatPrompt" in engine && typeof engine.buildAgentChatPrompt === "function") {
buildAgentChatPromptFn = engine.buildAgentChatPrompt;
}
} catch {
// Optional helper unavailable in mocked/test contexts.
} }
} }

View File

@@ -28,6 +28,7 @@ import {
badRequest, badRequest,
notFound, notFound,
} from "./api-error.js"; } from "./api-error.js";
import { createFnAgent, promptWithFallback } from "@fusion/engine";
/** /**
* Re-throws an error as an ApiError, converting unknown errors to internal errors. * Re-throws an error as an ApiError, converting unknown errors to internal errors.
@@ -251,8 +252,6 @@ export function createInsightsRouter(store: TaskStore): Router {
const existingInsights = await readInsightsMemory(rootDir); const existingInsights = await readInsightsMemory(rootDir);
try { try {
const { createFnAgent, promptWithFallback } = await import("@fusion/engine");
let responseText = ""; let responseText = "";
const { session } = await createFnAgent({ const { session } = await createFnAgent({
cwd: rootDir, cwd: rootDir,

View File

@@ -31,6 +31,7 @@ import {
nonfatal, nonfatal,
} from "./ai-session-diagnostics.js"; } from "./ai-session-diagnostics.js";
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine"; import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import * as engineModule from "@fusion/engine";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type AgentResult = any; type AgentResult = any;
@@ -133,28 +134,25 @@ async function ensureNtfyHelpersReady(): Promise<void> {
return; return;
} }
try { const hasNotificationService = "NotificationService" in engineModule
const engine = await import("@fusion/engine"); && typeof engineModule.NotificationService === "function";
const hasNotificationService = "NotificationService" in engine
&& typeof engine.NotificationService === "function";
const hasAllHelpers = const hasAllHelpers =
"isNtfyEventEnabled" in engine "isNtfyEventEnabled" in engineModule
&& "buildNtfyClickUrl" in engine && "buildNtfyClickUrl" in engineModule
&& "sendNtfyNotification" in engine && "sendNtfyNotification" in engineModule
&& typeof engine.isNtfyEventEnabled === "function" && typeof engineModule.isNtfyEventEnabled === "function"
&& typeof engine.buildNtfyClickUrl === "function" && typeof engineModule.buildNtfyClickUrl === "function"
&& typeof engine.sendNtfyNotification === "function"; && typeof engineModule.sendNtfyNotification === "function";
if (!hasAllHelpers) { if (!hasAllHelpers) {
return; return;
} }
planningNtfyHelpers = { planningNtfyHelpers = {
isNtfyEventEnabled: engine.isNtfyEventEnabled, isNtfyEventEnabled: engineModule.isNtfyEventEnabled,
buildNtfyClickUrl: engine.buildNtfyClickUrl, buildNtfyClickUrl: engineModule.buildNtfyClickUrl,
sendNtfyNotification: engine.sendNtfyNotification, sendNtfyNotification: engineModule.sendNtfyNotification,
}; };
if (hasNotificationService) { if (hasNotificationService) {
@@ -163,9 +161,6 @@ async function ensureNtfyHelpersReady(): Promise<void> {
{ operation: "notification-service-detection" }, { operation: "notification-service-detection" },
); );
} }
} catch {
// Optional notifier helpers unavailable in this runtime/test context.
}
} }
// ── Constants ─────────────────────────────────────────────────────────────── // ── Constants ───────────────────────────────────────────────────────────────

View File

@@ -275,7 +275,7 @@ async function discoverDashboardPiExtensions(cwd: string): Promise<PiExtensionSe
}; };
} }
import { createFnAgent as engineCreateFnAgentForRefine } from "@fusion/engine"; import { createFnAgent as engineCreateFnAgentForRefine, promptWithFallback as enginePromptWithFallback } from "@fusion/engine";
// Test-injectable override; defaults to the statically imported engine binding. // Test-injectable override; defaults to the statically imported engine binding.
let createFnAgentForRefine: typeof import("@fusion/engine").createFnAgent | undefined = engineCreateFnAgentForRefine; let createFnAgentForRefine: typeof import("@fusion/engine").createFnAgent | undefined = engineCreateFnAgentForRefine;
@@ -4121,7 +4121,21 @@ async function executeAiPromptStep(
}; };
} }
const { createFnAgent, promptWithFallback } = await import("@fusion/engine"); const createFnAgent = createFnAgentForRefine;
const promptWithFallback = enginePromptWithFallback;
if (!createFnAgent) {
return {
stepId: step.id,
stepName: step.name,
stepIndex: 0,
success: false,
output: "",
error: "AI agent not available",
startedAt,
completedAt: new Date().toISOString(),
};
}
const settings = await taskStore.getSettings(); const settings = await taskStore.getSettings();
const defaultModel = resolveProjectDefaultModel(settings); const defaultModel = resolveProjectDefaultModel(settings);
const modelProvider = step.modelProvider?.trim() || defaultModel.provider; const modelProvider = step.modelProvider?.trim() || defaultModel.provider;

View File

@@ -3,6 +3,7 @@ import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusio
import { getDefaultHeartbeatProcedurePath } from "@fusion/core"; import { getDefaultHeartbeatProcedurePath } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js"; import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
import { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } from "@fusion/engine";
interface AgentCoreRouteDeps { interface AgentCoreRouteDeps {
sanitizeAgentTaskLinks: (agents: Agent[], scopedStore: TaskStore) => Promise<Agent[]>; sanitizeAgentTaskLinks: (agents: Agent[], scopedStore: TaskStore) => Promise<Agent[]>;
@@ -162,7 +163,6 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id); const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id);
if (agent.heartbeatProcedurePath === expectedDefaultPath) { if (agent.heartbeatProcedurePath === expectedDefaultPath) {
try { try {
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE); await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE);
} catch { } catch {
// Non-fatal — the heartbeat resolver falls back to the in-memory constant. // Non-fatal — the heartbeat resolver falls back to the in-memory constant.
@@ -491,7 +491,6 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
} }
const targetPath = getDefaultHeartbeatProcedurePath(req.params.id); const targetPath = getDefaultHeartbeatProcedurePath(req.params.id);
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
const filePath = await ensureDefaultHeartbeatProcedureFile( const filePath = await ensureDefaultHeartbeatProcedureFile(
scopedStore.getRootDir(), scopedStore.getRootDir(),
targetPath, targetPath,

View File

@@ -1,5 +1,22 @@
import { ApiError, badRequest, internalError, notFound } from "../api-error.js"; import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
import * as engineModule from "@fusion/engine";
type EngineAgentReflectionService = typeof engineModule extends {
AgentReflectionService: infer T;
}
? T
: never;
let AgentReflectionServiceBinding: EngineAgentReflectionService | undefined =
"AgentReflectionService" in engineModule && typeof engineModule.AgentReflectionService === "function"
? (engineModule.AgentReflectionService as EngineAgentReflectionService)
: undefined;
/** @internal test hook for reflection-service-unavailable branches. */
export function __setAgentReflectionServiceForTests(service: EngineAgentReflectionService | undefined): void {
AgentReflectionServiceBinding = service;
}
export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void { export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError } = ctx; const { router, getProjectContext, rethrowAsApiError } = ctx;
@@ -98,7 +115,6 @@ export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void
try { try {
const { store: taskStore } = await getProjectContext(req); const { store: taskStore } = await getProjectContext(req);
const { AgentStore, ReflectionStore } = await import("@fusion/core"); const { AgentStore, ReflectionStore } = await import("@fusion/core");
const { AgentReflectionService } = await import("@fusion/engine");
const agentStore = new AgentStore({ rootDir: taskStore.getFusionDir() }); const agentStore = new AgentStore({ rootDir: taskStore.getFusionDir() });
const reflectionStore = new ReflectionStore({ rootDir: taskStore.getFusionDir() }); const reflectionStore = new ReflectionStore({ rootDir: taskStore.getFusionDir() });
await agentStore.init(); await agentStore.init();
@@ -115,6 +131,12 @@ export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void
throw notFound("Agent not found"); throw notFound("Agent not found");
} }
const AgentReflectionService = AgentReflectionServiceBinding;
if (!AgentReflectionService) {
res.status(503).json({ error: "Reflection service not available" });
return;
}
// Create the reflection service and generate a reflection // Create the reflection service and generate a reflection
const reflectionService = new AgentReflectionService({ const reflectionService = new AgentReflectionService({
agentStore, agentStore,
@@ -205,16 +227,7 @@ export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void
throw notFound("Agent not found"); throw notFound("Agent not found");
} }
// Check if AgentReflectionService is available const AgentReflectionService = AgentReflectionServiceBinding;
let AgentReflectionService: typeof import("@fusion/engine").AgentReflectionService | undefined;
try {
const engine = await import("@fusion/engine");
AgentReflectionService = engine.AgentReflectionService;
} catch {
res.status(503).json({ error: "Reflection service not available" });
return;
}
if (!AgentReflectionService) { if (!AgentReflectionService) {
res.status(503).json({ error: "Reflection service not available" }); res.status(503).json({ error: "Reflection service not available" });
return; return;