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:
5
.changeset/fn-3049-engine-static-imports.md
Normal file
5
.changeset/fn-3049-engine-static-imports.md
Normal 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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user