diff --git a/.changeset/local-starts-engine.md b/.changeset/local-starts-engine.md index b4b9c54884..e4ec39f8e1 100644 --- a/.changeset/local-starts-engine.md +++ b/.changeset/local-starts-engine.md @@ -1,5 +1,5 @@ --- -"@runfusion/fusion": patch +"@runfusion/fusion": minor --- Start the AI engine by default in `pnpm local`, keep dashboard `--dev` engine-on unless `--no-engine` is passed, start desktop local runtimes with engines, and show dashboard instructions when Fusion is launched without an engine. diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 122516f281..67930ef3c6 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -2229,6 +2229,15 @@ describe("runDashboard — --no-engine mode", () => { }); }); + it("does not start engine components when dev mode is launched with noEngine", async () => { + const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine"); + await runDashboard(0, { open: false, dev: true, noEngine: true }); + + expect(TriageProcessor).not.toHaveBeenCalled(); + expect(TaskExecutor).not.toHaveBeenCalled(); + expect(Scheduler).not.toHaveBeenCalled(); + }); + it("shows 'AI engine: ✓ active' when not in dev mode", async () => { await runDashboard(0, { open: false }); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 2de93cba81..21cd836331 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -330,7 +330,7 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b Engine - {info.engineMode === "dev" && dev} + {info.engineMode === "no-engine" && no-engine} {info.engineMode === "paused" && paused} {info.engineMode === "active" && active} diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index 8a144a2ac5..e26883be27 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -18,7 +18,7 @@ export interface SystemInfo { authEnabled: boolean; authToken?: string; tokenizedUrl?: string; - engineMode: "dev" | "active" | "paused"; + engineMode: "no-engine" | "active" | "paused"; fileWatcher: boolean; startTimeMs: number; startupDurationMs?: number; diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 845fcd5270..3abf84f79c 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -2380,7 +2380,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (isTTY && tui) { // Determine engine mode const settings = await store.getSettings(); - const engineMode = noEngine ? "dev" : settings.enginePaused ? "paused" : "active"; + const engineMode = noEngine ? "no-engine" : settings.enginePaused ? "paused" : "active"; const startupDurationMs = Date.now() - dashboardStartedAt; const systemInfo: SystemInfo = { @@ -2905,7 +2905,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: tui.log(`Dashboard started at ${baseUrl}`); if (engineMode === "active") { tui.log("AI engine active"); - } else if (engineMode === "dev") { + } else if (engineMode === "no-engine") { tui.log("AI engine disabled (--no-engine)"); } else { tui.log("AI engine paused"); diff --git a/packages/cli/src/commands/desktop.ts b/packages/cli/src/commands/desktop.ts index b162af6bd8..c5ba46861e 100644 --- a/packages/cli/src/commands/desktop.ts +++ b/packages/cli/src/commands/desktop.ts @@ -50,71 +50,74 @@ async function buildDesktopArtifacts(rootDir: string): Promise { async function startDashboardRuntime(rootDir: string, paused: boolean): Promise { const store = new TaskStore(rootDir); - await store.init(); - await store.watch(); - - if (paused) { - await store.updateSettings({ enginePaused: true }); - } - - /* - * FNXC:DesktopRuntime 2026-06-20-23:39: - * Desktop local mode must start the same project engine lifecycle as CLI dashboard mode; a desktop window without engines leaves users with a live dashboard that cannot execute tasks. - */ - const centralCore = new CentralCore(); - await centralCore.init(); - const cwdRegistered = await ensureCwdProjectRegistered({ - cwd: rootDir, - central: centralCore, - logPrefix: "desktop", - autoRegister: true, - }); - const engineManager = new ProjectEngineManager(centralCore); - await engineManager.startAll(); - engineManager.startReconciliation(); - const cwdEngine = cwdRegistered - ? await engineManager.ensureEngine(cwdRegistered.id).catch((err) => { - console.warn(`[desktop] Failed to warm cwd project engine: ${err instanceof Error ? err.message : String(err)}`); - return undefined; - }) - : undefined; - - const app = createServer(store, { - engine: cwdEngine, - engineManager, - centralCore, - onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), - }); - const server = app.listen(0); - + let server: import("node:http").Server | null = null; + let engineManager: ProjectEngineManager | undefined; + let centralCore: CentralCore | undefined; try { + await store.init(); + await store.watch(); + + if (paused) { + await store.updateSettings({ enginePaused: true }); + } + + /* + * FNXC:DesktopRuntime 2026-06-20-23:39: + * Desktop local mode must start the same project engine lifecycle as CLI dashboard mode; a desktop window without engines leaves users with a live dashboard that cannot execute tasks. + */ + centralCore = new CentralCore(); + await centralCore.init(); + const cwdRegistered = await ensureCwdProjectRegistered({ + cwd: rootDir, + central: centralCore, + logPrefix: "desktop", + autoRegister: true, + }); + engineManager = new ProjectEngineManager(centralCore); + await engineManager.startAll(); + engineManager.startReconciliation(); + const cwdEngine = cwdRegistered + ? await engineManager.ensureEngine(cwdRegistered.id).catch((err) => { + console.warn(`[desktop] Failed to warm cwd project engine: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + }) + : undefined; + + const app = createServer(store, { + engine: cwdEngine, + engineManager, + centralCore, + onProjectFirstAccessed: (projectId: string) => engineManager?.onProjectAccessed(projectId), + }); + server = app.listen(0); + await Promise.race([ once(server, "listening"), once(server, "error").then(([error]) => { throw error; }), ]); + const address = server.address() as AddressInfo | null; + if (!address?.port) { + throw new Error("Failed to determine dashboard server port"); + } + + return { + store, + server, + port: address.port, + engineManager, + centralCore, + }; } catch (error) { - await engineManager.stopAll().catch(() => undefined); - await centralCore.close?.().catch(() => undefined); + if (server) { + await new Promise((resolve) => server?.close(() => resolve())); + } + await engineManager?.stopAll().catch(() => undefined); + await centralCore?.close?.().catch(() => undefined); store.close(); throw error; } - - const address = server.address() as AddressInfo | null; - if (!address?.port) { - server.close(); - store.close(); - throw new Error("Failed to determine dashboard server port"); - } - - return { - store, - server, - port: address.port, - engineManager, - centralCore, - }; } async function closeDashboardRuntime(runtime: DashboardRuntime): Promise { diff --git a/packages/dashboard/app/components/EngineUnavailableBanner.tsx b/packages/dashboard/app/components/EngineUnavailableBanner.tsx index 51d0d2b1b6..7f69b71f9d 100644 --- a/packages/dashboard/app/components/EngineUnavailableBanner.tsx +++ b/packages/dashboard/app/components/EngineUnavailableBanner.tsx @@ -25,11 +25,10 @@ export function EngineUnavailableBanner({ isVisible }: EngineUnavailableBannerPr

, cliCmd: , - legacyCmd: , }} />

diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index a07481560b..f118da2e6c 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -742,7 +742,6 @@ describe("FN-4250 FileBrowserProvider coverage", () => { expect(await screen.findByText("AI engine is not running")).toBeInTheDocument(); expect(screen.getByText("pnpm local")).toBeInTheDocument(); expect(screen.getByText("fn dashboard")).toBeInTheDocument(); - expect(screen.getByText("pnpm local -- --engine")).toBeInTheDocument(); }); it("does not show engine restart instructions when health reports an engine", async () => { @@ -758,6 +757,32 @@ describe("FN-4250 FileBrowserProvider coverage", () => { expect(screen.queryByText("AI engine is not running")).not.toBeInTheDocument(); }); + it("does not show engine restart instructions when an older health payload omits engine status", async () => { + vi.mocked(fetchDashboardHealth).mockResolvedValueOnce({ + status: "ok", + version: "1.0.0", + uptime: 1, + database: { + healthy: true, + corruptionDetected: false, + corruptionErrors: [], + lastCheckedAt: null, + isRunning: false, + }, + taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null }, + }); + mockProjectsState.loading = false; + mockProjectsState.projects = [ + { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" }, + ]; + mockCurrentProjectState.loading = false; + + render(); + + await waitFor(() => expect(fetchDashboardHealth).toHaveBeenCalled()); + expect(screen.queryByText("AI engine is not running")).not.toBeInTheDocument(); + }); + it("shows engine restart instructions on mobile when health reports a UI-only dashboard", async () => { vi.mocked(fetchDashboardHealth).mockResolvedValueOnce({ status: "ok", @@ -786,7 +811,6 @@ describe("FN-4250 FileBrowserProvider coverage", () => { expect(await screen.findByText("AI engine is not running")).toBeInTheDocument(); expect(screen.getByText("fn dashboard")).toBeInTheDocument(); - expect(screen.getByText("pnpm local -- --engine")).toBeInTheDocument(); }); it("FN-4779: renders app shell immediately when project data is ready", () => { diff --git a/packages/desktop/src/__tests__/local-server.test.ts b/packages/desktop/src/__tests__/local-server.test.ts index 2ceac65cef..6503afbc26 100644 --- a/packages/desktop/src/__tests__/local-server.test.ts +++ b/packages/desktop/src/__tests__/local-server.test.ts @@ -38,6 +38,10 @@ const mocks = vi.hoisted(() => { const centralCore = { init: vi.fn(async () => undefined), close: vi.fn(async () => undefined), + getProjectByPath: vi.fn(async () => ({ id: "project-1", name: "Repo", path: "/repo", status: "active" })), + listProjects: vi.fn(async () => []), + registerProject: vi.fn(async ({ path, name }: { path: string; name: string }) => ({ id: "project-1", name, path, status: "initializing" })), + updateProject: vi.fn(async (id: string, patch: Record) => ({ id, name: "Repo", path: "/repo", status: patch.status ?? "active" })), }; const engine = { id: "engine-1" }; const engineMap = new Map([["project-1", engine]]); @@ -46,6 +50,7 @@ const mocks = vi.hoisted(() => { startReconciliation: vi.fn(), stopAll: vi.fn(async () => undefined), getAllEngines: vi.fn(() => engineMap), + ensureEngine: vi.fn(async () => engine), onProjectAccessed: vi.fn(), }; @@ -97,6 +102,8 @@ describe("DesktopLocalServerManager", () => { expect(manager.getPort()).toBe(4545); expect(manager.getState().status).toBe("ready"); expect(mocks.engineManager.startAll).toHaveBeenCalledTimes(1); + expect(mocks.centralCore.getProjectByPath).toHaveBeenCalledWith("/repo"); + expect(mocks.engineManager.ensureEngine).toHaveBeenCalledWith("project-1"); expect(mocks.createServer).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -130,6 +137,37 @@ describe("DesktopLocalServerManager", () => { expect(manager.getState()).toMatchObject({ status: "error", error: "init failed" }); }); + it("cleans up engine and central core when server creation fails", async () => { + mocks.createServer.mockImplementationOnce(() => { + throw new Error("server failed"); + }); + const { DesktopLocalServerManager } = await import("../local-server.ts"); + const manager = new DesktopLocalServerManager("/repo"); + + await expect(manager.start()).rejects.toThrow("server failed"); + + expect(mocks.engineManager.stopAll).toHaveBeenCalled(); + expect(mocks.centralCore.close).toHaveBeenCalled(); + expect(mocks.store.close).toHaveBeenCalled(); + expect(manager.getState()).toMatchObject({ status: "error", error: "server failed" }); + }); + + it("registers an active runtime-root project when no projects exist", async () => { + mocks.centralCore.getProjectByPath.mockResolvedValueOnce(undefined); + const { DesktopLocalServerManager } = await import("../local-server.ts"); + const manager = new DesktopLocalServerManager("/repo"); + + await manager.start(); + + expect(mocks.centralCore.registerProject).toHaveBeenCalledWith({ + path: "/repo", + name: "repo", + isolationMode: "in-process", + }); + expect(mocks.centralCore.updateProject).toHaveBeenCalledWith("project-1", { status: "active" }); + expect(mocks.engineManager.ensureEngine).toHaveBeenCalledWith("project-1"); + }); + it("returns existing runtime when start is called twice", async () => { const { DesktopLocalServerManager } = await import("../local-server.ts"); const manager = new DesktopLocalServerManager("/repo"); diff --git a/packages/desktop/src/__tests__/main.test.ts b/packages/desktop/src/__tests__/main.test.ts index f1cc7c20cc..a8405636de 100644 --- a/packages/desktop/src/__tests__/main.test.ts +++ b/packages/desktop/src/__tests__/main.test.ts @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => { focus: vi.fn(), hide: vi.fn(), maximize: vi.fn(), + webContents: { reload: vi.fn() }, }; const BrowserWindow = vi.fn(function () { @@ -386,6 +387,21 @@ describe("main process", () => { expect(mainDeps.saveDesktopLaunchMode).toHaveBeenCalledWith("remote"); }); + it("does not persist local menu mode when local runtime startup fails", async () => { + mainDeps.startLocal.mockRejectedValueOnce(new Error("boom")); + const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule(); + + await initializeApp(); + + const menuOptions = mainDeps.buildAppMenu.mock.calls[0]?.[0] as + | { onStartLocalRuntime?: () => Promise } + | undefined; + await expect(menuOptions?.onStartLocalRuntime?.()).rejects.toThrow("boom"); + + expect(mainDeps.saveDesktopLaunchMode).not.toHaveBeenCalledWith("local"); + expect(getCurrentDesktopLaunchMode()).toBe("choose"); + }); + it("createMainWindow registers close and closed handlers", async () => { const { createMainWindow } = await importMainModule(); diff --git a/packages/desktop/src/engine-runtime.ts b/packages/desktop/src/engine-runtime.ts new file mode 100644 index 0000000000..a6fbbd217d --- /dev/null +++ b/packages/desktop/src/engine-runtime.ts @@ -0,0 +1,26 @@ +import { basename } from "node:path"; + +import type { CentralCore, RegisteredProject } from "@fusion/core"; + +/* + * FNXC:DesktopRuntime 2026-06-21-02:04: + * Desktop local mode starts engines by default, so the embedded server should prefer the project represented by the desktop runtime root instead of whichever registered engine happens to be first. The runtime root may be a home directory, so this path must not call helpers that initialize Git repositories as a side effect. + */ +export async function ensureDesktopRuntimeProject(centralCore: CentralCore, rootDir: string): Promise { + const existing = await centralCore.getProjectByPath(rootDir); + if (existing) { + return existing; + } + + const projects = await centralCore.listProjects(); + if (projects.length > 0) { + return projects[0]!; + } + + const registered = await centralCore.registerProject({ + path: rootDir, + name: basename(rootDir) || "Fusion Desktop", + isolationMode: "in-process", + }); + return centralCore.updateProject(registered.id, { status: "active" }); +} diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index 89dfad774b..fb5d654f19 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -2,6 +2,8 @@ import { once } from "node:events"; import type { Server } from "node:http"; import type { AddressInfo } from "node:net"; +import { ensureDesktopRuntimeProject } from "./engine-runtime.js"; + export type RuntimeSource = "embedded-local" | "external-cli" | "none"; export type RuntimeState = "stopped" | "starting" | "running" | "error"; @@ -41,7 +43,7 @@ async function createStoreDefault(rootDir: string): Promise { return new TaskStore(rootDir) as TaskStoreLike; } -async function createDashboardServerDefault(store: TaskStoreLike, _rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> { +async function createDashboardServerDefault(store: TaskStoreLike, rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> { const { CentralCore } = await import("@fusion/core"); const { createServer } = await import("@fusion/dashboard"); const { ProjectEngineManager } = await import("@fusion/engine"); @@ -51,25 +53,33 @@ async function createDashboardServerDefault(store: TaskStoreLike, _rootDir: stri * Embedded desktop local mode should be an executable Fusion node, not a dashboard-only shell. Start all registered project engines and pass the manager to the API server so project-scoped routes can start newly accessed engines. */ const centralCore = new CentralCore(); - await centralCore.init(); const engineManager = new ProjectEngineManager(centralCore); - await engineManager.startAll(); - engineManager.startReconciliation(); - const primaryEngine = [...engineManager.getAllEngines().values()][0]; - const app = createServer(store as never, { - engine: primaryEngine, - engineManager, - centralCore, - onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), - }); - - return { - server: app.listen(0), - cleanup: async () => { - await engineManager.stopAll(); - await centralCore.close?.(); - }, + const cleanup = async () => { + await engineManager.stopAll(); + await centralCore.close?.(); }; + + try { + await centralCore.init(); + const rootProject = await ensureDesktopRuntimeProject(centralCore, rootDir); + await engineManager.startAll(); + engineManager.startReconciliation(); + const primaryEngine = await engineManager.ensureEngine(rootProject.id); + const app = createServer(store as never, { + engine: primaryEngine, + engineManager, + centralCore, + onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), + }); + + return { + server: app.listen(0), + cleanup, + }; + } catch (error) { + await cleanup(); + throw error; + } } function parsePort(raw: string | undefined): number | undefined { diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index 6331bd4277..e6faa5ce95 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -1,6 +1,9 @@ import type { AddressInfo } from "node:net"; import { once } from "node:events"; import type { Server } from "node:http"; + +import { ensureDesktopRuntimeProject } from "./engine-runtime.js"; + type TaskStoreLike = { init(): Promise; watch(): Promise; @@ -61,11 +64,16 @@ export class DesktopLocalServerManager { * This legacy desktop local server path still needs to launch project engines so every embedded desktop server follows the same executable-by-default contract. */ const centralCore = new CentralCore(); - await centralCore.init(); const engineManager = new ProjectEngineManager(centralCore); + cleanup = async () => { + await engineManager.stopAll(); + await centralCore.close?.(); + }; + await centralCore.init(); + const rootProject = await ensureDesktopRuntimeProject(centralCore, this.rootDir); await engineManager.startAll(); engineManager.startReconciliation(); - const primaryEngine = [...engineManager.getAllEngines().values()][0]; + const primaryEngine = await engineManager.ensureEngine(rootProject.id); const app = createServer(store as never, { engine: primaryEngine, engineManager, @@ -73,10 +81,6 @@ export class DesktopLocalServerManager { onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), }); server = app.listen(0); - cleanup = async () => { - await engineManager.stopAll(); - await centralCore.close?.(); - }; await Promise.race([ once(server, "listening"), diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 896832c4b7..84b8cb58cf 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -238,12 +238,16 @@ export async function initializeApp(): Promise { onChangeLaunchMode: async () => { await resetLaunchModeAndReload(createdWindow); }, + /* + * FNXC:DesktopRuntimeMode 2026-06-21-02:04: + * Menu-driven local/remote switching must only persist local mode after the embedded runtime starts successfully; shutdown stops the local server without rewriting the launch-mode preference. + */ onStartLocalRuntime: async () => { if (!localRuntimeManager) return; - currentRemoteLaunch = null; - currentDesktopLaunchMode = "local"; localRuntimeStartupAttempted = false; await startLocalRuntimeOnce(); + currentRemoteLaunch = null; + currentDesktopLaunchMode = "local"; await saveDesktopLaunchMode("local"); createdWindow.webContents.reload(); }, diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index e6f0db29f0..f15fd2e873 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -23,6 +23,10 @@ function runMenuAction(label: string, action: (() => Promise | void) | und } function buildConnectionSubmenu(options: AppMenuOptions): MenuItemConstructorOptions { + /* + * FNXC:DesktopConnectionMenu 2026-06-21-02:04: + * Desktop users need menu-level controls to start the embedded local server, shut it down, or connect to a remote server without returning to first-run setup. + */ return { label: "Connection", submenu: [