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 58c0c612a6
commit 113f182e25
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 { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.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
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
@@ -18730,6 +18731,15 @@ describe("Agent Reflection routes", () => {
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", () => {
it("returns 200 for valid agent (uses real stores)", async () => {
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 () => {
const engine = await import("@fusion/engine");
__setAgentReflectionServiceForTests(engine.AgentReflectionService);
const generateReflectionSpy = vi
.spyOn(engine.AgentReflectionService.prototype, "generateReflection")
.mockResolvedValueOnce(null);
@@ -18826,6 +18837,13 @@ describe("Agent Reflection routes", () => {
});
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 () => {
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 { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import * as engineModule from "@fusion/engine";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AgentResult = any;
@@ -100,13 +101,8 @@ async function ensureEngineReady(): Promise<void> {
return;
}
try {
const engine = await import("@fusion/engine");
if ("buildAgentChatPrompt" in engine && typeof engine.buildAgentChatPrompt === "function") {
buildAgentChatPromptFn = engine.buildAgentChatPrompt;
}
} catch {
// Optional helper unavailable in mocked/test contexts.
if ("buildAgentChatPrompt" in engineModule && typeof engineModule.buildAgentChatPrompt === "function") {
buildAgentChatPromptFn = engineModule.buildAgentChatPrompt;
}
}

View File

@@ -28,6 +28,7 @@ import {
badRequest,
notFound,
} from "./api-error.js";
import { createFnAgent, promptWithFallback } from "@fusion/engine";
/**
* 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);
try {
const { createFnAgent, promptWithFallback } = await import("@fusion/engine");
let responseText = "";
const { session } = await createFnAgent({
cwd: rootDir,

View File

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

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.
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 defaultModel = resolveProjectDefaultModel(settings);
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 { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
import { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } from "@fusion/engine";
interface AgentCoreRouteDeps {
sanitizeAgentTaskLinks: (agents: Agent[], scopedStore: TaskStore) => Promise<Agent[]>;
@@ -162,7 +163,6 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id);
if (agent.heartbeatProcedurePath === expectedDefaultPath) {
try {
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE);
} catch {
// 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 { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
const filePath = await ensureDefaultHeartbeatProcedureFile(
scopedStore.getRootDir(),
targetPath,

View File

@@ -1,5 +1,22 @@
import { ApiError, badRequest, internalError, notFound } from "../api-error.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 {
const { router, getProjectContext, rethrowAsApiError } = ctx;
@@ -98,7 +115,6 @@ export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void
try {
const { store: taskStore } = await getProjectContext(req);
const { AgentStore, ReflectionStore } = await import("@fusion/core");
const { AgentReflectionService } = await import("@fusion/engine");
const agentStore = new AgentStore({ rootDir: taskStore.getFusionDir() });
const reflectionStore = new ReflectionStore({ rootDir: taskStore.getFusionDir() });
await agentStore.init();
@@ -115,6 +131,12 @@ export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void
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
const reflectionService = new AgentReflectionService({
agentStore,
@@ -205,16 +227,7 @@ export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void
throw notFound("Agent not found");
}
// Check if AgentReflectionService is available
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;
}
const AgentReflectionService = AgentReflectionServiceBinding;
if (!AgentReflectionService) {
res.status(503).json({ error: "Reflection service not available" });
return;