Files
fusion/packages/desktop/src/local-runtime.ts
gsxdsm a227b19a22 feat: add Command Center System panel with rebuild/restart controls, Plugins tab, and supervised-by-default dashboard
- pnpm dev / new pnpm start default to the dashboard command
- fn dashboard (and bare fn/fusion/npx, incl. packaged binaries) now runs
  supervised by default via an attached foreground child (TUI-safe);
  --no-supervise opts out; FUSION_RESTART_EXIT_CODE=86 = intentional restart
- New /api/system routes: info, restart, rebuild jobs with SSE output,
  engine restart, agents restart-all, plugins reload-all, log tail
- System tab: rebuild & restart (source checkouts only, hidden elsewhere),
  restart server/engine/agents, backup DB, live server logs, copy
  diagnostics, report bug; new Plugins tab reusing PluginManager
- Desktop restart via Electron app.relaunch(); DashboardLogSink now keeps a
  bounded history + listener feed for the log viewer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:42:09 -07:00

540 lines
22 KiB
TypeScript

import { once } from "node:events";
import { appendFileSync } from "node:fs";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js";
import { resolveDesktopBundlePluginDirs } from "./bundled-plugin-dirs.js";
/*
* FNXC:DesktopRuntime 2026-07-02-14:35:
* Env-gated startup trace. Packaged desktop builds have no file logging, so a stalled
* or failed embedded-runtime start is invisible to operators (the symptom is only a
* spinner that times out). Setting FUSION_STARTUP_TRACE=<path> appends a timestamped
* step-by-step trace of startLocal()/startEmbedded()/createDashboardServer() to that
* file — the diagnostic that pinpointed the launch-mode split-brain hang. Zero cost
* when unset; keep it so this class of hang is diagnosable in the field.
*/
const STARTUP_TRACE_FILE = process.env.FUSION_STARTUP_TRACE;
const __traceStart = Date.now();
function strace(msg: string): void {
if (!STARTUP_TRACE_FILE) return;
try {
appendFileSync(STARTUP_TRACE_FILE, `[+${((Date.now() - __traceStart) / 1000).toFixed(2)}s] ${msg}\n`);
} catch {
// best-effort
}
}
export type RuntimeSource = "embedded-local" | "external-cli" | "none";
export type RuntimeState = "stopped" | "starting" | "running" | "error";
export interface DesktopRuntimeStatus {
source: RuntimeSource;
state: RuntimeState;
port?: number;
baseUrl?: string;
error?: string;
}
/*
* FNXC:DesktopRuntime 2026-07-07-12:00:
* FN-7623: the embedded desktop server must wire a PluginStore + PluginLoader into createServer
* (as the CLI dashboard command does) or the Settings -> Plugins Browse-registry sub-router never
* mounts ("Plugin \"registry\" not found") and plugin install throws "Plugin install mode is not
* supported: plugin loader not available". getPluginStore()/getDatabase() are the two TaskStore
* members this wiring needs beyond the pre-existing init/watch/close surface.
*/
type PluginStoreLike = { init(): Promise<void> };
type PluginDatabaseLike = { runPluginSchemaInits(hooks: Array<{ pluginId: string; hook: unknown }>): Promise<void> };
type TaskStoreLike = {
init(): Promise<void>;
watch(): Promise<void>;
close(): void;
getPluginStore(): PluginStoreLike;
getDatabase(): PluginDatabaseLike;
};
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, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
/**
* FNXC:DesktopRuntime 2026-07-05-00:00:
* Total attempts (including the first) for embedded startup. Field reports (FN-7617)
* show Windows first-launch embedded starts intermittently throw once (during store
* init/watch or dashboard-server boot) and then succeed immediately on a manual
* Retry (a renderer reload that re-invokes startLocal()). Default 3 total attempts
* so that self-heal happens inside the manager before the operator ever sees the
* "Couldn't start local Fusion" error screen. Only applies to the embedded-start
* path (never external-cli / already-running). Overridable so tests can drive
* deterministic attempt counts with zero delay.
*/
startupRetries?: number;
/** Delay between failed embedded-start attempts, in ms. Overridable (use 0 in tests). */
startupRetryDelayMs?: number;
}
const DEFAULT_STARTUP_RETRIES = 3;
const DEFAULT_STARTUP_RETRY_DELAY_MS = 150;
function delay(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
const { TaskStore } = await import("@fusion/core");
return new TaskStore(rootDir) as TaskStoreLike;
}
/*
FNXC:SystemPanel 2026-07-12-14:20:
Desktop restart support for the dashboard System panel. Electron owns the
process lifecycle, so "restart" = app.relaunch() + app.exit() (after a short
delay so the HTTP 202 flushes). Electron is resolved dynamically so this
module still loads under plain-node tests, where the electron package exports
a binary path instead of the runtime API — then systemControl is simply
omitted and the System panel disables its restart controls. Rebuild controls
never appear on desktop (no sourceWorkspaceRoot — nothing to rebuild).
Cross-reference: local-server.ts carries the matching wiring for the other
desktop startup path.
*/
export async function resolveDesktopSystemControl(): Promise<Record<string, unknown>> {
try {
const electron = (await import("electron")) as unknown as {
app?: { relaunch: () => void; exit: (code?: number) => void };
};
const electronApp = electron.app;
if (!electronApp || typeof electronApp.relaunch !== "function") return {};
return {
systemControl: {
supervised: true,
requestRestart: (_reason: string) => {
setTimeout(() => {
electronApp.relaunch();
electronApp.exit(0);
}, 300);
return true;
},
},
};
} catch {
return {};
}
}
async function createDashboardServerDefault(store: TaskStoreLike, rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> {
const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core");
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = 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 providerSeeding: { dispose?: () => void } = {};
const cleanup = async () => {
providerSeeding.dispose?.();
await engineManager.stopAll();
await centralCore.close?.();
};
try {
strace("createDashboardServer: centralCore.init");
await centralCore.init();
/*
* FNXC:DesktopRuntime 2026-07-03-03:30:
* Do NOT auto-register the home directory as a project. Start engines for whatever projects the
* operator has already onboarded (none on a fresh install), and only pick a default/primary engine
* when such a project exists. With zero projects the server starts engine-less and the dashboard
* shows its onboarding empty state; new projects register via POST /api/projects and their engines
* spin up lazily through onProjectFirstAccessed / reconciliation.
*/
void rootDir; // runtime root no longer implies a project; kept for signature/back-compat.
strace("createDashboardServer: startAll");
await engineManager.startAll();
strace("createDashboardServer: startAll DONE; startReconciliation");
engineManager.startReconciliation();
const rootProject = await resolveDesktopRuntimePrimaryProject(centralCore);
strace(`createDashboardServer: primaryProject=${rootProject?.id ?? "none"}`);
const primaryEngine = rootProject ? await engineManager.ensureEngine(rootProject.id) : undefined;
/*
* FNXC:DesktopRuntime 2026-07-07-00:00:
* FN-7622: wire an auth storage into the embedded server AND run it through the same
* registration sequence the CLI serve/dashboard/daemon commands use — built-in Zai/API-key
* provider seeding (registerBuiltInZaiProvider), wrapAuthStorageWithApiKeyProviders, and
* registerCustomProviders(globalSettings.customProviders) — via the shared
* @fusion/engine seedDashboardProviders() helper. Previously this path passed the RAW
* authStorage/modelRegistry straight to createServer and skipped that whole sequence, so
* desktop's Authentication page and model picker showed a truncated provider catalog
* (stock API-key providers and user customProviders[] missing) versus the identical config
* rendered by the web build. Passing the WRAPPED authStorage returned by
* seedDashboardProviders (not the raw one) closes that gap; the disposer unsubscribes the
* settings:updated -> reregisterCustomProviders listener on shutdown.
*/
const authStorage = createFusionAuthStorage();
// FNXC:DesktopRuntime 2026-07-03-07:00: a ModelRegistry is required for the /api/models endpoint;
// without it the onboarding model picker shows "no models" even with a provider connected.
const modelRegistry = createFusionModelRegistry(authStorage);
strace("createDashboardServer: seedDashboardProviders");
const { authStorage: wrappedAuthStorage, dispose } = await seedDashboardProviders({
store: store as never,
authStorage,
modelRegistry,
log: (scope, message) => strace(`[${scope}] ${message}`),
});
providerSeeding.dispose = dispose;
/*
* FNXC:DesktopRuntime 2026-07-07-12:00:
* FN-7623: mirror the CLI dashboard command's plugin wiring (packages/cli/src/commands/dashboard.ts)
* — construct the store's PluginStore, build a PluginLoader, load enabled plugins, and run schema-init
* hooks — so the desktop embedded server's registry sub-router mounts (GET /api/plugins/registry) and
* POST /api/plugins install mode works. Failures here must not crash embedded startup: the dashboard
* still needs to boot even if the plugin subsystem can't come up (e.g. a corrupt plugin manifest), so
* this is wrapped and traced rather than left to throw.
*
* FNXC:DesktopRuntime 2026-07-07-12:30:
* FN-7637: bundled-plugin auto-install (Dependency Graph, Hermes, OpenClaw, Paperclip, …) is now
* host-agnostic in @fusion/core's ensureBundledPluginInstalled. The only host-specific input is
* bundle-directory resolution: resolveDesktopBundlePluginDirs (./bundled-plugin-dirs.js) resolves
* each manifest id to its staged `@fusion-plugin-examples/<short-name>` package directory via
* import.meta.resolve, mirroring the CLI's `<cli>/dist/plugins/<id>` resolver
* (packages/cli/src/plugins/bundled-plugin-install.ts). Mirrors the CLI dashboard command's startup
* auto-install pass: install the bundled Dependency Graph plugin before loadAllPlugins() so it is
* enabled/registered before the general load pass runs, and expose the same lazy-install callback the
* CLI wires so PUT /api/plugins/:id/settings can auto-install Hermes/OpenClaw/Paperclip/etc. on first
* save. Cross-reference: local-server.ts carries the matching wiring for the other desktop startup path.
*/
let pluginStore: PluginStoreLike | undefined;
let pluginLoader: InstanceType<typeof PluginLoader> | undefined;
let ensureBundledPluginInstalledCallback: ((pluginId: string) => Promise<boolean>) | undefined;
try {
strace("createDashboardServer: pluginStore.init");
pluginStore = store.getPluginStore();
await pluginStore.init();
pluginLoader = new PluginLoader({ pluginStore: pluginStore as never, taskStore: store as never });
const boundPluginStore = pluginStore;
const boundPluginLoader = pluginLoader;
try {
strace("createDashboardServer: bundled dependency-graph auto-install");
const installStatus = await ensureBundledPluginInstalled(
boundPluginStore as never,
boundPluginLoader,
"fusion-plugin-dependency-graph",
resolveDesktopBundlePluginDirs,
);
strace(`createDashboardServer: bundled dependency-graph auto-install status=${installStatus}`);
} catch (error) {
strace(
`createDashboardServer: bundled dependency-graph auto-install FAILED (non-fatal) — ${error instanceof Error ? error.stack : String(error)}`,
);
}
strace("createDashboardServer: pluginLoader.loadAllPlugins");
const { loaded, errors } = await pluginLoader.loadAllPlugins();
strace(`createDashboardServer: plugins loaded=${loaded} errors=${errors}`);
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
}
ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
if (!isBundledPluginId(pluginId)) {
strace(`ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`);
return false;
}
try {
const status = await ensureBundledPluginInstalled(boundPluginStore as never, boundPluginLoader, pluginId, resolveDesktopBundlePluginDirs);
if (status === "missing-bundle") {
strace(`ensureBundledPluginInstalled: bundled plugin "${pluginId}" not found in this build`);
return false;
}
strace(`ensureBundledPluginInstalled: bundled plugin "${pluginId}" status=${status}`);
return true;
} catch (error) {
strace(
`ensureBundledPluginInstalled: failed to auto-install "${pluginId}" — ${error instanceof Error ? error.stack : String(error)}`,
);
throw error;
}
};
} catch (error) {
strace(
`createDashboardServer: plugin subsystem init FAILED (non-fatal, dashboard still boots) — ${error instanceof Error ? error.stack : String(error)}`,
);
pluginStore = undefined;
pluginLoader = undefined;
ensureBundledPluginInstalledCallback = undefined;
}
strace("createDashboardServer: createServer");
const app = createServer(store as never, {
...(primaryEngine ? { engine: primaryEngine } : {}),
engineManager,
centralCore,
authStorage: wrappedAuthStorage,
modelRegistry,
...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}),
...(ensureBundledPluginInstalledCallback ? { ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback } : {}),
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
...(await resolveDesktopSystemControl()),
});
strace("createDashboardServer: app.listen(0)");
const server = app.listen(0);
strace("createDashboardServer: returning server object");
return {
server,
cleanup,
};
} catch (error) {
strace(`createDashboardServer: THREW ${error instanceof Error ? error.stack : String(error)}`);
await cleanup();
throw error;
}
}
function parsePort(raw: string | undefined): number | undefined {
if (!raw) {
return undefined;
}
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
return undefined;
}
return value;
}
function getAddressPort(server: Server): number {
const address = server.address() as AddressInfo | null;
const port = address?.port;
if (!port) {
throw new Error("Failed to resolve local server port");
}
return port;
}
export class LocalRuntimeManager {
private runtime: RuntimeInstance | null = null;
private startupPromise: Promise<DesktopRuntimeStatus> | null = null;
private stopPromise: Promise<DesktopRuntimeStatus> | null = null;
private status: DesktopRuntimeStatus = { source: "none", state: "stopped" };
private readonly getExternalPort: () => number | undefined;
private readonly createStore: (rootDir: string) => Promise<TaskStoreLike>;
private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
private readonly startupRetries: number;
private readonly startupRetryDelayMs: number;
constructor(private readonly options: LocalRuntimeManagerOptions) {
this.getExternalPort = options.getExternalPort ?? (() => parsePort(process.env.FUSION_SERVER_PORT));
this.createStore = options.createStore ?? createStoreDefault;
this.createDashboardServer = options.createDashboardServer ?? createDashboardServerDefault;
this.startupRetries = Math.max(1, options.startupRetries ?? DEFAULT_STARTUP_RETRIES);
this.startupRetryDelayMs = options.startupRetryDelayMs ?? DEFAULT_STARTUP_RETRY_DELAY_MS;
}
getStatus(): DesktopRuntimeStatus {
if (this.runtime) {
return {
source: "embedded-local",
state: "running",
port: this.runtime.port,
baseUrl: this.runtime.baseUrl,
};
}
const externalPort = this.getExternalPort();
if (externalPort) {
this.status = {
source: "external-cli",
state: "running",
port: externalPort,
baseUrl: `http://127.0.0.1:${externalPort}`,
};
}
return this.status;
}
getServerPort(): number | undefined {
return this.getStatus().port;
}
async startLocal(): Promise<DesktopRuntimeStatus> {
strace("startLocal: ENTER");
const externalPort = this.getExternalPort();
if (externalPort) {
strace(`startLocal: external-cli branch (FUSION_SERVER_PORT=${externalPort}) — NOT starting embedded`);
this.status = {
source: "external-cli",
state: "running",
port: externalPort,
baseUrl: `http://127.0.0.1:${externalPort}`,
};
return this.status;
}
if (this.runtime) {
return this.getStatus();
}
if (this.startupPromise) {
return this.startupPromise;
}
this.status = { source: "embedded-local", state: "starting" };
this.startupPromise = this.startEmbedded();
try {
return await this.startupPromise;
} finally {
this.startupPromise = null;
}
}
/*
* FNXC:DesktopRuntime 2026-07-05-00:00:
* Windows first-launch field report (FN-7617): the embedded runtime start intermittently
* throws on its very first attempt (store init/watch or dashboard-server boot), but a
* manual Retry (a renderer reload that re-invokes startLocal()) always succeeds — proving
* the failure is transient rather than a hard misconfiguration. Rather than surface that
* transient to the renderer (which renders the scary "Couldn't start local Fusion"
* local-error phase in DesktopLaunchGate.tsx), retry the embedded attempt internally,
* bounded and with full cleanup between attempts, so a healthy install self-heals before
* the operator ever sees an error screen. `status.state` stays "starting" across retries;
* only the LAST attempt's real error sets state "error" and is thrown, so genuine failures
* (e.g. a bad dashboard import) still surface their real message unchanged.
*/
private async startEmbedded(): Promise<DesktopRuntimeStatus> {
let lastError: unknown;
for (let attempt = 1; attempt <= this.startupRetries; attempt++) {
strace(`startEmbedded: attempt ${attempt}/${this.startupRetries}`);
try {
return await this.startEmbeddedAttempt();
} catch (error) {
lastError = error;
strace(
`startEmbedded: attempt ${attempt}/${this.startupRetries} FAILED — ${error instanceof Error ? error.message : String(error)}`,
);
if (attempt < this.startupRetries) {
// Keep reporting "starting" while a retry is still pending — the operator/gate
// must not see "error" for a transient attempt that self-heals.
this.status = { source: "embedded-local", state: "starting" };
if (this.startupRetryDelayMs > 0) {
await delay(this.startupRetryDelayMs);
}
}
}
}
this.runtime = null;
this.status = {
source: "embedded-local",
state: "error",
error: lastError instanceof Error ? lastError.message : String(lastError),
};
strace(`startEmbedded: all ${this.startupRetries} attempts failed — surfacing final error`);
throw lastError;
}
private async startEmbeddedAttempt(): Promise<DesktopRuntimeStatus> {
let store: TaskStoreLike | null = null;
let server: Server | null = null;
let cleanup: RuntimeCleanup | undefined;
try {
strace(`startEmbedded: BEGIN rootDir=${this.options.rootDir}`);
store = await this.createStore(this.options.rootDir);
strace("startEmbedded: store.init");
await store.init();
strace("startEmbedded: store.watch");
await store.watch();
strace("startEmbedded: createDashboardServer()");
const dashboardServer = await this.createDashboardServer(store, this.options.rootDir);
cleanup = "server" in dashboardServer ? dashboardServer.cleanup : undefined;
server = "server" in dashboardServer ? dashboardServer.server : dashboardServer;
strace("startEmbedded: awaiting server 'listening' | 'error'");
await Promise.race([
once(server, "listening"),
once(server, "error").then(([error]) => {
throw error;
}),
]);
const port = getAddressPort(server);
const baseUrl = `http://127.0.0.1:${port}`;
this.runtime = { store, server, port, baseUrl, cleanup };
this.status = { source: "embedded-local", state: "running", port, baseUrl };
strace(`startEmbedded: RUNNING port=${port}`);
return this.status;
} catch (error) {
if (server) {
await new Promise<void>((resolve) => {
server!.close(() => resolve());
});
}
await cleanup?.();
if (store) {
store.close();
}
this.runtime = null;
strace(`startEmbedded: CATCH/ERROR ${error instanceof Error ? error.stack : String(error)}`);
throw error;
}
}
async stopLocal(): Promise<DesktopRuntimeStatus> {
if (this.stopPromise) {
return this.stopPromise;
}
this.stopPromise = this.stopInternal();
try {
return await this.stopPromise;
} finally {
this.stopPromise = null;
}
}
private async stopInternal(): Promise<DesktopRuntimeStatus> {
if (this.runtime) {
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;
}
if (this.getStatus().source === "external-cli") {
return this.status;
}
this.status = { source: "none", state: "stopped" };
return this.status;
}
}