Merge pull request #1699 from Runfusion/codex/start-engines-by-default
[codex] Start Fusion 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": minor
|
||||
---
|
||||
|
||||
Start the AI engine by default in `pnpm local`, keep dashboard `--dev` engine-on unless `--no-engine` is passed, start desktop local runtimes with engines, and show dashboard instructions when Fusion is launched without an engine.
|
||||
@@ -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,26 @@ 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("does not start engine components when dev mode is launched with noEngine", async () => {
|
||||
const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine");
|
||||
await runDashboard(0, { open: false, dev: true, noEngine: true });
|
||||
|
||||
expect(TriageProcessor).not.toHaveBeenCalled();
|
||||
expect(TaskExecutor).not.toHaveBeenCalled();
|
||||
expect(Scheduler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows 'AI engine: ✓ active' when not in dev mode", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
@@ -2910,7 +2930,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 +3220,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);
|
||||
});
|
||||
|
||||
@@ -330,7 +330,7 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Text dimColor>Engine</Text>
|
||||
{info.engineMode === "dev" && <Text color="yellow">dev</Text>}
|
||||
{info.engineMode === "no-engine" && <Text color="yellow">no-engine</Text>}
|
||||
{info.engineMode === "paused" && <Text color="yellow">paused</Text>}
|
||||
{info.engineMode === "active" && <Text color="green">active</Text>}
|
||||
</Box>
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface SystemInfo {
|
||||
authEnabled: boolean;
|
||||
authToken?: string;
|
||||
tokenizedUrl?: string;
|
||||
engineMode: "dev" | "active" | "paused";
|
||||
engineMode: "no-engine" | "active" | "paused";
|
||||
fileWatcher: boolean;
|
||||
startTimeMs: number;
|
||||
startupDurationMs?: number;
|
||||
|
||||
@@ -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 ? "no-engine" : settings.enginePaused ? "paused" : "active";
|
||||
const startupDurationMs = Date.now() - dashboardStartedAt;
|
||||
|
||||
const systemInfo: SystemInfo = {
|
||||
@@ -2900,8 +2905,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
tui.log(`Dashboard started at ${baseUrl}`);
|
||||
if (engineMode === "active") {
|
||||
tui.log("AI engine active");
|
||||
} else if (engineMode === "dev") {
|
||||
tui.log("AI engine disabled (dev mode)");
|
||||
} else if (engineMode === "no-engine") {
|
||||
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> {
|
||||
@@ -46,46 +50,82 @@ async function buildDesktopArtifacts(rootDir: string): Promise<void> {
|
||||
|
||||
async function startDashboardRuntime(rootDir: string, paused: boolean): Promise<DashboardRuntime> {
|
||||
const store = new TaskStore(rootDir);
|
||||
await store.init();
|
||||
await store.watch();
|
||||
|
||||
if (paused) {
|
||||
await store.updateSettings({ enginePaused: true });
|
||||
}
|
||||
|
||||
const app = createServer(store);
|
||||
const server = app.listen(0);
|
||||
|
||||
let server: import("node:http").Server | null = null;
|
||||
let engineManager: ProjectEngineManager | undefined;
|
||||
let centralCore: CentralCore | undefined;
|
||||
try {
|
||||
await store.init();
|
||||
await store.watch();
|
||||
|
||||
if (paused) {
|
||||
await store.updateSettings({ enginePaused: true });
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:DesktopRuntime 2026-06-20-23:39:
|
||||
* Desktop local mode must start the same project engine lifecycle as CLI dashboard mode; a desktop window without engines leaves users with a live dashboard that cannot execute tasks.
|
||||
*/
|
||||
centralCore = new CentralCore();
|
||||
await centralCore.init();
|
||||
const cwdRegistered = await ensureCwdProjectRegistered({
|
||||
cwd: rootDir,
|
||||
central: centralCore,
|
||||
logPrefix: "desktop",
|
||||
autoRegister: true,
|
||||
});
|
||||
engineManager = new ProjectEngineManager(centralCore);
|
||||
await engineManager.startAll();
|
||||
engineManager.startReconciliation();
|
||||
const cwdEngine = cwdRegistered
|
||||
? await engineManager.ensureEngine(cwdRegistered.id).catch((err) => {
|
||||
console.warn(`[desktop] Failed to warm cwd project engine: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engineManager,
|
||||
centralCore,
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager?.onProjectAccessed(projectId),
|
||||
});
|
||||
server = app.listen(0);
|
||||
|
||||
await Promise.race([
|
||||
once(server, "listening"),
|
||||
once(server, "error").then(([error]) => {
|
||||
throw error;
|
||||
}),
|
||||
]);
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address?.port) {
|
||||
throw new Error("Failed to determine dashboard server port");
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
server,
|
||||
port: address.port,
|
||||
engineManager,
|
||||
centralCore,
|
||||
};
|
||||
} catch (error) {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
||||
}
|
||||
await engineManager?.stopAll().catch(() => undefined);
|
||||
await centralCore?.close?.().catch(() => undefined);
|
||||
store.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address?.port) {
|
||||
server.close();
|
||||
store.close();
|
||||
throw new Error("Failed to determine dashboard server port");
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
server,
|
||||
port: address.port,
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -2005,6 +2006,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,38 @@
|
||||
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."
|
||||
components={{
|
||||
sourceCmd: <code />,
|
||||
cliCmd: <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,105 @@ 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();
|
||||
});
|
||||
|
||||
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("does not show engine restart instructions when an older health payload omits engine status", async () => {
|
||||
vi.mocked(fetchDashboardHealth).mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
version: "1.0.0",
|
||||
uptime: 1,
|
||||
database: {
|
||||
healthy: true,
|
||||
corruptionDetected: false,
|
||||
corruptionErrors: [],
|
||||
lastCheckedAt: null,
|
||||
isRunning: false,
|
||||
},
|
||||
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
|
||||
});
|
||||
mockProjectsState.loading = false;
|
||||
mockProjectsState.projects = [
|
||||
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
|
||||
];
|
||||
mockCurrentProjectState.loading = false;
|
||||
|
||||
render(<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();
|
||||
});
|
||||
|
||||
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,24 @@ const mocks = vi.hoisted(() => {
|
||||
watch: vi.fn(async () => undefined),
|
||||
close: vi.fn(),
|
||||
};
|
||||
const centralCore = {
|
||||
init: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
getProjectByPath: vi.fn(async () => ({ id: "project-1", name: "Repo", path: "/repo", status: "active" })),
|
||||
listProjects: vi.fn(async () => []),
|
||||
registerProject: vi.fn(async ({ path, name }: { path: string; name: string }) => ({ id: "project-1", name, path, status: "initializing" })),
|
||||
updateProject: vi.fn(async (id: string, patch: Record<string, unknown>) => ({ id, name: "Repo", path: "/repo", status: patch.status ?? "active" })),
|
||||
};
|
||||
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),
|
||||
ensureEngine: vi.fn(async () => engine),
|
||||
onProjectAccessed: vi.fn(),
|
||||
};
|
||||
|
||||
class TaskStore {
|
||||
constructor(_rootDir: string) {}
|
||||
@@ -55,11 +73,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 +101,17 @@ 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.centralCore.getProjectByPath).toHaveBeenCalledWith("/repo");
|
||||
expect(mocks.engineManager.ensureEngine).toHaveBeenCalledWith("project-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 +121,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();
|
||||
@@ -98,6 +137,37 @@ describe("DesktopLocalServerManager", () => {
|
||||
expect(manager.getState()).toMatchObject({ status: "error", error: "init failed" });
|
||||
});
|
||||
|
||||
it("cleans up engine and central core when server creation fails", async () => {
|
||||
mocks.createServer.mockImplementationOnce(() => {
|
||||
throw new Error("server failed");
|
||||
});
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
await expect(manager.start()).rejects.toThrow("server failed");
|
||||
|
||||
expect(mocks.engineManager.stopAll).toHaveBeenCalled();
|
||||
expect(mocks.centralCore.close).toHaveBeenCalled();
|
||||
expect(mocks.store.close).toHaveBeenCalled();
|
||||
expect(manager.getState()).toMatchObject({ status: "error", error: "server failed" });
|
||||
});
|
||||
|
||||
it("registers an active runtime-root project when no projects exist", async () => {
|
||||
mocks.centralCore.getProjectByPath.mockResolvedValueOnce(undefined);
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(mocks.centralCore.registerProject).toHaveBeenCalledWith({
|
||||
path: "/repo",
|
||||
name: "repo",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
expect(mocks.centralCore.updateProject).toHaveBeenCalledWith("project-1", { status: "active" });
|
||||
expect(mocks.engineManager.ensureEngine).toHaveBeenCalledWith("project-1");
|
||||
});
|
||||
|
||||
it("returns existing runtime when start is called twice", async () => {
|
||||
const { DesktopLocalServerManager } = await import("../local-server.ts");
|
||||
const manager = new DesktopLocalServerManager("/repo");
|
||||
|
||||
@@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => {
|
||||
focus: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
webContents: { reload: vi.fn() },
|
||||
};
|
||||
|
||||
const BrowserWindow = vi.fn(function () {
|
||||
@@ -386,6 +387,21 @@ describe("main process", () => {
|
||||
expect(mainDeps.saveDesktopLaunchMode).toHaveBeenCalledWith("remote");
|
||||
});
|
||||
|
||||
it("does not persist local menu mode when local runtime startup fails", async () => {
|
||||
mainDeps.startLocal.mockRejectedValueOnce(new Error("boom"));
|
||||
const { initializeApp, getCurrentDesktopLaunchMode } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
const menuOptions = mainDeps.buildAppMenu.mock.calls[0]?.[0] as
|
||||
| { onStartLocalRuntime?: () => Promise<void> }
|
||||
| undefined;
|
||||
await expect(menuOptions?.onStartLocalRuntime?.()).rejects.toThrow("boom");
|
||||
|
||||
expect(mainDeps.saveDesktopLaunchMode).not.toHaveBeenCalledWith("local");
|
||||
expect(getCurrentDesktopLaunchMode()).toBe("choose");
|
||||
});
|
||||
|
||||
it("createMainWindow registers close and closed handlers", async () => {
|
||||
const { createMainWindow } = await importMainModule();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
26
packages/desktop/src/engine-runtime.ts
Normal file
26
packages/desktop/src/engine-runtime.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { basename } from "node:path";
|
||||
|
||||
import type { CentralCore, RegisteredProject } from "@fusion/core";
|
||||
|
||||
/*
|
||||
* FNXC:DesktopRuntime 2026-06-21-02:04:
|
||||
* Desktop local mode starts engines by default, so the embedded server should prefer the project represented by the desktop runtime root instead of whichever registered engine happens to be first. The runtime root may be a home directory, so this path must not call helpers that initialize Git repositories as a side effect.
|
||||
*/
|
||||
export async function ensureDesktopRuntimeProject(centralCore: CentralCore, rootDir: string): Promise<RegisteredProject> {
|
||||
const existing = await centralCore.getProjectByPath(rootDir);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const projects = await centralCore.listProjects();
|
||||
if (projects.length > 0) {
|
||||
return projects[0]!;
|
||||
}
|
||||
|
||||
const registered = await centralCore.registerProject({
|
||||
path: rootDir,
|
||||
name: basename(rootDir) || "Fusion Desktop",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
return centralCore.updateProject(registered.id, { status: "active" });
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { once } from "node:events";
|
||||
import type { Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { ensureDesktopRuntimeProject } from "./engine-runtime.js";
|
||||
|
||||
export type RuntimeSource = "embedded-local" | "external-cli" | "none";
|
||||
export type RuntimeState = "stopped" | "starting" | "running" | "error";
|
||||
|
||||
@@ -19,18 +21,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 +43,43 @@ 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();
|
||||
const engineManager = new ProjectEngineManager(centralCore);
|
||||
const cleanup = async () => {
|
||||
await engineManager.stopAll();
|
||||
await centralCore.close?.();
|
||||
};
|
||||
|
||||
try {
|
||||
await centralCore.init();
|
||||
const rootProject = await ensureDesktopRuntimeProject(centralCore, rootDir);
|
||||
await engineManager.startAll();
|
||||
engineManager.startReconciliation();
|
||||
const primaryEngine = await engineManager.ensureEngine(rootProject.id);
|
||||
const app = createServer(store as never, {
|
||||
engine: primaryEngine,
|
||||
engineManager,
|
||||
centralCore,
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
});
|
||||
|
||||
return {
|
||||
server: app.listen(0),
|
||||
cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePort(raw: string | undefined): number | undefined {
|
||||
@@ -73,7 +112,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 +180,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 +199,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 +208,7 @@ export class LocalRuntimeManager {
|
||||
server!.close(() => resolve());
|
||||
});
|
||||
}
|
||||
await cleanup?.();
|
||||
if (store) {
|
||||
store.close();
|
||||
}
|
||||
@@ -197,6 +240,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;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { once } from "node:events";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { ensureDesktopRuntimeProject } from "./engine-runtime.js";
|
||||
|
||||
type TaskStoreLike = {
|
||||
init(): Promise<void>;
|
||||
watch(): Promise<void>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
type RuntimeCleanup = () => Promise<void> | void;
|
||||
|
||||
export interface DesktopLocalRuntime {
|
||||
store: TaskStoreLike;
|
||||
server: Server;
|
||||
port: number;
|
||||
cleanup?: RuntimeCleanup;
|
||||
}
|
||||
|
||||
export interface DesktopLocalServerState {
|
||||
@@ -41,14 +47,40 @@ 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();
|
||||
const engineManager = new ProjectEngineManager(centralCore);
|
||||
cleanup = async () => {
|
||||
await engineManager.stopAll();
|
||||
await centralCore.close?.();
|
||||
};
|
||||
await centralCore.init();
|
||||
const rootProject = await ensureDesktopRuntimeProject(centralCore, this.rootDir);
|
||||
await engineManager.startAll();
|
||||
engineManager.startReconciliation();
|
||||
const primaryEngine = await engineManager.ensureEngine(rootProject.id);
|
||||
const app = createServer(store as never, {
|
||||
engine: primaryEngine,
|
||||
engineManager,
|
||||
centralCore,
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
});
|
||||
server = app.listen(0);
|
||||
|
||||
await Promise.race([
|
||||
once(server, "listening"),
|
||||
@@ -62,10 +94,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 +121,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,25 @@ export async function initializeApp(): Promise<void> {
|
||||
onChangeLaunchMode: async () => {
|
||||
await resetLaunchModeAndReload(createdWindow);
|
||||
},
|
||||
/*
|
||||
* FNXC:DesktopRuntimeMode 2026-06-21-02:04:
|
||||
* Menu-driven local/remote switching must only persist local mode after the embedded runtime starts successfully; shutdown stops the local server without rewriting the launch-mode preference.
|
||||
*/
|
||||
onStartLocalRuntime: async () => {
|
||||
if (!localRuntimeManager) return;
|
||||
localRuntimeStartupAttempted = false;
|
||||
await startLocalRuntimeOnce();
|
||||
currentRemoteLaunch = null;
|
||||
currentDesktopLaunchMode = "local";
|
||||
await saveDesktopLaunchMode("local");
|
||||
createdWindow.webContents.reload();
|
||||
},
|
||||
onStopLocalRuntime: async () => {
|
||||
await localRuntimeManager?.stopLocal();
|
||||
},
|
||||
onConnectRemoteServer: async () => {
|
||||
await resetLaunchModeAndReload(createdWindow);
|
||||
},
|
||||
onCheckForUpdates: async () => {
|
||||
await triggerUpdateCheck(createdWindow);
|
||||
},
|
||||
|
||||
@@ -9,21 +9,45 @@ 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 {
|
||||
/*
|
||||
* FNXC:DesktopConnectionMenu 2026-06-21-02:04:
|
||||
* Desktop users need menu-level controls to start the embedded local server, shut it down, or connect to a remote server without returning to first-run setup.
|
||||
*/
|
||||
return {
|
||||
label: "Connection",
|
||||
submenu: [
|
||||
{
|
||||
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 +62,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 +252,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