feat: start engines by default
This commit is contained in:
5
.changeset/local-starts-engine.md
Normal file
5
.changeset/local-starts-engine.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -227,6 +227,9 @@ export interface DashboardHealthResponse {
|
||||
status: string;
|
||||
version: string;
|
||||
uptime: number;
|
||||
engine?: {
|
||||
available: boolean;
|
||||
};
|
||||
database: {
|
||||
healthy: boolean;
|
||||
corruptionDetected: boolean;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/engine": "workspace:*",
|
||||
"electron-updater": "^6.6.0",
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user