From 7ef381762a6c6b045236439768da78474bea9538 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 23:56:27 -0700 Subject: [PATCH 1/2] feat: start engines by default --- .changeset/local-starts-engine.md | 5 ++ README.md | 4 +- docs/cli-reference.md | 7 +- docs/contributing.md | 6 +- packages/cli/README.md | 3 +- packages/cli/STANDALONE.md | 3 +- .../skill/fusion/references/cli-commands.md | 3 +- .../fusion/references/fusion-capabilities.md | 3 +- packages/cli/src/bin.ts | 9 ++- .../src/commands/__tests__/dashboard.test.ts | 49 +++++++----- .../src/commands/__tests__/desktop.test.ts | 51 ++++++++++++ packages/cli/src/commands/dashboard.ts | 55 +++++++------ packages/cli/src/commands/desktop.ts | 41 +++++++++- packages/dashboard/app/App.tsx | 2 + packages/dashboard/app/api/legacy.ts | 3 + .../components/EngineUnavailableBanner.css | 54 +++++++++++++ .../components/EngineUnavailableBanner.tsx | 39 +++++++++ .../app/components/__tests__/App.test.tsx | 80 ++++++++++++++++++- .../dashboard/src/__tests__/server.test.ts | 48 +++++++++++ packages/dashboard/src/server.ts | 50 +++++++++--- packages/desktop/package.json | 1 + .../src/__tests__/local-runtime.test.ts | 19 +++++ .../src/__tests__/local-server.test.ts | 36 ++++++++- packages/desktop/src/__tests__/menu.test.ts | 22 +++++ packages/desktop/src/local-runtime.ts | 46 +++++++++-- packages/desktop/src/local-server.ts | 42 +++++++++- packages/desktop/src/main.ts | 15 ++++ packages/desktop/src/menu.ts | 46 ++++++----- pnpm-lock.yaml | 3 + scripts/start-local.mjs | 13 ++- 30 files changed, 650 insertions(+), 108 deletions(-) create mode 100644 .changeset/local-starts-engine.md create mode 100644 packages/dashboard/app/components/EngineUnavailableBanner.css create mode 100644 packages/dashboard/app/components/EngineUnavailableBanner.tsx diff --git a/.changeset/local-starts-engine.md b/.changeset/local-starts-engine.md new file mode 100644 index 0000000000..b4b9c54884 --- /dev/null +++ b/.changeset/local-starts-engine.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +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/README.md b/README.md index aee08bbc91..1af2c5e23a 100644 --- a/README.md +++ b/README.md @@ -462,8 +462,8 @@ fn skills install firebase/agent-skills # Install agent skills ```bash pnpm install # Install dependencies -pnpm local # Start local dashboard/API on a non-4040 port -pnpm local -- --engine # Start local dashboard with the AI engine +pnpm local # Start local dashboard/API + AI engine on a non-4040 port +pnpm local --no-engine # Start local dashboard/API only pnpm build # Build default workspace packages (excludes desktop/mobile) pnpm build:all # Build all packages (including desktop/mobile) pnpm dev dashboard # Run dashboard + AI engine diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5987657139..7019c2a242 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -211,7 +211,8 @@ fn dashboard --token fn_yourStaticToken # reuse a fixed token fn dashboard --no-auth # disable bearer auth (local only) fn dashboard --interactive fn dashboard --paused -fn dashboard --dev +fn dashboard --dev # development-mode dashboard + engine +fn dashboard --no-engine # dashboard/API only fn dashboard --lang zh-TW # force a UI locale for this run ``` @@ -228,7 +229,8 @@ The terminal UI is localized. `--lang ` (one of `en`, `zh-CN`, `zh-TW`, | `--no-auth` | Disable bearer-token auth. Not recommended when binding to `0.0.0.0`. | | `--paused` | Start with the engine paused (automation disabled). | | `--interactive` | Interactive port selection. | -| `--dev` | Start dashboard only (no AI engine, no planning/scheduler). | +| `--dev` | Start dashboard in development mode. The AI engine still starts unless `--no-engine` is also passed. | +| `--no-engine` | Start dashboard/API only with no AI engine, planning, or scheduler runtime. | ### Interactive Terminal UI (TTY Mode) @@ -1062,6 +1064,7 @@ Subcommands: `search`, `install`. | `--interactive` | `fn dashboard`, `fn serve`, `fn daemon`, `fn desktop`, `fn task import`, `fn project add` | | `--paused` | `fn dashboard`, `fn serve`, `fn daemon`, `fn desktop` | | `--dev` | `fn dashboard`, `fn desktop` | +| `--no-engine` | `fn dashboard` | | `--attach` | `fn task create` | | `--depends` | `fn task create` | | `--node` | `fn task create` | diff --git a/docs/contributing.md b/docs/contributing.md index d88893348b..a049d7f8c7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -55,9 +55,9 @@ pnpm build:all # full recursive build including desktop/mobile ## Development Workflow ```bash -pnpm local # fast local dashboard/API startup on a safe localhost port -pnpm local --engine # fast local startup with the AI engine enabled -pnpm local --prebuild # local dashboard/API startup with an explicit prebuild level +pnpm local # fast local dashboard/API + AI engine startup on a safe localhost port +pnpm local --no-engine # fast local dashboard/API-only startup +pnpm local --prebuild # local dashboard/API + AI engine startup with an explicit prebuild level pnpm dev # source-mode CLI; dashboard gets a client-only prebuild, other commands skip it FUSION_DEV_PREBUILD=full pnpm dev dashboard # production-like full workspace prebuild pnpm dev:ui # dashboard dev server only diff --git a/packages/cli/README.md b/packages/cli/README.md index c56708986f..55d8a7d9d0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -57,7 +57,8 @@ From a shell: ```bash fn dashboard # or: fusion dashboard / npx @runfusion/fusion dashboard fn dashboard --paused # start with automation paused -fn dashboard --dev # web UI only, no AI engine +fn dashboard --dev # development-mode dashboard + AI engine +fn dashboard --no-engine # web UI only, no AI engine ``` The dashboard gives you: diff --git a/packages/cli/STANDALONE.md b/packages/cli/STANDALONE.md index 5c67fc8e58..aab60f12a5 100644 --- a/packages/cli/STANDALONE.md +++ b/packages/cli/STANDALONE.md @@ -39,7 +39,8 @@ fn dashboard fn dashboard --port 8080 fn dashboard --interactive # Interactive port selection (prompts for port) fn dashboard --paused # Start with automation paused (review before work begins) -fn dashboard --dev # Start web UI only (no AI engine) +fn dashboard --dev # Start in development mode with the AI engine +fn dashboard --no-engine # Start web UI only (no AI engine) ``` ### Multi-Instance Deployments diff --git a/packages/cli/skill/fusion/references/cli-commands.md b/packages/cli/skill/fusion/references/cli-commands.md index b23001d03b..a72f9913fa 100644 --- a/packages/cli/skill/fusion/references/cli-commands.md +++ b/packages/cli/skill/fusion/references/cli-commands.md @@ -9,7 +9,8 @@ fn dashboard # Start web UI + AI engine (port 4040) fn dashboard --port 8080 # Custom port fn dashboard --interactive # Interactive port selection fn dashboard --paused # Start with automation paused -fn dashboard --dev # Web UI only (no AI engine) +fn dashboard --dev # Development-mode dashboard + AI engine +fn dashboard --no-engine # Web UI only (no AI engine) ``` ## Task Management diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index bfb8800676..0671b074e8 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -82,7 +82,8 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names ### Dashboard and Node Runtime - `fn dashboard` — Start web UI + AI engine - `fn dashboard --paused` — Start with automation paused -- `fn dashboard --dev` — Start web UI only (no AI engine) +- `fn dashboard --dev` — Start development-mode dashboard + AI engine +- `fn dashboard --no-engine` — Start web UI only (no AI engine) - `fn serve` — Start headless node mode (API + engine, no UI) - `fn daemon` — Start daemon mode with auth diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 024bc1977a..9299fc816f 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -281,7 +281,8 @@ Usage: and auto-skips for serve/daemon, non-TTY, --skip-onboarding, and FUSION_SKIP_ONBOARDING fn dashboard Start the board web UI fn dashboard --paused Start with automation paused - fn dashboard --dev Start web UI only (no AI engine) + fn dashboard --dev Start dashboard in development mode + fn dashboard --no-engine Start web UI only (no AI engine) fn dashboard --interactive Start with interactive port selection fn serve [--port ] [--host ] [--paused] [--daemon] [--project ] [--no-auto-register] Start Fusion as a headless node (API + engine, no UI) @@ -449,7 +450,8 @@ Options: --no-auth Disable dashboard bearer-token auth (local-only; not recommended on 0.0.0.0) --interactive Interactive mode (port selection for dashboard, issue selection for import) --paused Start with engine paused (automation disabled) - --dev Start dashboard only (no AI engine) + --dev Start dashboard in development mode + --no-engine Start dashboard only (no AI engine) --lang Terminal-UI locale for this run (en, zh-CN, zh-TW, fr, es, ko); the browser dashboard resolves its own language --attach Attach file(s) on task create (repeatable) --depends Declare dependency on task create (repeatable) @@ -787,6 +789,7 @@ async function main() { const port = pi !== -1 ? parseInt(args[pi + 1], 10) : 4040; const paused = args.includes("--paused"); const dev = args.includes("--dev"); + const noEngine = args.includes("--no-engine"); const interactive = args.includes("--interactive"); const dashHostIdx = args.indexOf("--host"); const host = dashHostIdx !== -1 && dashHostIdx + 1 < args.length ? args[dashHostIdx + 1] : undefined; @@ -804,7 +807,7 @@ async function main() { process.exit(1); } } - await runDashboard(port, { paused, dev, interactive, host, noAuth, token, lang }); + await runDashboard(port, { paused, dev, noEngine, interactive, host, noAuth, token, lang }); break; } diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index e39b42c005..122516f281 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -1247,7 +1247,7 @@ describe("runDashboard — PR-first auto-merge queue", () => { globalPause: false, }); - await runDashboard(0, { open: false, dev: true }); + await runDashboard(0, { open: false, noEngine: true }); const createServerCall = (createServer as ReturnType).mock.calls[0]; const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise }; @@ -2112,7 +2112,7 @@ describe("runDashboard — --paused flag", () => { }); }); -describe("runDashboard — --dev mode", () => { +describe("runDashboard — --no-engine mode", () => { let mockStore: ReturnType; let consoleSpy: ReturnType; @@ -2140,27 +2140,27 @@ describe("runDashboard — --dev mode", () => { consoleSpy.mockRestore(); }); - it("does NOT start TriageProcessor in dev mode", async () => { + it("does NOT start TriageProcessor in no-engine mode", async () => { const { TriageProcessor } = await import("@fusion/engine"); - await runDashboard(0, { open: false, dev: true }); + await runDashboard(0, { open: false, noEngine: true }); expect(TriageProcessor).not.toHaveBeenCalled(); }); - it("does NOT start TaskExecutor in dev mode", async () => { + it("does NOT start TaskExecutor in no-engine mode", async () => { const { TaskExecutor } = await import("@fusion/engine"); - await runDashboard(0, { open: false, dev: true }); + await runDashboard(0, { open: false, noEngine: true }); expect(TaskExecutor).not.toHaveBeenCalled(); }); - it("does NOT start Scheduler in dev mode", async () => { + it("does NOT start Scheduler in no-engine mode", async () => { const { Scheduler } = await import("@fusion/engine"); - await runDashboard(0, { open: false, dev: true }); + await runDashboard(0, { open: false, noEngine: true }); expect(Scheduler).not.toHaveBeenCalled(); }); - it("starts the server correctly in dev mode", async () => { + it("starts the server correctly in no-engine mode", async () => { const { createServer } = await import("@fusion/dashboard"); - await runDashboard(4040, { open: false, dev: true }); + await runDashboard(4040, { open: false, noEngine: true }); await waitForAsyncExpectation(() => { expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1"); @@ -2176,23 +2176,23 @@ describe("runDashboard — --dev mode", () => { ); }); - it("shows 'AI engine: disabled (dev mode)' in dev mode", async () => { - await runDashboard(0, { open: false, dev: true }); + it("shows 'AI engine: disabled (--no-engine)' in no-engine mode", async () => { + await runDashboard(0, { open: false, noEngine: true }); await waitForAsyncExpectation(() => { expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("✗ disabled (dev mode)"), + expect.stringContaining("✗ disabled (--no-engine)"), ); }); // Should show disabled message expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("✗ disabled (dev mode)"), + expect.stringContaining("✗ disabled (--no-engine)"), ); }); - it("does NOT show triage/scheduler details in dev mode", async () => { - await runDashboard(0, { open: false, dev: true }); + it("does NOT show triage/scheduler details in no-engine mode", async () => { + await runDashboard(0, { open: false, noEngine: true }); await Promise.resolve(); @@ -2207,7 +2207,7 @@ describe("runDashboard — --dev mode", () => { expect(schedulerCall).toBeUndefined(); }); - it("starts all engine components when dev is false (default)", async () => { + it("starts all engine components by default", async () => { const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine"); await runDashboard(0, { open: false }); @@ -2218,6 +2218,17 @@ describe("runDashboard — --dev mode", () => { }); }); + it("starts all engine components in dev mode unless noEngine is passed", async () => { + const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine"); + await runDashboard(0, { open: false, dev: true }); + + await waitForAsyncExpectation(() => { + expect(TriageProcessor).toHaveBeenCalled(); + expect(TaskExecutor).toHaveBeenCalled(); + expect(Scheduler).toHaveBeenCalled(); + }); + }); + it("shows 'AI engine: ✓ active' when not in dev mode", async () => { await runDashboard(0, { open: false }); @@ -2910,7 +2921,7 @@ describe("runDashboard — CentralCore cleanup diagnostics", () => { const baselineSigtermHandlers = process.listeners("SIGTERM"); try { - await runDashboard(0, { open: false, dev: true }); + await runDashboard(0, { open: false, noEngine: true }); const sigtermHandler = getNewSignalHandler("SIGTERM", baselineSigtermHandlers); sigtermHandler(); @@ -3200,7 +3211,7 @@ describe("runDashboard — merge stream sink routing", () => { }, ); - await runDashboard(0, { open: false, dev: true }); + await runDashboard(0, { open: false, noEngine: true }); consoleLogSpy.mockClear(); stdoutWriteSpy.mockClear(); diff --git a/packages/cli/src/commands/__tests__/desktop.test.ts b/packages/cli/src/commands/__tests__/desktop.test.ts index 6e662d41c9..284047740a 100644 --- a/packages/cli/src/commands/__tests__/desktop.test.ts +++ b/packages/cli/src/commands/__tests__/desktop.test.ts @@ -87,6 +87,21 @@ const mocks = vi.hoisted(() => { updateSettings: vi.fn().mockResolvedValue(undefined), close: vi.fn(), }; + const project = { id: "project-1", name: "Repo", path: "/repo", status: "active" }; + const centralCore = { + init: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + const engine = { id: "engine-1" }; + const engineMap = new Map([[project.id, engine]]); + const engineManager = { + startAll: vi.fn().mockResolvedValue(undefined), + startReconciliation: vi.fn(), + ensureEngine: vi.fn().mockResolvedValue(engine), + onProjectAccessed: vi.fn(), + stopAll: vi.fn().mockResolvedValue(undefined), + getAllEngines: vi.fn(() => engineMap), + }; const server = Object.assign(createEmitter(), { address: vi.fn(() => ({ port: 4545 })), @@ -125,6 +140,17 @@ const mocks = vi.hoisted(() => { taskStoreCtor: vi.fn(function () { return store; }), + centralCoreCtor: vi.fn(function () { + return centralCore; + }), + project, + centralCore, + engine, + engineManager, + projectEngineManagerCtor: vi.fn(function () { + return engineManager; + }), + ensureCwdProjectRegistered: vi.fn().mockResolvedValue(project), createServer: vi.fn(() => app), }; }); @@ -135,6 +161,15 @@ vi.mock("node:child_process", () => ({ vi.mock("@fusion/core", () => ({ TaskStore: mocks.taskStoreCtor, + CentralCore: mocks.centralCoreCtor, +})); + +vi.mock("@fusion/engine", () => ({ + ProjectEngineManager: mocks.projectEngineManagerCtor, +})); + +vi.mock("../ensure-project-registered.js", () => ({ + ensureCwdProjectRegistered: mocks.ensureCwdProjectRegistered, })); vi.mock("@fusion/dashboard", () => ({ @@ -200,6 +235,20 @@ describe("runDesktop", () => { ); expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo"); expect(mocks.store.updateSettings).toHaveBeenCalledWith({ enginePaused: true }); + expect(mocks.ensureCwdProjectRegistered).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/repo", central: mocks.centralCore, autoRegister: true }), + ); + expect(mocks.projectEngineManagerCtor).toHaveBeenCalledWith(mocks.centralCore); + expect(mocks.engineManager.startAll).toHaveBeenCalled(); + expect(mocks.engineManager.ensureEngine).toHaveBeenCalledWith("project-1"); + expect(mocks.createServer).toHaveBeenCalledWith( + mocks.store, + expect.objectContaining({ + engine: mocks.engine, + engineManager: mocks.engineManager, + centralCore: mocks.centralCore, + }), + ); expect(mocks.app.listen).toHaveBeenCalledWith(0); // In production mode (not dev), renderer uses embedded assets, so no FUSION_DASHBOARD_URL @@ -249,6 +298,7 @@ describe("runDesktop", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(mocks.server.close).toHaveBeenCalledTimes(1); + expect(mocks.engineManager.stopAll).toHaveBeenCalledTimes(1); expect(mocks.store.close).toHaveBeenCalledTimes(1); expect(process.exit).toHaveBeenCalledWith(7); }); @@ -261,6 +311,7 @@ describe("runDesktop", () => { expect(mocks.state.electronChild.kill).toHaveBeenCalledWith("SIGTERM"); expect(mocks.server.close).toHaveBeenCalledTimes(1); + expect(mocks.engineManager.stopAll).toHaveBeenCalledTimes(1); expect(mocks.store.close).toHaveBeenCalledTimes(1); expect(process.exit).toHaveBeenCalledWith(0); }); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 00d8dc4e43..845fcd5270 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -703,7 +703,7 @@ async function resolveDashboardAuthToken(opts: { noAuth?: boolean; token?: strin return tokenManager.generateToken(); } -export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string; lang?: string } = {}) { +export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; noEngine?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string; lang?: string } = {}) { // Default to localhost so the dashboard (and its shell-capable terminal API) // is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in. const selectedHost = opts.host ?? "127.0.0.1"; @@ -870,7 +870,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // CentralCore.init() is independent of store inits — start it early so it // overlaps with plugin loading and extension resolution instead of running // after them. - const centralCoreInitPromise = !opts.dev + const noEngine = opts.noEngine === true; + + const centralCoreInitPromise = !noEngine ? (async () => { const core = new CentralCore(); try { await core.init(); } catch { /* non-fatal — fallback defaults */ } @@ -1268,12 +1270,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // ── HeartbeatMonitor + HeartbeatTriggerScheduler ────────────────────── // - // In non-dev mode: obtained from ProjectEngine after engine.start(), which + // In engine mode: obtained from ProjectEngine after engine.start(), which // delegates to InProcessRuntime's already-initialized instances. This avoids // running duplicate heartbeat infrastructure alongside the engine's own. // - // In dev mode: created inline inside the opts.dev block below, since the - // engine does not start in dev mode. + // In UI-only mode: created inline inside the noEngine block below, since the + // engine does not start when --no-engine is passed. // // heartbeatMonitorImpl is a mutable reference. The proxy passed to // createServer delegates through it so routes work in both modes. @@ -1291,10 +1293,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // // onMergeImpl is a mutable reference so createServer always gets a stable // wrapper function while the underlying implementation is swapped when the - // engine starts in non-dev mode. + // engine starts in engine mode. // - // In dev mode: calls aiMergeTask directly (no engine, no semaphore). - // In non-dev mode: replaced by engine.onMerge() after ProjectEngine starts + // In UI-only mode: calls aiMergeTask directly (no engine, no semaphore). + // In engine mode: replaced by engine.onMerge() after ProjectEngine starts // (semaphore-gated via the engine's InProcessRuntime). // const onMergeImpl = async (taskId: string) => { @@ -1339,8 +1341,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // ── MissionAutopilot + MissionExecutionLoop: mission lifecycle ──── // - // Created inline for dev mode (engine doesn't start in dev mode). - // In non-dev mode, the engine is passed to createServer which derives these. + // Created inline for UI-only mode (engine doesn't start with --no-engine). + // In engine mode, the engine is passed to createServer which derives these. // const missionAutopilotImpl: MissionAutopilot | undefined = new MissionAutopilot(store, store.getMissionStore()); const missionExecutionLoopImpl: MissionExecutionLoop | undefined = new MissionExecutionLoop({ @@ -1582,9 +1584,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // ── createServer: deferred until engine is conditionally started ──── // - // In non-dev mode, pass the engine so createServer derives subsystem + // In engine mode, pass the engine so createServer derives subsystem // options (onMerge, automationStore, missionAutopilot, etc.) automatically. - // In dev mode, no engine — pass individual proxy objects instead. + // In UI-only mode, no engine — pass individual proxy objects instead. // let app: ReturnType; @@ -1598,9 +1600,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: let centralCoreForMesh: CentralCore | null = null; let localNodeIdForMesh: string | undefined; - // Start the AI engine (unless in dev mode) + // Start the AI engine unless the caller explicitly requested a UI-only process. if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.startingEngine); - if (!opts.dev) { + if (!noEngine) { // ── ProjectEngineManager: uniform engine lifecycle for all projects ── // // Every registered project gets an identical ProjectEngine with the @@ -1951,11 +1953,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: logSink.log("Received SIGHUP (terminal disconnected) — ignoring", "dashboard"); }); } else { - // Dev mode: create HeartbeatMonitor + TriggerScheduler inline (engine not started) + // UI-only mode: create HeartbeatMonitor + TriggerScheduler inline (engine not started) - // ── Mesh networking for dev mode ───────────────────────────────────── + // ── Mesh networking for UI-only mode ───────────────────────────────── // - // In dev mode we don't use the engine's CentralCore, so create a separate + // In UI-only mode we don't use the engine's CentralCore, so create a separate // instance for peer exchange and mDNS discovery. // try { @@ -2008,7 +2010,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: }); }, store, - // Dev-mode scheduler: no TaskExecutor runs here (engine not started), so + // UI-only scheduler: no TaskExecutor runs here (engine not started), so // neither `isTaskExecuting` nor the U5 reverse-direction // `isAgentEffectivelyExecuting` guard has a source — both stay unwired (the // guards simply never fire), matching the prior `isTaskExecuting` omission. @@ -2083,7 +2085,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // to createServer — routes derived from getPluginRoutes() rely on it. await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise); - // Dev mode: no engine, pass individual proxy objects to createServer + // UI-only mode: no engine, pass individual proxy objects to createServer. + // + // FNXC:DashboardStartup 2026-06-20-23:39: + // Dashboard development mode still needs a running engine by default; only the explicit `--no-engine` flag should produce a UI-only process so local and dev startup paths match user expectations. app = createServer(store, { onMerge, centralCore: centralCoreForMesh ?? undefined, @@ -2191,8 +2196,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: }); } - // Dev mode: simplified shutdown handlers (no engine components) - if (opts.dev) { + // UI-only mode: simplified shutdown handlers (no engine components) + if (noEngine) { const devShutdown = async (signal: NodeJS.Signals) => { if (shutdownInProgress) return; shutdownInProgress = true; @@ -2375,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 = opts.dev ? "dev" : settings.enginePaused ? "paused" : "active"; + const engineMode = noEngine ? "dev" : settings.enginePaused ? "paused" : "active"; const startupDurationMs = Date.now() - dashboardStartedAt; const systemInfo: SystemInfo = { @@ -2901,7 +2906,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (engineMode === "active") { tui.log("AI engine active"); } else if (engineMode === "dev") { - tui.log("AI engine disabled (dev mode)"); + tui.log("AI engine disabled (--no-engine)"); } else { tui.log("AI engine paused"); } @@ -2930,8 +2935,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: console.log(); console.log(` Tasks stored in .fusion/tasks/`); console.log(` Merge: AI-assisted (conflict resolution + commit messages)`); - if (opts.dev) { - console.log(` AI engine: ✗ disabled (dev mode)`); + if (noEngine) { + console.log(` AI engine: ✗ disabled (--no-engine)`); } else { console.log(` AI engine: ✓ active`); console.log(` • planning: auto-planning tasks`); diff --git a/packages/cli/src/commands/desktop.ts b/packages/cli/src/commands/desktop.ts index 06ed52117b..b162af6bd8 100644 --- a/packages/cli/src/commands/desktop.ts +++ b/packages/cli/src/commands/desktop.ts @@ -3,8 +3,10 @@ import { once } from "node:events"; import { join } from "node:path"; import type { AddressInfo } from "node:net"; import { createRequire } from "node:module"; -import { TaskStore } from "@fusion/core"; +import { CentralCore, TaskStore } from "@fusion/core"; import { createServer } from "@fusion/dashboard"; +import { ProjectEngineManager } from "@fusion/engine"; +import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; const require = createRequire(import.meta.url); @@ -18,6 +20,8 @@ interface DashboardRuntime { store: TaskStore; server: import("node:http").Server; port: number; + engineManager?: ProjectEngineManager; + centralCore?: CentralCore; } function runCommand(command: string, args: string[], cwd: string): Promise { @@ -53,7 +57,34 @@ async function startDashboardRuntime(rootDir: string, paused: boolean): Promise< await store.updateSettings({ enginePaused: true }); } - const app = createServer(store); + /* + * 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); try { @@ -64,6 +95,8 @@ async function startDashboardRuntime(rootDir: string, paused: boolean): Promise< }), ]); } catch (error) { + await engineManager.stopAll().catch(() => undefined); + await centralCore.close?.().catch(() => undefined); store.close(); throw error; } @@ -79,6 +112,8 @@ async function startDashboardRuntime(rootDir: string, paused: boolean): Promise< store, server, port: address.port, + engineManager, + centralCore, }; } @@ -86,6 +121,8 @@ async function closeDashboardRuntime(runtime: DashboardRuntime): Promise { await new Promise((resolve) => { runtime.server.close(() => resolve()); }); + await runtime.engineManager?.stopAll().catch(() => undefined); + await runtime.centralCore?.close?.().catch(() => undefined); runtime.store.close(); } diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index b6c4f87b6d..88e8b452b7 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -27,6 +27,7 @@ import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner"; import { SetupWarningBanner } from "./components/SetupWarningBanner"; import { CapacityRiskBanner } from "./components/CapacityRiskBanner"; import { TestModeBanner } from "./components/TestModeBanner"; +import { EngineUnavailableBanner } from "./components/EngineUnavailableBanner"; import { OAuthReloginBanner } from "./components/OAuthReloginBanner"; import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner"; import { DbCorruptionBanner } from "./components/DbCorruptionBanner"; @@ -2002,6 +2003,7 @@ function AppInner() { {viewMode === "project" && currentProject && ( <> + modalManager.openSettings("authentication" as SectionId)} /> diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e09ba58744..fd5ecad101 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -227,6 +227,9 @@ export interface DashboardHealthResponse { status: string; version: string; uptime: number; + engine?: { + available: boolean; + }; database: { healthy: boolean; corruptionDetected: boolean; diff --git a/packages/dashboard/app/components/EngineUnavailableBanner.css b/packages/dashboard/app/components/EngineUnavailableBanner.css new file mode 100644 index 0000000000..4e9d7d36ce --- /dev/null +++ b/packages/dashboard/app/components/EngineUnavailableBanner.css @@ -0,0 +1,54 @@ +.engine-unavailable-banner { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + margin-bottom: var(--space-md); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-md); + border-inline-start: var(--space-xs) solid var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 10%, transparent); + color: var(--text); +} + +.engine-unavailable-banner__icon { + width: 1.1rem; + height: 1.1rem; + margin-top: 0.15rem; + color: var(--color-warning); + flex: 0 0 auto; +} + +.engine-unavailable-banner__copy { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.engine-unavailable-banner__title { + margin: 0; + font-size: var(--font-size-base); + line-height: var(--line-height-tight); + color: var(--text); +} + +.engine-unavailable-banner__body { + margin: 0; + color: var(--text-muted); +} + +.engine-unavailable-banner code { + color: var(--text); + white-space: nowrap; +} + +@media (max-width: 768px) { + .engine-unavailable-banner { + padding: var(--space-sm); + } + + .engine-unavailable-banner code { + white-space: normal; + overflow-wrap: anywhere; + } +} diff --git a/packages/dashboard/app/components/EngineUnavailableBanner.tsx b/packages/dashboard/app/components/EngineUnavailableBanner.tsx new file mode 100644 index 0000000000..51d0d2b1b6 --- /dev/null +++ b/packages/dashboard/app/components/EngineUnavailableBanner.tsx @@ -0,0 +1,39 @@ +import { AlertTriangle } from "lucide-react"; +import { Trans, useTranslation } from "react-i18next"; + +import "./EngineUnavailableBanner.css"; + +interface EngineUnavailableBannerProps { + isVisible: boolean; +} + +export function EngineUnavailableBanner({ isVisible }: EngineUnavailableBannerProps) { + const { t } = useTranslation("app"); + if (!isVisible) { + return null; + } + + /* + * FNXC:EngineAvailability 2026-06-20-22:11: + * When the dashboard is served without an in-process AI engine, users need an explicit operational banner with the exact restart command because task execution, review, and merge automation cannot run from a UI-only process. + */ + return ( +
+
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 7d4c7ee53d..a07481560b 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -71,6 +71,9 @@ vi.mock("../../api", async (importOriginal) => { status: "ok", version: "1.0.0", uptime: 1, + engine: { + available: true, + }, database: { healthy: true, corruptionDetected: false, @@ -599,7 +602,7 @@ vi.mock("../../hooks/useMobileScrollLock", () => ({ import { App, didEnterAwaitingApproval, didEnterDone } from "../../App"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; -import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api"; +import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews, fetchDashboardHealth } from "../../api"; import { __resetShellHostContextForTests } from "../../shell-host"; import * as apiNodeModule from "../../hooks/useRemoteNodeData"; @@ -711,6 +714,81 @@ beforeEach(() => { }); describe("FN-4250 FileBrowserProvider coverage", () => { + it("shows engine restart instructions when health reports a UI-only dashboard", async () => { + vi.mocked(fetchDashboardHealth).mockResolvedValueOnce({ + status: "ok", + version: "1.0.0", + uptime: 1, + engine: { + available: false, + }, + 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(); + + 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 () => { + 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", + version: "1.0.0", + uptime: 1, + engine: { + available: false, + }, + database: { + healthy: true, + corruptionDetected: false, + corruptionErrors: [], + lastCheckedAt: null, + isRunning: false, + }, + taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null }, + }); + mockUseViewportMode.mockReturnValue("mobile"); + 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(); + + 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", () => { mockProjectsState.loading = false; mockProjectsState.projects = [ diff --git a/packages/dashboard/src/__tests__/server.test.ts b/packages/dashboard/src/__tests__/server.test.ts index 899aa13725..3a3421c039 100644 --- a/packages/dashboard/src/__tests__/server.test.ts +++ b/packages/dashboard/src/__tests__/server.test.ts @@ -363,6 +363,9 @@ describe("createServer health and headless mode", () => { status: "ok", version: CLI_PACKAGE_VERSION, uptime: expect.any(Number), + engine: { + available: false, + }, database: { healthy: true, corruptionDetected: false, @@ -379,6 +382,39 @@ describe("createServer health and headless mode", () => { }); }); + it("reports the engine unavailable when the manager has no running engines", async () => { + const store = createMockStore(); + const app = createServer(store, { + engineManager: { + getAllEngines: vi.fn().mockReturnValue(new Map()), + getEngine: vi.fn(), + } as any, + }); + + const res = await GET(app, "/api/health"); + + expect(res.status).toBe(200); + expect(res.body.engine).toEqual({ available: false }); + }); + + it("reports the engine available when the manager has a running engine", async () => { + const store = createMockStore(); + const engine = { + attachChatStore: vi.fn(), + }; + const app = createServer(store, { + engineManager: { + getAllEngines: vi.fn().mockReturnValue(new Map([["proj_123", engine]])), + getEngine: vi.fn(), + } as any, + }); + + const res = await GET(app, "/api/health"); + + expect(res.status).toBe(200); + expect(res.body.engine).toEqual({ available: true }); + }); + it("reports degraded status when database corruption is detected", async () => { const store = createMockStore({ getDatabaseHealth: vi.fn().mockReturnValue({ @@ -398,6 +434,9 @@ describe("createServer health and headless mode", () => { status: "degraded", version: CLI_PACKAGE_VERSION, uptime: expect.any(Number), + engine: { + available: false, + }, database: { healthy: false, corruptionDetected: true, @@ -438,6 +477,9 @@ describe("createServer health and headless mode", () => { status: "degraded", version: CLI_PACKAGE_VERSION, uptime: expect.any(Number), + engine: { + available: false, + }, database: { healthy: true, corruptionDetected: false, @@ -487,6 +529,9 @@ describe("createServer health and headless mode", () => { status: "degraded", version: CLI_PACKAGE_VERSION, uptime: expect.any(Number), + engine: { + available: false, + }, database: { healthy: true, corruptionDetected: false, @@ -530,6 +575,9 @@ describe("createServer health and headless mode", () => { status: "degraded", version: CLI_PACKAGE_VERSION, uptime: expect.any(Number), + engine: { + available: false, + }, database: { healthy: false, corruptionDetected: true, diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 4275e74804..537c515821 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -109,13 +109,25 @@ function buildTaskIdIntegrityHealth(report: TaskIdIntegrityReport) { }; } -function buildHealthPayload(store: TaskStore, cliPackageVersion: string) { - const database = store.getDatabaseHealth(); - const taskIdIntegrity = buildTaskIdIntegrityHealth(store.getTaskIdIntegrityReport()); +function buildHealthPayload(args: { + database: ReturnType; + taskIdIntegrityReport: ReturnType; + cliPackageVersion: string; + engineAvailable: boolean; +}) { + const { database, cliPackageVersion, engineAvailable } = args; + const taskIdIntegrity = buildTaskIdIntegrityHealth(args.taskIdIntegrityReport); return { status: !database.healthy || database.corruptionDetected || taskIdIntegrity.status === "anomaly" ? "degraded" : "ok", version: cliPackageVersion, uptime: Math.floor(process.uptime()), + /* + * FNXC:DashboardHealth 2026-06-20-22:11: + * The dashboard must distinguish "engine not started" from "engine paused" so UI-only launches can show remediation instructions instead of leaving users to infer why automation cannot run. + */ + engine: { + available: engineAvailable, + }, database, taskIdIntegrity, }; @@ -466,6 +478,11 @@ export interface ServerOptions { }; } +function hasDashboardEngine(options?: ServerOptions): boolean { + const engines = options?.engineManager?.getAllEngines?.(); + return Boolean(options?.engine || (engines && engines.size > 0)); +} + type DashboardExpressApp = ReturnType & { terminalWsServer?: WebSocketServer | null; badgeWsServer?: WebSocketServer | null; @@ -1411,7 +1428,12 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT } app.get("/api/health", (_req, res) => { - res.json(buildHealthPayload(store, cliPackageVersion)); + res.json(buildHealthPayload({ + database: store.getDatabaseHealth(), + taskIdIntegrityReport: store.getTaskIdIntegrityReport(), + cliPackageVersion, + engineAvailable: hasDashboardEngine(options), + })); }); app.get("/api/health/reliability", async (req, res) => { @@ -1525,15 +1547,17 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT }); app.post("/api/health/refresh", (_req, res) => { - const report = store.refreshTaskIdIntegrityReport(); - const database = store.refreshDatabaseHealth(); - res.json({ - status: !database.healthy || database.corruptionDetected || report.status === "anomaly" ? "degraded" : "ok", - version: cliPackageVersion, - uptime: Math.floor(process.uptime()), - database, - taskIdIntegrity: buildTaskIdIntegrityHealth(report), - }); + // Force-recompute integrity + database health, then shape the response via + // buildHealthPayload so this endpoint cannot drift from GET /api/health as + // the payload evolves (the `engine` field had to be hand-synced here + // before). The refreshed snapshots are passed in directly so the response + // reflects the freshly-recomputed values, not a separately-read cache. + res.json(buildHealthPayload({ + database: store.refreshDatabaseHealth(), + taskIdIntegrityReport: store.refreshTaskIdIntegrityReport(), + cliPackageVersion, + engineAvailable: hasDashboardEngine(options), + })); }); app.get("/api/updates/check", async (_req, res) => { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8714128263..c32f5caa47 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -38,6 +38,7 @@ "dependencies": { "@fusion/core": "workspace:*", "@fusion/dashboard": "workspace:*", + "@fusion/engine": "workspace:*", "electron-updater": "^6.6.0", "ms": "^2.1.3" }, diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index d06e0de6a7..b931ae819d 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -136,6 +136,25 @@ describe("LocalRuntimeManager", () => { expect(manager.getStatus()).toEqual({ source: "none", state: "stopped" }); }); + it("runs embedded runtime cleanup on stop", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const server = new FakeServer(4545); + const cleanup = vi.fn(async () => undefined); + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + createDashboardServer: async () => { + setTimeout(() => server.emit("listening"), 0); + return { server: server as unknown as Server, cleanup }; + }, + }); + + await manager.startLocal(); + await manager.stopLocal(); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it("startLocal while already running returns current status", async () => { const { LocalRuntimeManager } = await import("../local-runtime.ts"); const server = new FakeServer(4545); diff --git a/packages/desktop/src/__tests__/local-server.test.ts b/packages/desktop/src/__tests__/local-server.test.ts index c11f23b1ec..2ceac65cef 100644 --- a/packages/desktop/src/__tests__/local-server.test.ts +++ b/packages/desktop/src/__tests__/local-server.test.ts @@ -35,6 +35,19 @@ const mocks = vi.hoisted(() => { watch: vi.fn(async () => undefined), close: vi.fn(), }; + const centralCore = { + init: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + const engine = { id: "engine-1" }; + const engineMap = new Map([["project-1", engine]]); + const engineManager = { + startAll: vi.fn(async () => undefined), + startReconciliation: vi.fn(), + stopAll: vi.fn(async () => undefined), + getAllEngines: vi.fn(() => engineMap), + onProjectAccessed: vi.fn(), + }; class TaskStore { constructor(_rootDir: string) {} @@ -55,11 +68,19 @@ const mocks = vi.hoisted(() => { const createServer = vi.fn(() => ({ listen })); - return { TaskStore, createServer, store, listen }; + const CentralCore = vi.fn(function () { + return centralCore; + }); + const ProjectEngineManager = vi.fn(function () { + return engineManager; + }); + + return { TaskStore, CentralCore, ProjectEngineManager, createServer, store, listen, centralCore, engineManager, engine }; }); -vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore })); +vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore, CentralCore: mocks.CentralCore })); vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer })); +vi.mock("@fusion/engine", () => ({ ProjectEngineManager: mocks.ProjectEngineManager })); describe("DesktopLocalServerManager", () => { beforeEach(() => { @@ -75,6 +96,15 @@ describe("DesktopLocalServerManager", () => { expect(runtime.port).toBe(4545); expect(manager.getPort()).toBe(4545); expect(manager.getState().status).toBe("ready"); + expect(mocks.engineManager.startAll).toHaveBeenCalledTimes(1); + expect(mocks.createServer).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + engine: mocks.engine, + engineManager: mocks.engineManager, + centralCore: mocks.centralCore, + }), + ); }); it("stops local runtime and resets state", async () => { @@ -84,6 +114,8 @@ describe("DesktopLocalServerManager", () => { await manager.stop(); + expect(mocks.engineManager.stopAll).toHaveBeenCalled(); + expect(mocks.centralCore.close).toHaveBeenCalled(); expect(mocks.store.close).toHaveBeenCalled(); expect(manager.getState().status).toBe("idle"); expect(manager.getPort()).toBeUndefined(); diff --git a/packages/desktop/src/__tests__/menu.test.ts b/packages/desktop/src/__tests__/menu.test.ts index be479f3b61..7849b92fb9 100644 --- a/packages/desktop/src/__tests__/menu.test.ts +++ b/packages/desktop/src/__tests__/menu.test.ts @@ -224,6 +224,28 @@ describe("application menu", () => { ); }); + it("Connection menu exposes local, shutdown, and remote actions", async () => { + const onStartLocalRuntime = vi.fn(); + const onStopLocalRuntime = vi.fn(); + const onConnectRemoteServer = vi.fn(); + const { buildMenuTemplate } = await import("../menu.ts"); + const template = buildMenuTemplate({ + mainWindow: createMainWindowMock() as never, + appName: "Fusion", + onStartLocalRuntime, + onStopLocalRuntime, + onConnectRemoteServer, + }); + + findMenuItem(template, "Use Local Server")?.click?.({} as never, {} as never, {} as never); + findMenuItem(template, "Shut Down Local Server")?.click?.({} as never, {} as never, {} as never); + findMenuItem(template, "Connect to Remote Server…")?.click?.({} as never, {} as never, {} as never); + + expect(onStartLocalRuntime).toHaveBeenCalledTimes(1); + expect(onStopLocalRuntime).toHaveBeenCalledTimes(1); + expect(onConnectRemoteServer).toHaveBeenCalledTimes(1); + }); + it("all keyboard shortcuts use CmdOrCtrl prefix convention", async () => { const { buildMenuTemplate } = await import("../menu.ts"); const template = buildMenuTemplate({ diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index 289f456d22..89dfad774b 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -19,18 +19,21 @@ type TaskStoreLike = { close(): void; }; +type RuntimeCleanup = () => Promise | void; + type RuntimeInstance = { store: TaskStoreLike; server: Server; port: number; baseUrl: string; + cleanup?: RuntimeCleanup; }; export interface LocalRuntimeManagerOptions { rootDir: string; getExternalPort?: () => number | undefined; createStore?: (rootDir: string) => Promise; - createDashboardServer?: (store: TaskStoreLike) => Promise; + createDashboardServer?: (store: TaskStoreLike, rootDir: string) => Promise; } async function createStoreDefault(rootDir: string): Promise { @@ -38,9 +41,35 @@ async function createStoreDefault(rootDir: string): Promise { return new TaskStore(rootDir) as TaskStoreLike; } -async function createDashboardServerDefault(store: TaskStoreLike): Promise { +async function createDashboardServerDefault(store: TaskStoreLike, _rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> { + const { CentralCore } = await import("@fusion/core"); const { createServer } = await import("@fusion/dashboard"); - return createServer(store as never).listen(0); + const { ProjectEngineManager } = await import("@fusion/engine"); + + /* + * FNXC:DesktopRuntime 2026-06-20-23:39: + * 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?.(); + }, + }; } function parsePort(raw: string | undefined): number | undefined { @@ -73,7 +102,7 @@ export class LocalRuntimeManager { private readonly getExternalPort: () => number | undefined; private readonly createStore: (rootDir: string) => Promise; - private readonly createDashboardServer: (store: TaskStoreLike) => Promise; + private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise; constructor(private readonly options: LocalRuntimeManagerOptions) { this.getExternalPort = options.getExternalPort ?? (() => parsePort(process.env.FUSION_SERVER_PORT)); @@ -141,13 +170,16 @@ export class LocalRuntimeManager { private async startEmbedded(): Promise { let store: TaskStoreLike | null = null; let server: Server | null = null; + let cleanup: RuntimeCleanup | undefined; try { store = await this.createStore(this.options.rootDir); await store.init(); await store.watch(); - server = await this.createDashboardServer(store); + const dashboardServer = await this.createDashboardServer(store, this.options.rootDir); + cleanup = "server" in dashboardServer ? dashboardServer.cleanup : undefined; + server = "server" in dashboardServer ? dashboardServer.server : dashboardServer; await Promise.race([ once(server, "listening"), once(server, "error").then(([error]) => { @@ -157,7 +189,7 @@ export class LocalRuntimeManager { const port = getAddressPort(server); const baseUrl = `http://127.0.0.1:${port}`; - this.runtime = { store, server, port, baseUrl }; + this.runtime = { store, server, port, baseUrl, cleanup }; this.status = { source: "embedded-local", state: "running", port, baseUrl }; return this.status; } catch (error) { @@ -166,6 +198,7 @@ export class LocalRuntimeManager { server!.close(() => resolve()); }); } + await cleanup?.(); if (store) { store.close(); } @@ -197,6 +230,7 @@ export class LocalRuntimeManager { const runtime = this.runtime; this.runtime = null; await new Promise((resolve) => runtime.server.close(() => resolve())); + await runtime.cleanup?.(); runtime.store.close(); this.status = { source: "none", state: "stopped" }; return this.status; diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index d3af311d97..6331bd4277 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -7,10 +7,13 @@ type TaskStoreLike = { close(): void; }; +type RuntimeCleanup = () => Promise | void; + export interface DesktopLocalRuntime { store: TaskStoreLike; server: Server; port: number; + cleanup?: RuntimeCleanup; } export interface DesktopLocalServerState { @@ -41,14 +44,39 @@ export class DesktopLocalServerManager { this.state = { status: "starting", error: null }; + let store: TaskStoreLike | null = null; + let server: Server | null = null; + let cleanup: RuntimeCleanup | undefined; + try { const { TaskStore } = await import("@fusion/core"); + const { CentralCore } = await import("@fusion/core"); const { createServer } = await import("@fusion/dashboard"); - const store = new TaskStore(this.rootDir) as TaskStoreLike; + const { ProjectEngineManager } = await import("@fusion/engine"); + store = new TaskStore(this.rootDir) as TaskStoreLike; await store.init(); await store.watch(); - const app = createServer(store as never); - const server = app.listen(0); + /* + * FNXC:DesktopRuntime 2026-06-20-23:39: + * 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); + 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), + }); + server = app.listen(0); + cleanup = async () => { + await engineManager.stopAll(); + await centralCore.close?.(); + }; await Promise.race([ once(server, "listening"), @@ -62,10 +90,15 @@ export class DesktopLocalServerManager { throw new Error("Failed to resolve local server port"); } - this.runtime = { store, server, port: address.port }; + this.runtime = { store, server, port: address.port, cleanup }; this.state = { status: "ready", port: address.port, error: null }; return this.runtime; } catch (error) { + if (server) { + await new Promise((resolve) => server!.close(() => resolve())); + } + await cleanup?.(); + store?.close(); this.state = { status: "error", error: error instanceof Error ? error.message : String(error), @@ -84,6 +117,7 @@ export class DesktopLocalServerManager { this.runtime = null; await new Promise((resolve) => runtime.server.close(() => resolve())); + await runtime.cleanup?.(); runtime.store.close(); this.state = { status: "idle", error: null }; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 87c83e98de..896832c4b7 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -238,6 +238,21 @@ export async function initializeApp(): Promise { onChangeLaunchMode: async () => { await resetLaunchModeAndReload(createdWindow); }, + onStartLocalRuntime: async () => { + if (!localRuntimeManager) return; + currentRemoteLaunch = null; + currentDesktopLaunchMode = "local"; + localRuntimeStartupAttempted = false; + await startLocalRuntimeOnce(); + await saveDesktopLaunchMode("local"); + createdWindow.webContents.reload(); + }, + onStopLocalRuntime: async () => { + await localRuntimeManager?.stopLocal(); + }, + onConnectRemoteServer: async () => { + await resetLaunchModeAndReload(createdWindow); + }, onCheckForUpdates: async () => { await triggerUpdateCheck(createdWindow); }, diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 799565f96b..e6f0db29f0 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -9,21 +9,41 @@ export interface AppMenuOptions { mainWindow: BrowserWindow; appName: string; onChangeLaunchMode?: () => Promise | void; + onStartLocalRuntime?: () => Promise | void; + onStopLocalRuntime?: () => Promise | void; + onConnectRemoteServer?: () => Promise | void; onCheckForUpdates?: () => Promise | void; } +function runMenuAction(label: string, action: (() => Promise | void) | undefined): void { + if (!action) return; + void Promise.resolve(action()).catch((error: unknown) => { + console.error(`[desktop/menu] ${label} failed`, error); + }); +} + function buildConnectionSubmenu(options: AppMenuOptions): MenuItemConstructorOptions { return { label: "Connection", submenu: [ + { + label: "Use Local Server", + click: () => runMenuAction("onStartLocalRuntime", options.onStartLocalRuntime), + }, + { + label: "Shut Down Local Server", + click: () => runMenuAction("onStopLocalRuntime", options.onStopLocalRuntime), + }, + { + type: "separator", + }, + { + label: "Connect to Remote Server…", + click: () => runMenuAction("onConnectRemoteServer", options.onConnectRemoteServer ?? options.onChangeLaunchMode), + }, { label: "Change Launch Mode…", - click: () => { - if (!options.onChangeLaunchMode) return; - void Promise.resolve(options.onChangeLaunchMode()).catch((error: unknown) => { - console.error("[desktop/menu] onChangeLaunchMode failed", error); - }); - }, + click: () => runMenuAction("onChangeLaunchMode", options.onChangeLaunchMode), }, ], }; @@ -38,12 +58,7 @@ function buildAppSubmenu(options: AppMenuOptions): MenuItemConstructorOptions { }, { label: "Check for Updates…", - click: () => { - if (!options.onCheckForUpdates) return; - void Promise.resolve(options.onCheckForUpdates()).catch((error: unknown) => { - console.error("[desktop/menu] onCheckForUpdates failed", error); - }); - }, + click: () => runMenuAction("onCheckForUpdates", options.onCheckForUpdates), }, { type: "separator", @@ -233,12 +248,7 @@ function buildHelpSubmenu(options: AppMenuOptions): MenuItemConstructorOptions { submenu: [ { label: "Check for Updates…", - click: () => { - if (!options.onCheckForUpdates) return; - void Promise.resolve(options.onCheckForUpdates()).catch((error: unknown) => { - console.error("[desktop/menu] onCheckForUpdates failed", error); - }); - }, + click: () => runMenuAction("onCheckForUpdates", options.onCheckForUpdates), }, { label: "Fusion Documentation", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2923da0e40..cb0e19567c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -400,6 +400,9 @@ importers: '@fusion/dashboard': specifier: workspace:* version: link:../dashboard + '@fusion/engine': + specifier: workspace:* + version: link:../engine electron-updater: specifier: ^6.6.0 version: 6.8.3 diff --git a/scripts/start-local.mjs b/scripts/start-local.mjs index 253eb59438..acbcbd9005 100644 --- a/scripts/start-local.mjs +++ b/scripts/start-local.mjs @@ -5,7 +5,7 @@ * Defaults are intentionally conservative: * - localhost only * - first free port at/above 4050 - * - dashboard/API without the AI engine unless --engine is passed + * - dashboard/API with the AI engine unless --no-engine is passed * - no bearer-token auth on localhost */ @@ -25,7 +25,8 @@ Usage: pnpm local [options] Options: - --engine Start the full AI engine. Default: dashboard/API only. + --engine Start the full AI engine. Default. + --no-engine Start dashboard/API only, without the AI engine. --paused Start with automation paused. --port Preferred port. Default: 4050. Port 4040 is reserved. --host Host to bind. Default: 127.0.0.1. @@ -58,7 +59,11 @@ function warn(message) { function parseArgs(argv) { const opts = { - engine: false, + /* + * FNXC:LocalStartup 2026-06-20-22:11: + * `pnpm local` must start a working local Fusion node by default, including the AI engine, so users do not land in a dashboard that cannot execute tasks unless they deliberately pass `--no-engine`. + */ + engine: true, paused: false, port: 4050, host: "127.0.0.1", @@ -361,7 +366,7 @@ async function main() { } const dashboardArgs = ["dashboard", "--host", opts.host, "--port", String(port)]; - if (!opts.engine) dashboardArgs.push("--dev"); + if (!opts.engine) dashboardArgs.push("--no-engine"); if (opts.paused) dashboardArgs.push("--paused"); if (shouldDisableAuth(opts)) dashboardArgs.push("--no-auth"); From 42a1346d6ccbaef9507c5e2e40469e1f0f114b95 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 02:13:24 -0700 Subject: [PATCH 2/2] fix: address engine startup review feedback --- .changeset/local-starts-engine.md | 2 +- .../src/commands/__tests__/dashboard.test.ts | 9 ++ .../cli/src/commands/dashboard-tui/app.tsx | 2 +- .../cli/src/commands/dashboard-tui/state.ts | 2 +- packages/cli/src/commands/dashboard.ts | 4 +- packages/cli/src/commands/desktop.ts | 111 +++++++++--------- .../components/EngineUnavailableBanner.tsx | 3 +- .../app/components/__tests__/App.test.tsx | 28 ++++- .../src/__tests__/local-server.test.ts | 38 ++++++ packages/desktop/src/__tests__/main.test.ts | 16 +++ packages/desktop/src/engine-runtime.ts | 26 ++++ packages/desktop/src/local-runtime.ts | 46 +++++--- packages/desktop/src/local-server.ts | 16 ++- packages/desktop/src/main.ts | 8 +- packages/desktop/src/menu.ts | 4 + 15 files changed, 226 insertions(+), 89 deletions(-) create mode 100644 packages/desktop/src/engine-runtime.ts 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: [