feat: start engines by default

This commit is contained in:
gsxdsm
2026-06-20 23:56:27 -07:00
parent 2c1cff820b
commit 7ef381762a
30 changed files with 650 additions and 108 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 <code>` (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` |

View File

@@ -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 <none|client|full> # 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 <none|client|full> # 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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 <port>] [--host <host>] [--paused] [--daemon] [--project <id|name>] [--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 <locale> Terminal-UI locale for this run (en, zh-CN, zh-TW, fr, es, ko); the browser dashboard resolves its own language
--attach <file> Attach file(s) on task create (repeatable)
--depends <id> 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;
}

View File

@@ -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<typeof vi.fn>).mock.calls[0];
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
@@ -2112,7 +2112,7 @@ describe("runDashboard — --paused flag", () => {
});
});
describe("runDashboard — --dev mode", () => {
describe("runDashboard — --no-engine mode", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
@@ -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();

View File

@@ -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);
});

View File

@@ -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<typeof createServer>;
@@ -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`);

View File

@@ -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<void> {
@@ -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<void> {
await new Promise<void>((resolve) => {
runtime.server.close(() => resolve());
});
await runtime.engineManager?.stopAll().catch(() => undefined);
await runtime.centralCore?.close?.().catch(() => undefined);
runtime.store.close();
}

View File

@@ -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 && (
<>
<TestModeBanner isActive={isTestMode} />
<EngineUnavailableBanner isVisible={dashboardHealth?.engine?.available === false} />
<OAuthReloginBanner
onReLogin={(_providerId) => modalManager.openSettings("authentication" as SectionId)}
/>

View File

@@ -227,6 +227,9 @@ export interface DashboardHealthResponse {
status: string;
version: string;
uptime: number;
engine?: {
available: boolean;
};
database: {
healthy: boolean;
corruptionDetected: boolean;

View File

@@ -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;
}
}

View File

@@ -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 (
<section className="engine-unavailable-banner" role="status" aria-live="polite">
<AlertTriangle className="engine-unavailable-banner__icon" aria-hidden="true" />
<div className="engine-unavailable-banner__copy">
<h2 className="engine-unavailable-banner__title">{t("engineUnavailable.title", "AI engine is not running")}</h2>
<p className="engine-unavailable-banner__body">
<Trans
i18nKey="app:engineUnavailable.body"
defaults="This dashboard can display project data, but task automation will not run until you restart Fusion with the engine. Stop this server and run <sourceCmd>pnpm local</sourceCmd> from a source checkout, or <cliCmd>fn dashboard</cliCmd> from an installed CLI. On older source checkouts, use <legacyCmd>pnpm local -- --engine</legacyCmd>."
components={{
sourceCmd: <code />,
cliCmd: <code />,
legacyCmd: <code />,
}}
/>
</p>
</div>
</section>
);
}

View File

@@ -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(<App />);
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(<App />);
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(<App />);
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 = [

View File

@@ -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,

View File

@@ -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<TaskStore["getDatabaseHealth"]>;
taskIdIntegrityReport: ReturnType<TaskStore["getTaskIdIntegrityReport"]>;
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<typeof express> & {
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) => {

View File

@@ -38,6 +38,7 @@
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/engine": "workspace:*",
"electron-updater": "^6.6.0",
"ms": "^2.1.3"
},

View File

@@ -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);

View File

@@ -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();

View File

@@ -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({

View File

@@ -19,18 +19,21 @@ type TaskStoreLike = {
close(): void;
};
type RuntimeCleanup = () => Promise<void> | 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<TaskStoreLike>;
createDashboardServer?: (store: TaskStoreLike) => Promise<Server>;
createDashboardServer?: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
}
async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
@@ -38,9 +41,35 @@ async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
return new TaskStore(rootDir) as TaskStoreLike;
}
async function createDashboardServerDefault(store: TaskStoreLike): Promise<Server> {
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<TaskStoreLike>;
private readonly createDashboardServer: (store: TaskStoreLike) => Promise<Server>;
private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
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<DesktopRuntimeStatus> {
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<void>((resolve) => runtime.server.close(() => resolve()));
await runtime.cleanup?.();
runtime.store.close();
this.status = { source: "none", state: "stopped" };
return this.status;

View File

@@ -7,10 +7,13 @@ type TaskStoreLike = {
close(): void;
};
type RuntimeCleanup = () => Promise<void> | 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<void>((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<void>((resolve) => runtime.server.close(() => resolve()));
await runtime.cleanup?.();
runtime.store.close();
this.state = { status: "idle", error: null };
}

View File

@@ -238,6 +238,21 @@ export async function initializeApp(): Promise<void> {
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);
},

View File

@@ -9,21 +9,41 @@ export interface AppMenuOptions {
mainWindow: BrowserWindow;
appName: string;
onChangeLaunchMode?: () => Promise<void> | void;
onStartLocalRuntime?: () => Promise<void> | void;
onStopLocalRuntime?: () => Promise<void> | void;
onConnectRemoteServer?: () => Promise<void> | void;
onCheckForUpdates?: () => Promise<void> | void;
}
function runMenuAction(label: string, action: (() => Promise<void> | 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",

3
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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 <port> Preferred port. Default: 4050. Port 4040 is reserved.
--host <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");