From 7ef381762a6c6b045236439768da78474bea9538 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 23:56:27 -0700 Subject: [PATCH] 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");