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>
This commit is contained in:
gsxdsm
2026-07-12 13:32:34 -07:00
parent cbe07ee86b
commit a227b19a22
24 changed files with 2364 additions and 41 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Command Center System controls (rebuild & restart, engine/agent restarts, backups, live logs) and a Plugins tab.
category: feature
dev: New `/api/system/*` routes gated by `ServerOptions.systemControl`/`systemLogs`; `fn dashboard` is now supervised by default (attached foreground child, `--no-supervise` opts out) and restart uses `FUSION_RESTART_EXIT_CODE` (86) honored by the supervisor, `scripts/dev-with-memory.mjs`, and Electron `app.relaunch()` on desktop; rebuild controls only render from a source checkout; new Command Center "Plugins" tab reuses PluginManager.

View File

@@ -22,6 +22,7 @@
"smoke:boot": "node scripts/boot-smoke.mjs",
"local": "node scripts/start-local.mjs",
"dev": "node scripts/dev-with-memory.mjs",
"start": "node scripts/dev-with-memory.mjs",
"dev:ui": "pnpm --filter @fusion/dashboard dev",
"dev:hmr": "node scripts/dev-hmr.mjs",
"lint": "eslint .",

View File

@@ -52,6 +52,29 @@ describe("dev-with-memory prebuild options", () => {
expect(() => parseDevWrapperArgs(["--prebuild=", "dashboard"], {})).toThrow(/Invalid prebuild mode/);
});
it("defaults a bare invocation to the dashboard command with the dev host", () => {
// FNXC:DevWorkflow 2026-07-12-10:20: `pnpm dev`/`pnpm start` with no
// command must equal `pnpm dev dashboard` (prebuild + host injection).
expect(buildForwardedDevArgs([])).toEqual(["dashboard", "--host", "0.0.0.0"]);
});
it("defaults a flag-only invocation to the dashboard command, preserving flags", () => {
expect(buildForwardedDevArgs(["--paused"])).toEqual([
"dashboard",
"--paused",
"--host",
"0.0.0.0",
]);
});
it("leaves non-dashboard commands untouched", () => {
expect(buildForwardedDevArgs(["serve", "--port", "4050"])).toEqual([
"serve",
"--port",
"4050",
]);
});
it("does not inject a dev host when --host=value is already present", () => {
expect(buildForwardedDevArgs(["dashboard", "--host=127.0.0.1"])).toEqual([
"dashboard",

View File

@@ -478,7 +478,8 @@ Options:
--paused Start with engine paused (automation disabled)
--dev Start dashboard in development mode
--no-engine Start dashboard only (no AI engine)
--supervise Run with auto-restart on crash (bounded retries)
--supervise (default) Run with auto-restart on crash and System-panel restart support
--no-supervise Run the dashboard without the supervising parent process
--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)
@@ -833,7 +834,17 @@ async function main() {
const noAuth = args.includes("--no-auth");
const dashTokenIdx = args.indexOf("--token");
const token = dashTokenIdx !== -1 && dashTokenIdx + 1 < args.length ? args[dashTokenIdx + 1] : undefined;
const supervise = args.includes("--supervise");
/*
FNXC:SystemPanel 2026-07-12-14:10:
Supervision is the default for the dashboard (bare `fn`, `fusion`,
npx, packaged binary alike): a foreground parent respawns the child on
crash and on the System panel's intentional-restart exit code.
`--no-supervise` opts out; a child under an existing supervisor
(FUSION_RESTART_SUPERVISED=1, incl. `pnpm dev`) and inspector runs
never self-supervise. `--supervise` is kept as a no-op-compat flag.
*/
const { shouldSuperviseDashboard } = await import("./commands/dashboard.js");
const supervise = shouldSuperviseDashboard(args);
const dashLangIdx = args.indexOf("--lang");
const lang = dashLangIdx !== -1 && dashLangIdx + 1 < args.length ? args[dashLangIdx + 1] : undefined;
if (lang !== undefined) {

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { resolveSupervisorRespawnCommand, shouldSuperviseDashboard } from "../dashboard.js";
/*
FNXC:SystemPanel 2026-07-12-14:25:
Supervision must be the DEFAULT for the dashboard across install shapes (bare
`fn`/`fusion`, npx, packaged binary) so the System panel restart works out of
the box, while never nesting supervisors (FUSION_RESTART_SUPERVISED=1 set by
the supervisor itself and by scripts/dev-with-memory.mjs), never fighting an
attached debugger, and honoring the --no-supervise opt-out.
*/
describe("shouldSuperviseDashboard", () => {
it("defaults to supervised for a plain dashboard invocation", () => {
expect(shouldSuperviseDashboard(["dashboard"], {}, [])).toBe(true);
});
it("defaults to supervised for a bare invocation (no explicit command)", () => {
expect(shouldSuperviseDashboard([], {}, [])).toBe(true);
});
it("is disabled by --no-supervise", () => {
expect(shouldSuperviseDashboard(["dashboard", "--no-supervise"], {}, [])).toBe(false);
});
it("never nests: disabled when a supervising parent already exists", () => {
expect(shouldSuperviseDashboard(["dashboard"], { FUSION_RESTART_SUPERVISED: "1" }, [])).toBe(false);
});
it("is disabled when an inspector is attached (child would fight over the port)", () => {
expect(shouldSuperviseDashboard(["dashboard"], {}, ["--inspect=9230"])).toBe(false);
expect(shouldSuperviseDashboard(["dashboard"], {}, ["--inspect-brk"])).toBe(false);
});
});
describe("resolveSupervisorRespawnCommand", () => {
it("re-execs the node entry script with execArgv preserved outside a compiled binary", () => {
const respawn = resolveSupervisorRespawnCommand();
expect(respawn).not.toBeNull();
expect(respawn!.command).toBe(process.execPath);
// Under a plain-node/vitest run, argv[1] is the entry script and must be
// the last respawn arg, after any loader flags from execArgv.
expect(respawn!.args[respawn!.args.length - 1]).toBe(process.argv[1]);
expect(respawn!.args.slice(0, -1)).toEqual(process.execArgv);
});
});

View File

@@ -41,6 +41,30 @@ const { mockSuperviseSpawn } = vi.hoisted(() => ({
waitExit: vi.fn().mockResolvedValue({ code: 0, signal: null }),
})),
}));
/*
FNXC:SystemPanel 2026-07-12-14:35:
Fake attached child for runDashboardSupervised: the supervisor now uses a
plain node:child_process spawn (foreground, TUI-safe) instead of the detached
superviseSpawn, so tests mock spawn and complete the loop by emitting a clean
SIGINT close on a microtask (after the supervisor wires its close listener).
*/
const { mockSupervisorSpawn } = vi.hoisted(() => ({
mockSupervisorSpawn: vi.fn(() => {
const listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
const child = {
on(event: string, cb: (...args: unknown[]) => void) {
(listeners[event] ??= []).push(cb);
return child;
},
kill: () => true,
};
queueMicrotask(() => {
for (const cb of listeners["close"] ?? []) cb(null, "SIGINT");
});
return child;
}),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
@@ -332,6 +356,7 @@ vi.mock("node:child_process", async (importOriginal) => {
execSync: mockExecSync,
execFile: mockExecFile,
execFileSync: mockExecFileSync,
spawn: mockSupervisorSpawn,
};
});
@@ -3506,10 +3531,10 @@ describe("runDashboard update check wiring", () => {
describe("runDashboardSupervised — bounded restart behavior", () => {
beforeEach(() => {
mockSuperviseSpawn.mockClear();
mockSupervisorSpawn.mockClear();
});
it("spawns the dashboard without inheriting the supervisor flag or a lifetime cap", async () => {
it("spawns an attached child without the supervision flags and advertises the restart contract", async () => {
const mod = await import("../dashboard.js");
const originalArgv = process.argv;
process.argv = [
@@ -3529,14 +3554,18 @@ describe("runDashboardSupervised — bounded restart behavior", () => {
process.argv = originalArgv;
}
expect(mockSuperviseSpawn).toHaveBeenCalledWith(
expect(mockSupervisorSpawn).toHaveBeenCalledWith(
process.execPath,
["/tmp/fn-entry.mjs", "dashboard", "--host", "127.0.0.1", "--port", "4040"],
[...process.execArgv, "/tmp/fn-entry.mjs", "dashboard", "--host", "127.0.0.1", "--port", "4040"],
expect.objectContaining({
stdio: "inherit",
maxLifetimeMs: Number.POSITIVE_INFINITY,
env: expect.objectContaining({ FUSION_RESTART_SUPERVISED: "1" }),
}),
);
// Attached child (TUI-safe): the supervisor must NOT detach it into a
// background process group.
const spawnOptions = mockSupervisorSpawn.mock.calls[0]![2] as Record<string, unknown>;
expect(spawnOptions.detached).toBeUndefined();
});
it("preserves global flags before the dashboard subcommand without duplicating dashboard", async () => {
@@ -3550,7 +3579,7 @@ describe("runDashboardSupervised — bounded restart behavior", () => {
"dashboard",
"--port",
"4040",
"--supervise",
"--no-supervise",
];
try {
@@ -3559,12 +3588,12 @@ describe("runDashboardSupervised — bounded restart behavior", () => {
process.argv = originalArgv;
}
expect(mockSuperviseSpawn).toHaveBeenCalledWith(
expect(mockSupervisorSpawn).toHaveBeenCalledWith(
process.execPath,
["/tmp/fn-entry.mjs", "--project", "atlas-notes", "dashboard", "--port", "4040"],
[...process.execArgv, "/tmp/fn-entry.mjs", "--project", "atlas-notes", "dashboard", "--port", "4040"],
expect.objectContaining({
stdio: "inherit",
maxLifetimeMs: Number.POSITIVE_INFINITY,
env: expect.objectContaining({ FUSION_RESTART_SUPERVISED: "1" }),
}),
);
});

View File

@@ -315,3 +315,39 @@ describe("formatConsoleArgs", () => {
expect(typeof message).toBe("string");
});
});
/*
FNXC:SystemPanel 2026-07-12-12:10:
The sink's history + listener surface powers the dashboard System panel's
"View logs" tail; every log/warn/error must be recorded (with level and
prefix) in both TTY and headless modes, and unsubscribing must stop delivery.
*/
describe("DashboardLogSink system-log history", () => {
it("records entries with level and prefix and serves bounded recents", () => {
const sink = new DashboardLogSink();
sink.log("hello", "dashboard");
sink.warn("careful");
sink.error("boom", "engine");
const entries = sink.getRecentEntries();
expect(entries).toHaveLength(3);
expect(entries[0]).toMatchObject({ level: "info", message: "hello", prefix: "dashboard" });
expect(entries[1]).toMatchObject({ level: "warn", message: "careful" });
expect(entries[2]).toMatchObject({ level: "error", message: "boom", prefix: "engine" });
expect(sink.getRecentEntries(1)).toHaveLength(1);
expect(sink.getRecentEntries(1)[0].message).toBe("boom");
});
it("notifies subscribers live and stops after unsubscribe", () => {
const sink = new DashboardLogSink();
const seen: string[] = [];
const unsubscribe = sink.subscribeEntries((entry) => seen.push(entry.message));
sink.log("first");
unsubscribe();
sink.log("second");
expect(seen).toEqual(["first"]);
});
});

View File

@@ -1,4 +1,4 @@
import type { LogEntry } from "./log-ring-buffer.js";
import { LogRingBuffer, type LogEntry } from "./log-ring-buffer.js";
// ── formatConsoleArgs ─────────────────────────────────────────────────────────
@@ -71,6 +71,16 @@ export class DashboardLogSink {
private tui: LogSinkTarget | null = null;
private isTTY: boolean;
private silenced = false;
/*
FNXC:SystemPanel 2026-07-12-11:10:
The sink is the single funnel for runtime + captured-console logs in
`fn dashboard`, so it also keeps a bounded history and a listener set. The
dashboard server's System panel "View logs" surface reads/tails these via
the systemLogs ServerOptions provider — works in both TTY (TUI) and
headless modes because recording happens before TTY routing.
*/
private readonly history = new LogRingBuffer();
private readonly entryListeners = new Set<(entry: LogEntry) => void>();
private originalConsole: {
log: typeof console.log;
warn: typeof console.warn;
@@ -107,8 +117,35 @@ export class DashboardLogSink {
console.error = noop;
}
private record(level: LogEntry["level"], message: string, prefix?: string): void {
const entry: LogEntry = { timestamp: new Date(), level, message, prefix };
this.history.push(entry);
for (const listener of this.entryListeners) {
try {
listener(entry);
} catch {
// Listeners must never throw into logging flows.
}
}
}
/** Most recent log entries in chronological order (bounded by the ring buffer). */
getRecentEntries(limit = 500): LogEntry[] {
const all = this.history.getAll();
return limit >= all.length ? all : all.slice(-limit);
}
/** Subscribe to live log entries. Returns an unsubscribe function. */
subscribeEntries(listener: (entry: LogEntry) => void): () => void {
this.entryListeners.add(listener);
return () => {
this.entryListeners.delete(listener);
};
}
log(message: string, prefix?: string): void {
if (this.silenced) return;
this.record("info", message, prefix);
const line = prefix ? `[${prefix}] ${message}` : message;
if (this.tui && this.isTTY) {
this.tui.log(message, prefix);
@@ -121,6 +158,7 @@ export class DashboardLogSink {
warn(message: string, prefix?: string): void {
if (this.silenced) return;
this.record("warn", message, prefix);
const line = prefix ? `[${prefix}] ${message}` : message;
if (this.tui && this.isTTY) {
this.tui.warn(message, prefix);
@@ -133,6 +171,7 @@ export class DashboardLogSink {
error(message: string, prefix?: string): void {
if (this.silenced) return;
this.record("error", message, prefix);
const line = prefix ? `[${prefix}] ${message}` : message;
if (this.tui && this.isTTY) {
this.tui.error(message, prefix);

View File

@@ -1,8 +1,10 @@
import type { AddressInfo } from "node:net";
import { join, resolve as pathResolve } from "node:path";
import { execFile as execFileCb } from "node:child_process";
import { execFile as execFileCb, spawn, type ChildProcess } from "node:child_process";
import { promisify } from "node:util";
import { stat, readdir, readFile as fsReadFile } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
TaskStore,
AutomationStore,
@@ -28,8 +30,7 @@ import {
registerBuiltInZaiProvider,
type WorkflowIrColumn,
type TraitFlags,
superviseSpawn,
type SupervisedChild,
FUSION_RESTART_EXIT_CODE,
} from "@fusion/core";
import {
createServer,
@@ -1103,6 +1104,24 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
let disposed = false;
let shutdownInProgress = false;
/*
FNXC:SystemPanel 2026-07-12-11:00:
In-place restart support for the dashboard System panel. A restart request
flips the shutdown exit code from 0 to FUSION_RESTART_EXIT_CODE so the
graceful-shutdown path (including the hard-exit watchdog and second-signal
escape hatch) exits with the restart code and a supervising parent
(`--supervise` loop or scripts/dev-with-memory.mjs) respawns the process.
`requestSelfRestart` is late-bound because createServer options are built
before the shutdown closure exists.
*/
let shutdownExitCode = 0;
let requestSelfRestart: ((reason: string) => boolean) | null = null;
const systemControlForServer = {
supervised: process.env.FUSION_RESTART_SUPERVISED === "1",
requestRestart: (reason: string) => (requestSelfRestart ? requestSelfRestart(reason) : false),
sourceWorkspaceRoot: resolveFusionSourceWorkspaceRoot(),
};
/*
* FNXC:DashboardShutdown 2026-06-27-10:32:
* Pressing `q`/Ctrl+C in the TUI routes through SIGINT so the graceful shutdown
@@ -1143,7 +1162,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
`fusion: graceful shutdown stalled on "${currentShutdownStep}" after ${SHUTDOWN_HARD_EXIT_GRACE_MS}ms — forcing exit\n`,
);
}
process.exit(0);
process.exit(shutdownExitCode);
}, SHUTDOWN_HARD_EXIT_GRACE_MS).unref();
}
async function timeShutdownStep(label: string, fn: () => Promise<void> | void): Promise<void> {
@@ -2080,12 +2099,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
noAuth: opts.noAuth,
runtimeLogger,
systemControl: systemControlForServer,
systemLogs: {
getRecent: (limit?: number) => logSink.getRecentEntries(limit),
subscribe: (listener) => logSink.subscribeEntries(listener),
},
});
const shutdown = async (signal: NodeJS.Signals) => {
// Second signal (user mashing q/Ctrl+C because the first didn't exit) —
// force an immediate exit rather than being swallowed by the guard.
if (shutdownInProgress) process.exit(0);
if (shutdownInProgress) process.exit(shutdownExitCode);
shutdownInProgress = true;
armHardExitWatchdog();
@@ -2143,7 +2167,22 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
);
store.close();
process.exit(0);
process.exit(shutdownExitCode);
};
/*
FNXC:SystemPanel 2026-07-12-11:00:
Bind the System panel restart request to the real graceful-shutdown path.
The short delay lets the HTTP 202 response flush before teardown starts.
Restart is only honored when a supervising parent will respawn us.
*/
requestSelfRestart = (reason: string) => {
if (!systemControlForServer.supervised || shutdownInProgress) return false;
logSink.log(`restart requested (${reason}) — shutting down for supervised respawn`, "dashboard");
shutdownExitCode = FUSION_RESTART_EXIT_CODE;
setTimeout(() => {
void shutdown("SIGTERM");
}, 300);
return true;
};
registerHandler(process, "SIGINT", () => void shutdown("SIGINT"));
registerHandler(process, "SIGTERM", () => void shutdown("SIGTERM"));
@@ -2396,6 +2435,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
noAuth: opts.noAuth,
runtimeLogger,
systemControl: systemControlForServer,
systemLogs: {
getRecent: (limit?: number) => logSink.getRecentEntries(limit),
subscribe: (listener) => logSink.subscribeEntries(listener),
},
});
}
@@ -2404,7 +2448,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const devShutdown = async (signal: NodeJS.Signals) => {
// Second signal (user mashing q/Ctrl+C because the first didn't exit) —
// force an immediate exit rather than being swallowed by the guard.
if (shutdownInProgress) process.exit(0);
if (shutdownInProgress) process.exit(shutdownExitCode);
shutdownInProgress = true;
armHardExitWatchdog();
@@ -2458,7 +2502,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
store.close();
process.exit(0);
process.exit(shutdownExitCode);
};
// FNXC:SystemPanel 2026-07-12-11:00: System panel restart binding for
// UI-only mode — same contract as the engine-mode shutdown above.
requestSelfRestart = (reason: string) => {
if (!systemControlForServer.supervised || shutdownInProgress) return false;
logSink.log(`restart requested (${reason}) — shutting down for supervised respawn`, "dashboard");
shutdownExitCode = FUSION_RESTART_EXIT_CODE;
setTimeout(() => {
void devShutdown("SIGTERM");
}, 300);
return true;
};
registerHandler(process, "SIGINT", () => void devShutdown("SIGINT"));
registerHandler(process, "SIGTERM", () => void devShutdown("SIGTERM"));
@@ -3156,6 +3211,41 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
return { dispose };
}
// ── System Panel Support ─────────────────────────────────────────────────────
/*
FNXC:SystemPanel 2026-07-12-11:05:
"Rebuild & restart" in the dashboard System panel only makes sense when the
running CLI comes from a Fusion source checkout (where `pnpm build` /
scripts/*.mjs exist). Resolve the workspace root by walking up from this
module and requiring BOTH pnpm-workspace.yaml AND package.json name
"fusion-workspace" — the name guard prevents a globally-installed
@runfusion/fusion nested under some unrelated pnpm workspace from being
misdetected as a rebuildable checkout. Returns undefined for packaged
installs, which disables rebuild controls in the UI.
*/
export function resolveFusionSourceWorkspaceRoot(): string | undefined {
try {
let dir = pathResolve(fileURLToPath(import.meta.url), "..");
for (let i = 0; i < 10; i++) {
if (existsSync(join(dir, "pnpm-workspace.yaml"))) {
try {
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { name?: string };
return pkg?.name === "fusion-workspace" ? dir : undefined;
} catch {
return undefined;
}
}
const parent = pathResolve(dir, "..");
if (parent === dir) return undefined;
dir = parent;
}
} catch {
// Best-effort detection only — never let it break startup.
}
return undefined;
}
// ── Supervised Dashboard Mode ────────────────────────────────────────────────
const SUPERVISE_MAX_RESTARTS = 3;
@@ -3163,6 +3253,59 @@ const SUPERVISE_BASE_DELAY_MS = 2_000;
const SUPERVISE_MAX_DELAY_MS = 16_000;
const SUPERVISE_STALE_RESET_MS = 60_000;
/** True when running inside a bun-compiled single-file `fn` binary. */
function isCompiledBinary(): boolean {
const bun = (globalThis as { Bun?: { embeddedFiles?: unknown } }).Bun;
return typeof bun !== "undefined" && Boolean(bun.embeddedFiles);
}
/*
FNXC:SystemPanel 2026-07-12-14:05:
How the supervisor respawns "itself", per install shape:
- node script (npx / global npm install / `pnpm dev` source run): re-exec
process.execPath with execArgv preserved (tsx loader flags under source
runs) plus the argv[1] entry script.
- bun-compiled packaged binary (`fn`/`fn.exe` from build:exe): the binary IS
the program; argv[1] is Bun's virtual embedded path, so re-exec
process.execPath alone.
Returns null when no respawn command can be determined (then supervision is
skipped and the dashboard runs unsupervised).
*/
export function resolveSupervisorRespawnCommand(): { command: string; args: string[] } | null {
if (isCompiledBinary()) {
return { command: process.execPath, args: [] };
}
const entryPoint = process.argv[1];
if (!entryPoint) return null;
return { command: process.execPath, args: [...process.execArgv, entryPoint] };
}
/*
FNXC:SystemPanel 2026-07-12-14:05:
Supervision decision for `fn dashboard` (and bare `fn`, which defaults to the
dashboard). Supervision is now the DEFAULT so every install shape — bare `fn`,
`fusion`, npx, packaged binary — supports the System panel's in-place restart
and gets crash recovery. Skipped when:
- --no-supervise is passed (explicit opt-out; also the escape hatch for
debugging the child directly),
- FUSION_RESTART_SUPERVISED=1 (a supervising parent already exists — the
supervisor's own child, or scripts/dev-with-memory.mjs under `pnpm dev` —
so never nest supervisors),
- an inspector flag is active (the debugger must attach to the real app
process, and a respawned child would fight over the inspector port),
- no respawn command can be resolved.
*/
export function shouldSuperviseDashboard(
args: readonly string[],
env: NodeJS.ProcessEnv = process.env,
execArgv: readonly string[] = process.execArgv,
): boolean {
if (args.includes("--no-supervise")) return false;
if (env.FUSION_RESTART_SUPERVISED === "1") return false;
if (execArgv.some((arg) => arg.startsWith("--inspect"))) return false;
return resolveSupervisorRespawnCommand() !== null;
}
/**
* Run the dashboard under foreground process supervision with bounded restart
* attempts and exponential backoff.
@@ -3176,6 +3319,17 @@ const SUPERVISE_STALE_RESET_MS = 60_000;
* supervisor restarts up to SUPERVISE_MAX_RESTARTS times with exponential
* backoff. Clean exits (SIGINT/SIGTERM/exit 0) propagate without restart.
*
* FNXC:SystemPanel 2026-07-12-14:05:
* The child is spawned ATTACHED (same foreground process group, stdio
* inherited) — NOT via superviseSpawn's detached process group — because the
* interactive TUI must own the terminal: a background-process-group child
* reading a TTY gets SIGTTIN/SIGTTOU-stopped, which is why detached
* supervision was headless-only. Attached means terminal Ctrl+C reaches the
* child directly; the parent ignores SIGINT (waiting for the child's graceful
* exit) and forwards direct SIGTERM kills to the child. Exit code
* FUSION_RESTART_EXIT_CODE is an operator-requested restart (System panel):
* immediate respawn, no crash budget consumed.
*
* This does NOT use shell detachment wrappers, shell kill loops, or unbounded retries.
* Port 4040 processes are never killed — the child binds its own port.
*/
@@ -3183,41 +3337,62 @@ export async function runDashboardSupervised(
port: number,
_opts: Parameters<typeof runDashboard>[1] = {},
): Promise<void> {
// Reconstruct child args: same entry point, same flags, minus --supervise
const childArgs = process.argv.slice(2).filter((a) => a !== "--supervise");
// Reconstruct child args: same flags, minus the supervision flags.
const childArgs = process.argv.slice(2).filter((a) => a !== "--supervise" && a !== "--no-supervise");
// Ensure "dashboard" is present without duplicating it after global flags.
if (!childArgs.includes("dashboard")) {
const firstOptionIndex = childArgs.findIndex((arg) => arg.startsWith("-"));
childArgs.splice(firstOptionIndex === -1 ? 0 : firstOptionIndex, 0, "dashboard");
}
const entryPoint = process.argv[1];
if (!entryPoint) {
const respawn = resolveSupervisorRespawnCommand();
if (!respawn) {
console.error("[dashboard:supervisor] cannot determine entry point for child process");
process.exit(1);
}
let restartCount = 0;
let lastExitTime = 0;
const restartCommand = formatSupervisorRestartCommand(process.execPath, entryPoint, childArgs);
const restartCommand = formatSupervisorRestartCommand(respawn.command, respawn.args, childArgs);
let activeChild: ReturnType<typeof spawnAttached> | null = null;
// Parent lifecycle: terminal Ctrl+C (SIGINT) already reaches the attached
// child via the shared foreground process group, so the parent just waits
// for the child's graceful exit. A direct SIGTERM to the parent (process
// managers, `kill`) is forwarded so the child shuts down too. If the parent
// dies unexpectedly, best-effort kill the child on exit.
process.on("SIGINT", () => {
/* child receives terminal SIGINT directly; wait for its exit */
});
process.on("SIGTERM", () => {
try {
activeChild?.child.kill("SIGTERM");
} catch {
// Child may already be gone.
}
});
process.on("exit", () => {
try {
activeChild?.child.kill("SIGTERM");
} catch {
// Child may already be gone.
}
});
while (true) {
const attemptLabel = `${restartCount + 1}/${SUPERVISE_MAX_RESTARTS + 1}`;
console.log(`[dashboard:supervisor] starting dashboard (attempt ${attemptLabel})`);
let child: SupervisedChild;
try {
child = superviseSpawn(process.execPath, [entryPoint, ...childArgs], {
stdio: "inherit",
maxLifetimeMs: Number.POSITIVE_INFINITY,
});
activeChild = spawnAttached(respawn.command, [...respawn.args, ...childArgs]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[dashboard:supervisor] failed to spawn child: ${message}`);
process.exit(1);
}
const exitResult = await child.waitExit();
const exitResult = await activeChild.waitExit;
activeChild = null;
const exitCode = exitResult.code ?? 1;
const exitSignal = exitResult.signal;
@@ -3226,6 +3401,19 @@ export async function runDashboardSupervised(
return;
}
/*
FNXC:SystemPanel 2026-07-12-10:50:
Operator-requested restart (dashboard System panel). Respawn immediately
and reset the crash budget — an intentional restart must never consume
SUPERVISE_MAX_RESTARTS or incur crash backoff.
*/
if (exitCode === FUSION_RESTART_EXIT_CODE) {
console.log("[dashboard:supervisor] restart requested — restarting now");
restartCount = 0;
lastExitTime = 0;
continue;
}
// Reset restart counter if the child ran for a long time
const now = Date.now();
if (now - lastExitTime > SUPERVISE_STALE_RESET_MS) {
@@ -3257,8 +3445,33 @@ export async function runDashboardSupervised(
}
}
function formatSupervisorRestartCommand(nodePath: string, entryPoint: string, childArgs: readonly string[]): string {
return [nodePath, entryPoint, ...childArgs].map(quoteShellArg).join(" ");
interface AttachedChildExit {
code: number | null;
signal: NodeJS.Signals | null;
}
/*
FNXC:SystemPanel 2026-07-12-14:05:
Attached (non-detached) supervised spawn: the child shares the parent's
foreground process group so the interactive TUI keeps terminal ownership.
Deliberately NOT superviseSpawn (its detached process group is what made the
supervised TUI unusable on a TTY); parent-death cleanup is handled by the
supervisor's own exit/SIGTERM handlers.
*/
function spawnAttached(command: string, args: string[]): { child: ChildProcess; waitExit: Promise<AttachedChildExit> } {
const child = spawn(command, args, {
stdio: "inherit",
env: { ...process.env, FUSION_RESTART_SUPERVISED: "1" },
});
const waitExit = new Promise<AttachedChildExit>((resolve) => {
child.on("close", (code, signal) => resolve({ code, signal }));
child.on("error", () => resolve({ code: 1, signal: null }));
});
return { child, waitExit };
}
function formatSupervisorRestartCommand(command: string, respawnArgs: readonly string[], childArgs: readonly string[]): string {
return [command, ...respawnArgs, ...childArgs].map(quoteShellArg).join(" ");
}
function quoteShellArg(value: string): string {

View File

@@ -880,7 +880,7 @@ export {
readProjectIdentity,
writeProjectIdentity,
} from "./project-identity.js";
export { ProcessSupervisor, superviseSpawn } from "./process-supervisor.js";
export { ProcessSupervisor, superviseSpawn, FUSION_RESTART_EXIT_CODE } from "./process-supervisor.js";
export type {
SuperviseSpawnOptions,
SupervisedChild,

View File

@@ -2,6 +2,20 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
import { createLogger } from "./logger.js";
const log = createLogger("process-supervisor");
/*
FNXC:SystemPanel 2026-07-12-10:40:
Exit code contract for operator-requested in-place restarts (dashboard System
panel "Restart"/"Rebuild & restart"). A supervised fusion process exits with
this code to signal "respawn me immediately"; supervisors (`fn dashboard
--supervise`'s runDashboardSupervised loop and scripts/dev-with-memory.mjs,
which hardcodes 86 because plain .mjs cannot import TS) treat it as an
intentional restart — no crash-backoff, no restart-budget consumption. Any
other non-zero exit remains a crash. Keep the literal in sync with
scripts/dev-with-memory.mjs.
*/
export const FUSION_RESTART_EXIT_CODE = 86;
const DEFAULT_KILL_GRACE_MS = 2_000;
const DEFAULT_MAX_LIFETIME_MS = 600_000;
const MAX_KILL_WAIT_MS = 1_000;

View File

@@ -11380,3 +11380,112 @@ export interface ResearchStatsResponse {
export function getResearchStats(projectId?: string): Promise<ResearchStatsResponse> {
return api<ResearchStatsResponse>(withProjectId("/research/stats", projectId));
}
// ── System Panel (Command Center → System) ──────────────────────────────────
/*
FNXC:SystemPanel 2026-07-12-11:35:
Typed client for the /api/system operator controls: capability discovery,
in-place restart, rebuild jobs with streamed output, engine/agent restarts,
plugin reload, and the host-process log viewer.
*/
export interface SystemRebuildJobSnapshot {
id: string;
kind: "rebuild";
scope: "app" | "full" | "plugins";
restartAfter: boolean;
status: "running" | "succeeded" | "failed";
startedAt: number;
finishedAt?: number;
exitCode?: number | null;
error?: string;
restartScheduled?: boolean;
pluginsReloaded?: string[];
droppedLines: number;
lineCount: number;
lines?: SystemRebuildJobLine[];
}
export interface SystemRebuildJobLine {
i: number;
ts: number;
stream: "stdout" | "stderr" | "system";
text: string;
}
export interface SystemInfoResponse {
supervised: boolean;
restartSupported: boolean;
rebuildSupported: boolean;
sourceWorkspaceRoot?: string;
logsSupported: boolean;
engineAvailable: boolean;
pluginReloadSupported: boolean;
pid: number;
uptimeSeconds: number;
nodeVersion: string;
platform: string;
arch: string;
memoryRssBytes: number;
activeRebuild: SystemRebuildJobSnapshot | null;
lastRebuild: SystemRebuildJobSnapshot | null;
}
export interface SystemLogEntryDto {
timestamp: string;
level: "info" | "warn" | "error";
message: string;
prefix?: string;
}
export function fetchSystemInfo(): Promise<SystemInfoResponse> {
return api<SystemInfoResponse>("/system/info");
}
export function requestSystemRestart(reason?: string): Promise<{ scheduled: boolean }> {
return api<{ scheduled: boolean }>("/system/restart", {
method: "POST",
body: JSON.stringify({ reason }),
});
}
export function startSystemRebuild(
scope: "app" | "full" | "plugins",
restart?: boolean,
): Promise<SystemRebuildJobSnapshot> {
return api<SystemRebuildJobSnapshot>("/system/rebuild", {
method: "POST",
body: JSON.stringify({ scope, restart }),
});
}
export function fetchCurrentSystemRebuild(): Promise<{ job: SystemRebuildJobSnapshot | null }> {
return api<{ job: SystemRebuildJobSnapshot | null }>("/system/rebuild/current");
}
export function restartSystemEngines(): Promise<{
restarted: string[];
failed: Array<{ projectId: string; error: string }>;
}> {
return api("/system/engine/restart", { method: "POST" });
}
export function restartAllSystemAgents(projectId?: string): Promise<{
restarted: string[];
failed: Array<{ agentId: string; error: string }>;
}> {
return api(withProjectId("/system/agents/restart-all", projectId), { method: "POST" });
}
export function reloadAllSystemPlugins(): Promise<{
reloaded: string[];
failed: Array<{ id: string; error: string }>;
}> {
return api("/system/plugins/reload-all", { method: "POST" });
}
export function fetchSystemLogs(limit?: number): Promise<{ entries: SystemLogEntryDto[] }> {
const suffix = limit ? `?limit=${limit}` : "";
return api<{ entries: SystemLogEntryDto[] }>(`/system/logs${suffix}`);
}

View File

@@ -16,6 +16,8 @@ import { GithubArea } from "./areas/GithubArea";
import { GitlabArea } from "./areas/GitlabArea";
import { SignalsArea } from "./areas/SignalsArea";
import { SystemStatsArea } from "./areas/SystemStatsArea";
import { SystemControlsArea } from "./areas/SystemControlsArea";
import { PluginManager } from "../PluginManager";
import { MissionControlPanel } from "./MissionControlPanel";
import { CommandCenterControls } from "./CommandCenterControls";
import { ReliabilityView } from "../ReliabilityView";
@@ -44,6 +46,7 @@ type SubViewId =
| "gitlab"
| "signals"
| "system"
| "plugins"
| "nodes"
| "reliability"
| "mission-control";
@@ -87,6 +90,7 @@ function useSubViews(nodesEnabled: boolean): SubView[] {
{ id: "gitlab", label: t("commandCenter.tabs.gitlab", "GitLab") },
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
{ id: "system", label: t("commandCenter.tabs.system", "System") },
{ id: "plugins", label: t("commandCenter.tabs.plugins", "Plugins") },
...(nodesEnabled ? [{ id: "nodes" as const, label: t("commandCenter.tabs.nodes", "Nodes") }] : []),
{ id: "reliability", label: t("commandCenter.tabs.reliability", "Reliability") },
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
@@ -583,7 +587,28 @@ export function CommandCenter({
case "signals":
return <SignalsArea range={range} projectId={projectId} />;
case "system":
return <SystemStatsArea />;
/*
FNXC:SystemPanel 2026-07-12-11:55:
The System tab is the operator's debug home: system controls
(rebuild/restart/backup/logs/report-bug) render above the existing
runtime-metrics telemetry, per the requirement that these controls
live "on the dashboard under System where we have runtime metrics".
*/
return (
<>
<SystemControlsArea projectId={projectId} addToast={addToast} />
<SystemStatsArea />
</>
);
case "plugins":
/*
FNXC:SystemPanel 2026-07-12-11:55:
Plugins is a first-class Command Center tab: installed plugins first,
then available (registry/builtin) plugins — reusing PluginManager
unchanged (same precedent as the Reliability and Nodes tabs) so a
future server-hosted plugin browser can extend the same surface.
*/
return <PluginManager addToast={addToast ?? (() => {})} projectId={projectId} />;
case "nodes":
return <NodesView addToast={addToast} />;
case "reliability":

View File

@@ -1029,8 +1029,8 @@ describe("CommandCenter shell", () => {
render(<CommandCenter />);
const tablist = screen.getByRole("tablist");
const tabs = within(tablist).getAllByRole("tab");
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, GitLab, Signals, System, Reliability, Mission Control.
expect(tabs.length).toBe(14);
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, GitLab, Signals, System, Plugins, Reliability, Mission Control.
expect(tabs.length).toBe(15);
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
// roving tabindex: exactly one tab is focusable.
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
@@ -1260,9 +1260,15 @@ describe("CommandCenter shell", () => {
expect(tokensTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(tokensTab);
// FNXC:SystemPanel 2026-07-12-12:20: Plugins sits between System and Nodes.
const systemTab = screen.getByTestId("command-center-tab-system");
systemTab.focus();
fireEvent.keyDown(systemTab, { key: "ArrowRight" });
const pluginsTab = screen.getByTestId("command-center-tab-plugins");
expect(pluginsTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(pluginsTab);
fireEvent.keyDown(pluginsTab, { key: "ArrowRight" });
const nodesTab = screen.getByTestId("command-center-tab-nodes");
expect(nodesTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(nodesTab);

View File

@@ -0,0 +1,132 @@
/*
FNXC:SystemPanel 2026-07-12-11:50:
Operator System-controls panel styling for the Command Center System tab.
Follows the tokenized cc-* rhythm (space/radius/surface vars) so control cards
sit visually beside the SystemStatsArea telemetry cards. The rebuild output and
log tail use a terminal-style scroll box bounded in height so streaming output
never grows the page; the page must not scroll horizontally (pre wraps).
*/
.cc-syscontrols-grid {
min-inline-size: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
gap: var(--space-md);
}
.cc-syscontrol-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
}
.cc-syscontrol-head {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.cc-syscontrol-icon {
color: var(--text-muted);
flex: 0 0 auto;
}
.cc-syscontrol-title {
font-weight: 600;
}
.cc-syscontrol-desc {
margin: 0;
color: var(--text-muted);
font-size: 0.8125rem;
flex: 1 1 auto;
}
.cc-syscontrol-note {
margin: 0;
color: var(--text-muted);
font-size: 0.75rem;
font-style: italic;
}
.cc-syscontrol-cta {
align-self: flex-start;
}
.cc-syscontrols-banner {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin-block-end: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-2, var(--surface));
}
.cc-syscontrols-banner--back {
justify-content: space-between;
}
.cc-syscontrols-job-status {
font-size: 0.8125rem;
color: var(--text-muted);
}
.cc-syscontrols-job-status--succeeded {
color: var(--color-success);
}
.cc-syscontrols-job-status--failed {
color: var(--danger, #f87171);
}
.cc-syscontrols-output {
margin: 0;
padding: var(--space-md);
max-block-size: 20rem;
overflow-y: auto;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-2, var(--surface));
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.cc-syscontrols-logs {
display: flex;
flex-direction: column;
}
.cc-syscontrols-log-line {
display: flex;
gap: var(--space-sm);
min-inline-size: 0;
}
.cc-syscontrols-log-line--warn .cc-syscontrols-log-message {
color: var(--warning, #facc15);
}
.cc-syscontrols-log-line--error .cc-syscontrols-log-message {
color: var(--danger, #f87171);
}
.cc-syscontrols-log-time {
color: var(--text-muted);
flex: 0 0 auto;
}
.cc-syscontrols-log-prefix {
color: var(--text-muted);
flex: 0 0 auto;
}
.cc-syscontrols-log-message {
word-break: break-word;
}

View File

@@ -0,0 +1,643 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Blocks,
Bot,
Bug,
Copy,
Database,
Hammer,
Package,
Power,
RefreshCw,
ScrollText,
Cpu,
} from "lucide-react";
import {
createBackup,
fetchDashboardHealth,
fetchSystemInfo,
fetchSystemLogs,
reloadAllSystemPlugins,
requestSystemRestart,
restartAllSystemAgents,
restartSystemEngines,
startSystemRebuild,
type SystemInfoResponse,
type SystemLogEntryDto,
type SystemRebuildJobLine,
type SystemRebuildJobSnapshot,
} from "../../../api/legacy";
import { subscribeSse } from "../../../sse-bus";
import type { ToastType } from "../../../hooks/useToast";
import "./SystemControlsArea.css";
/*
FNXC:SystemPanel 2026-07-12-11:45:
Operator controls for the Command Center → System tab, rendered above the
runtime-metrics area. Requirements this encodes:
- "Rebuild & restart" must run the build in the server process, stream its
output live into this panel, restart the server seamlessly, and show when
the dashboard is back and ready (we poll /system/info until the PID
changes, then offer/auto reload).
- Controls degrade honestly: capabilities come from GET /system/info, and a
control that the host process can't honor (not supervised, not a source
checkout, no engine) renders disabled with the reason.
- Additional debug controls: restart server only, restart project engines,
restart all active agents, backup the database, rebuild+reload plugins,
live server log tail, report a bug (prefilled GitHub issue), and copy a
diagnostics bundle.
*/
const LOG_VIEW_CAP = 500;
const RESTART_POLL_MS = 1500;
const BACK_ONLINE_RELOAD_DELAY_MS = 3000;
const BUG_URL_BODY_CAP = 5500;
const GITHUB_NEW_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
type RestartPhase = null | "waiting" | "back";
interface SystemControlsAreaProps {
projectId?: string;
addToast?: (message: string, type?: ToastType) => void;
}
function formatLogTimestamp(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString();
}
export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaProps) {
const { t } = useTranslation("app");
const toast = useCallback(
(message: string, type?: ToastType) => addToast?.(message, type),
[addToast],
);
const [info, setInfo] = useState<SystemInfoResponse | null>(null);
const [infoError, setInfoError] = useState<string | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [job, setJob] = useState<SystemRebuildJobSnapshot | null>(null);
const [jobLines, setJobLines] = useState<SystemRebuildJobLine[]>([]);
const jobOutputRef = useRef<HTMLPreElement | null>(null);
const [restartPhase, setRestartPhase] = useState<RestartPhase>(null);
const prevPidRef = useRef<number | null>(null);
const [logsOpen, setLogsOpen] = useState(false);
const [logEntries, setLogEntries] = useState<SystemLogEntryDto[]>([]);
const logOutputRef = useRef<HTMLDivElement | null>(null);
const loadInfo = useCallback(async () => {
try {
const next = await fetchSystemInfo();
setInfo(next);
setInfoError(null);
if (next.activeRebuild) {
setJob((current) => (current && current.id === next.activeRebuild!.id ? current : next.activeRebuild));
}
return next;
} catch (err) {
setInfoError(err instanceof Error ? err.message : String(err));
return null;
}
}, []);
useEffect(() => {
void loadInfo();
}, [loadInfo]);
// ── Rebuild job output streaming ──────────────────────────────────────────
useEffect(() => {
if (!job || job.status !== "running") return;
const unsubscribe = subscribeSse(`/api/system/jobs/${job.id}/stream`, {
events: {
line: (event) => {
try {
const line = JSON.parse((event as MessageEvent).data) as SystemRebuildJobLine;
setJobLines((current) => {
if (current.some((existing) => existing.i === line.i)) return current;
return [...current, line];
});
} catch {
// Ignore malformed stream payloads.
}
},
end: (event) => {
try {
const snapshot = JSON.parse((event as MessageEvent).data) as SystemRebuildJobSnapshot;
setJob(snapshot);
if (snapshot.status === "succeeded" && snapshot.restartScheduled) {
prevPidRef.current = info?.pid ?? null;
setRestartPhase("waiting");
} else if (snapshot.status === "succeeded") {
toast(t("systemControls.rebuildSucceeded", "Rebuild finished successfully"), "success");
} else {
toast(t("systemControls.rebuildFailed", "Rebuild failed — see output for details"), "error");
}
} catch {
// Ignore malformed stream payloads.
}
},
},
});
return unsubscribe;
}, [job?.id, job?.status, info?.pid, t, toast]);
useEffect(() => {
const el = jobOutputRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [jobLines]);
// ── Restart wait loop: server is back when /system/info answers with a new PID ──
useEffect(() => {
if (restartPhase !== "waiting") return;
let cancelled = false;
const timer = setInterval(() => {
void (async () => {
try {
const next = await fetchSystemInfo();
if (cancelled) return;
if (prevPidRef.current === null || next.pid !== prevPidRef.current) {
setInfo(next);
setRestartPhase("back");
}
} catch {
// Server still restarting — keep polling.
}
})();
}, RESTART_POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [restartPhase]);
useEffect(() => {
if (restartPhase !== "back") return;
const timer = setTimeout(() => window.location.reload(), BACK_ONLINE_RELOAD_DELAY_MS);
return () => clearTimeout(timer);
}, [restartPhase]);
// ── Live server log tail ──────────────────────────────────────────────────
useEffect(() => {
if (!logsOpen || !info?.logsSupported) return;
let unsubscribed = false;
void fetchSystemLogs(LOG_VIEW_CAP)
.then((response) => {
if (!unsubscribed) setLogEntries(response.entries);
})
.catch(() => undefined);
const unsubscribe = subscribeSse("/api/system/logs/stream", {
events: {
log: (event) => {
try {
const entry = JSON.parse((event as MessageEvent).data) as SystemLogEntryDto;
setLogEntries((current) => {
const next = [...current, entry];
return next.length > LOG_VIEW_CAP ? next.slice(-LOG_VIEW_CAP) : next;
});
} catch {
// Ignore malformed stream payloads.
}
},
},
});
return () => {
unsubscribed = true;
unsubscribe();
};
}, [logsOpen, info?.logsSupported]);
useEffect(() => {
const el = logOutputRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [logEntries]);
// ── Actions ───────────────────────────────────────────────────────────────
const runAction = useCallback(
async (key: string, action: () => Promise<void>) => {
if (busyAction) return;
setBusyAction(key);
try {
await action();
} catch (err) {
toast(err instanceof Error ? err.message : String(err), "error");
} finally {
setBusyAction(null);
}
},
[busyAction, toast],
);
const beginRebuild = useCallback(
(scope: "app" | "full" | "plugins") =>
runAction(`rebuild-${scope}`, async () => {
const snapshot = await startSystemRebuild(scope, info?.restartSupported ?? false);
setJobLines([]);
setJob(snapshot);
}),
[info?.restartSupported, runAction],
);
const doRestart = useCallback(
() =>
runAction("restart", async () => {
prevPidRef.current = info?.pid ?? null;
await requestSystemRestart("system-panel");
setRestartPhase("waiting");
}),
[info?.pid, runAction],
);
const doEngineRestart = useCallback(
() =>
runAction("engine", async () => {
const result = await restartSystemEngines();
toast(
t("systemControls.engineRestarted", "Restarted {{count}} engine(s){{failed}}", {
count: result.restarted.length,
failed: result.failed.length ? ` — ${result.failed.length} failed` : "",
}),
result.failed.length ? "warning" : "success",
);
}),
[runAction, t, toast],
);
const doAgentsRestart = useCallback(
() =>
runAction("agents", async () => {
const result = await restartAllSystemAgents(projectId);
toast(
t("systemControls.agentsRestarted", "Restarted {{count}} active agent(s){{failed}}", {
count: result.restarted.length,
failed: result.failed.length ? ` — ${result.failed.length} failed` : "",
}),
result.failed.length ? "warning" : "success",
);
}),
[projectId, runAction, t, toast],
);
const doReloadPlugins = useCallback(
() =>
runAction("plugins-reload", async () => {
const result = await reloadAllSystemPlugins();
toast(
t("systemControls.pluginsReloaded", "Reloaded {{count}} plugin(s){{failed}}", {
count: result.reloaded.length,
failed: result.failed.length ? ` — ${result.failed.length} failed` : "",
}),
result.failed.length ? "warning" : "success",
);
}),
[runAction, t, toast],
);
const doBackup = useCallback(
() =>
runAction("backup", async () => {
await createBackup(projectId);
toast(t("systemControls.backupCreated", "Database backup created"), "success");
}),
[projectId, runAction, t, toast],
);
const buildDiagnostics = useCallback(async () => {
const health = await fetchDashboardHealth().catch(() => null);
const logs = info?.logsSupported
? await fetchSystemLogs(100).then((r) => r.entries).catch(() => [])
: [];
return {
capturedAt: new Date().toISOString(),
version: health?.version,
health: health ?? undefined,
system: info ?? undefined,
recentLogs: logs,
};
}, [info]);
const doCopyDiagnostics = useCallback(
() =>
runAction("diagnostics", async () => {
const diagnostics = await buildDiagnostics();
await navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2));
toast(t("systemControls.diagnosticsCopied", "Diagnostics copied to clipboard"), "success");
}),
[buildDiagnostics, runAction, t, toast],
);
const doReportBug = useCallback(
() =>
runAction("report-bug", async () => {
const health = await fetchDashboardHealth().catch(() => null);
const recentErrors = info?.logsSupported
? await fetchSystemLogs(200)
.then((r) => r.entries.filter((entry) => entry.level === "error").slice(-5))
.catch(() => [])
: [];
let body = [
"### What happened",
"",
"<!-- Describe the bug -->",
"",
"### Environment",
`- Fusion version: ${health?.version ?? "unknown"}`,
`- Platform: ${info?.platform ?? "unknown"} (${info?.arch ?? "?"}), Node ${info?.nodeVersion ?? "?"}`,
`- Uptime: ${info?.uptimeSeconds ?? "?"}s, supervised: ${info?.supervised ?? false}`,
"",
...(recentErrors.length
? ["### Recent server errors", "```", ...recentErrors.map((entry) => `${entry.prefix ? `[${entry.prefix}] ` : ""}${entry.message}`), "```"]
: []),
].join("\n");
if (body.length > BUG_URL_BODY_CAP) body = `${body.slice(0, BUG_URL_BODY_CAP)}\n…(truncated)`;
window.open(`${GITHUB_NEW_ISSUE_URL}?body=${encodeURIComponent(body)}`, "_blank", "noopener");
}),
[info, runAction],
);
// ── Control definitions ───────────────────────────────────────────────────
const restartDisabledNote = info && !info.restartSupported
? t("systemControls.restartUnavailable", "Needs a supervising parent — restart the dashboard without --no-supervise.")
: undefined;
/*
FNXC:SystemPanel 2026-07-12-14:15:
Rebuild controls are HIDDEN (not disabled) unless the server runs from a
Fusion source checkout (`pnpm dev`) — packaged installs (npm/npx, compiled
binary, desktop app) have nothing to rebuild, so showing the cards would
only confuse operators.
*/
const showRebuildControls = info?.rebuildSupported ?? false;
const rebuildRunning = job?.status === "running";
const controls = useMemo(
() => [
{
key: "rebuild-app",
icon: Hammer,
title: t("systemControls.rebuildRestart", "Rebuild & restart"),
description: t(
"systemControls.rebuildRestartDesc",
"Rebuild core, engine, dashboard and changed plugins, then restart the server.",
),
cta: t("systemControls.rebuildRestartCta", "Rebuild"),
hidden: !showRebuildControls,
disabled: rebuildRunning,
run: () => void beginRebuild("app"),
testId: "cc-syscontrol-rebuild-app",
},
{
key: "rebuild-full",
icon: Package,
title: t("systemControls.fullRebuild", "Full rebuild & restart"),
description: t("systemControls.fullRebuildDesc", "Rebuild the entire workspace (slower), then restart."),
cta: t("systemControls.fullRebuildCta", "Full rebuild"),
hidden: !showRebuildControls,
disabled: rebuildRunning,
run: () => void beginRebuild("full"),
testId: "cc-syscontrol-rebuild-full",
},
{
key: "restart",
icon: Power,
title: t("systemControls.restartServer", "Restart server"),
description: t("systemControls.restartServerDesc", "Gracefully restart the dashboard and engine process."),
cta: t("systemControls.restartServerCta", "Restart"),
disabled: !info?.restartSupported,
note: restartDisabledNote,
run: () => void doRestart(),
testId: "cc-syscontrol-restart",
},
{
key: "engine",
icon: Cpu,
title: t("systemControls.restartEngine", "Restart engine"),
description: t("systemControls.restartEngineDesc", "Stop and restart all running project engines in place."),
cta: t("systemControls.restartEngineCta", "Restart engine"),
disabled: !info?.engineAvailable,
note: info && !info.engineAvailable
? t("systemControls.engineUnavailable", "Engine is not running in this process.")
: undefined,
run: () => void doEngineRestart(),
testId: "cc-syscontrol-engine",
},
{
key: "agents",
icon: Bot,
title: t("systemControls.restartAgents", "Restart all agents"),
description: t(
"systemControls.restartAgentsDesc",
"Stop active runs and bounce every active agent. Paused agents stay paused.",
),
cta: t("systemControls.restartAgentsCta", "Restart agents"),
disabled: false,
run: () => void doAgentsRestart(),
testId: "cc-syscontrol-agents",
},
{
key: "rebuild-plugins",
icon: Blocks,
title: t("systemControls.rebuildPlugins", "Rebuild & reload plugins"),
description: t(
"systemControls.rebuildPluginsDesc",
"Rebuild changed plugin bundles and hot-reload them without a server restart.",
),
cta: t("systemControls.rebuildPluginsCta", "Rebuild plugins"),
hidden: !showRebuildControls,
disabled: rebuildRunning,
run: () => void beginRebuild("plugins"),
testId: "cc-syscontrol-rebuild-plugins",
},
{
key: "plugins-reload",
icon: RefreshCw,
title: t("systemControls.reloadPlugins", "Reload plugins"),
description: t("systemControls.reloadPluginsDesc", "Hot-reload every started plugin from its current build."),
cta: t("systemControls.reloadPluginsCta", "Reload"),
disabled: !info?.pluginReloadSupported,
note: info && !info.pluginReloadSupported
? t("systemControls.pluginReloadUnavailable", "Plugin runner is unavailable in this mode.")
: undefined,
run: () => void doReloadPlugins(),
testId: "cc-syscontrol-plugins-reload",
},
{
key: "backup",
icon: Database,
title: t("systemControls.backupDb", "Backup database"),
description: t("systemControls.backupDbDesc", "Create an integrity-checked backup of the database now."),
cta: t("systemControls.backupDbCta", "Backup now"),
disabled: false,
run: () => void doBackup(),
testId: "cc-syscontrol-backup",
},
{
key: "diagnostics",
icon: Copy,
title: t("systemControls.copyDiagnostics", "Copy diagnostics"),
description: t(
"systemControls.copyDiagnosticsDesc",
"Copy a JSON bundle with health, runtime info and recent logs.",
),
cta: t("systemControls.copyDiagnosticsCta", "Copy"),
disabled: false,
run: () => void doCopyDiagnostics(),
testId: "cc-syscontrol-diagnostics",
},
{
key: "report-bug",
icon: Bug,
title: t("systemControls.reportBug", "Report a bug"),
description: t("systemControls.reportBugDesc", "Open a prefilled GitHub issue with environment details."),
cta: t("systemControls.reportBugCta", "Report"),
disabled: false,
run: () => void doReportBug(),
testId: "cc-syscontrol-report-bug",
},
],
[
beginRebuild,
doAgentsRestart,
doBackup,
doCopyDiagnostics,
doEngineRestart,
doReloadPlugins,
doReportBug,
doRestart,
info,
showRebuildControls,
rebuildRunning,
restartDisabledNote,
t,
],
);
const jobStatusLabel = job
? job.status === "running"
? t("systemControls.jobRunning", "Running…")
: job.status === "succeeded"
? t("systemControls.jobSucceeded", "Succeeded")
: t("systemControls.jobFailed", "Failed")
: null;
return (
<>
<div className="cc-area-section" data-testid="cc-system-controls">
<div className="cc-area-section-header">
<h3 className="cc-area-section-title">{t("systemControls.title", "System controls")}</h3>
<button
type="button"
className="btn-icon"
onClick={() => void loadInfo()}
title={t("systemControls.refresh", "Refresh capabilities")}
aria-label={t("systemControls.refresh", "Refresh capabilities")}
>
<RefreshCw size={16} />
</button>
</div>
{infoError ? (
<p className="cc-system-note cc-system-note--error" role="status">
{t("systemControls.infoError", "Failed to load system info: {{error}}", { error: infoError })}
</p>
) : null}
{restartPhase === "waiting" ? (
<div className="cc-syscontrols-banner cc-syscontrols-banner--waiting" role="status" data-testid="cc-system-restart-waiting">
<RefreshCw size={16} className="spin" />
<span>{t("systemControls.restartWaiting", "Restarting server — waiting for it to come back…")}</span>
</div>
) : null}
{restartPhase === "back" ? (
<div className="cc-syscontrols-banner cc-syscontrols-banner--back" role="status" data-testid="cc-system-restart-back">
<span>{t("systemControls.restartBack", "Server is back online — reloading…")}</span>
<button type="button" className="btn" onClick={() => window.location.reload()}>
{t("systemControls.reloadNow", "Reload now")}
</button>
</div>
) : null}
<div className="cc-syscontrols-grid">
{controls.filter((control) => !("hidden" in control && control.hidden)).map((control) => (
<div key={control.key} className="card cc-syscontrol-card" data-testid={control.testId}>
<div className="cc-syscontrol-head">
<control.icon size={18} className="cc-syscontrol-icon" aria-hidden />
<span className="cc-syscontrol-title">{control.title}</span>
</div>
<p className="cc-syscontrol-desc">{control.description}</p>
{control.note ? <p className="cc-syscontrol-note">{control.note}</p> : null}
<button
type="button"
className="btn cc-syscontrol-cta"
disabled={control.disabled || busyAction !== null || restartPhase === "waiting"}
onClick={control.run}
>
{busyAction === control.key
? t("systemControls.working", "Working…")
: control.cta}
</button>
</div>
))}
</div>
</div>
{job ? (
<div className="cc-area-section" data-testid="cc-system-rebuild-output">
<div className="cc-area-section-header">
<h3 className="cc-area-section-title">{t("systemControls.rebuildOutput", "Rebuild output")}</h3>
<span className={`cc-syscontrols-job-status cc-syscontrols-job-status--${job.status}`}>
{jobStatusLabel}
</span>
</div>
<pre ref={jobOutputRef} className="cc-syscontrols-output" aria-live="polite">
{jobLines.map((line) => `${line.stream === "stderr" ? "! " : ""}${line.text}`).join("\n")}
</pre>
{job.status === "failed" && job.error ? (
<p className="cc-system-note cc-system-note--error">{job.error}</p>
) : null}
</div>
) : null}
<div className="cc-area-section" data-testid="cc-system-logs">
<div className="cc-area-section-header">
<h3 className="cc-area-section-title cc-system-section-title-with-icon">
<ScrollText size={16} />
<span>{t("systemControls.serverLogs", "Server logs")}</span>
</h3>
<button
type="button"
className="btn"
disabled={info ? !info.logsSupported : false}
onClick={() => setLogsOpen((open) => !open)}
data-testid="cc-system-logs-toggle"
>
{logsOpen
? t("systemControls.hideLogs", "Hide logs")
: t("systemControls.viewLogs", "View logs")}
</button>
</div>
{info && !info.logsSupported ? (
<p className="cc-system-note">
{t("systemControls.logsUnavailable", "Host-process logs are not available in this mode.")}
</p>
) : null}
{logsOpen && info?.logsSupported ? (
<div ref={logOutputRef} className="cc-syscontrols-output cc-syscontrols-logs" aria-live="polite">
{logEntries.map((entry, index) => (
<div key={`${entry.timestamp}-${index}`} className={`cc-syscontrols-log-line cc-syscontrols-log-line--${entry.level}`}>
<span className="cc-syscontrols-log-time">{formatLogTimestamp(entry.timestamp)}</span>
{entry.prefix ? <span className="cc-syscontrols-log-prefix">[{entry.prefix}]</span> : null}
<span className="cc-syscontrols-log-message">{entry.message}</span>
</div>
))}
{logEntries.length === 0 ? (
<div className="cc-syscontrols-log-line">{t("systemControls.noLogs", "No log entries yet.")}</div>
) : null}
</div>
) : null}
</div>
</>
);
}

View File

@@ -183,6 +183,7 @@ import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provide
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
import { registerSystemRoutes } from "./routes/register-system-routes.js";
import { registerCliAgentHooksRoute } from "./routes/cli-agent-hooks.js";
import { registerCliAgentSettingsRoutes } from "./routes/cli-agent-settings.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
@@ -3347,6 +3348,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
isMemoryBackendError: (error): error is { code: string; backend?: string; message: string } => error instanceof MemoryBackendError,
});
// ── System Panel Routes (Command Center → System) ─────────────────────────
// FNXC:SystemPanel 2026-07-12-11:25: operator restart/rebuild/logs/debug
// controls. Registered here so the same heartbeat-monitor resolution used by
// agent runtime routes powers "restart all agents".
registerSystemRoutes(routeContext, {
hasHeartbeatExecutor,
heartbeatMonitor,
isHeartbeatMonitorForProject,
resolveHeartbeatMonitor,
});
// ── Agent Reflection Routes ──────────────────────────────────────────────
registerAgentReflectionRatingRoutes(routeContext);

View File

@@ -0,0 +1,377 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import express from "express";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { request as performRequest } from "../../test-request.js";
import { registerSystemRoutes, __resetSystemJobsForTests } from "../register-system-routes.js";
type App = Parameters<typeof performRequest>[0];
async function getJson(app: App, path: string): Promise<{ status: number; body: any }> {
const res = await performRequest(app, "GET", path);
return { status: res.status, body: res.body };
}
async function postJson(app: App, path: string, payload: unknown = {}): Promise<{ status: number; body: any }> {
const res = await performRequest(app, "POST", path, JSON.stringify(payload), {
"content-type": "application/json",
});
return { status: res.status, body: res.body };
}
/*
FNXC:SystemPanel 2026-07-12-12:00:
Contract tests for the System panel API: capability discovery must reflect the
injected systemControl/systemLogs, every unsupported control must 409 with a
reason (never silently no-op), restart must call the host requestRestart hook,
the rebuild job must stream/buffer real build output, engine restart must
pause+resume each running project engine, and agent restart-all must bounce
only ACTIVE agents (operator-paused agents stay paused).
*/
const agentStoreState: { agents: Array<{ id: string; state: string }> } = { agents: [] };
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return {
...actual,
AgentStore: class {
constructor(_opts: unknown) {}
async init(): Promise<void> {}
async listAgents(): Promise<Array<{ id: string; state: string }>> {
return agentStoreState.agents;
}
},
};
});
function createLogger(): never {
const logger: Record<string, unknown> = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
logger.child = vi.fn(() => logger);
return logger as never;
}
interface HarnessOptions {
options?: Record<string, unknown>;
deps?: Partial<Parameters<typeof registerSystemRoutes>[1]>;
store?: Record<string, unknown>;
}
function createApp(harness: HarnessOptions = {}) {
const router = express.Router();
const rethrowAsApiError = vi.fn((error: unknown) => {
throw error;
});
registerSystemRoutes(
{
router,
store: (harness.store ?? {}) as never,
options: harness.options as never,
runtimeLogger: createLogger(),
planningLogger: createLogger(),
chatLogger: createLogger(),
getProjectIdFromRequest: vi.fn(() => "proj-1"),
getScopedStore: vi.fn(async () => ({}) as never),
getProjectContext: vi.fn(async () => ({
projectId: "proj-1",
engine: undefined,
store: { getFusionDir: () => "/tmp/fusion-test" } as never,
})),
prioritizeProjectsForCurrentDirectory: (projects) => projects,
emitRemoteRouteDiagnostic: vi.fn(),
emitAuthSyncAuditLog: vi.fn(),
parseScopeParam: vi.fn(),
resolveAutomationStore: vi.fn() as never,
resolveRoutineStore: vi.fn() as never,
resolveRoutineRunner: vi.fn() as never,
registerDispose: vi.fn(),
dispose: vi.fn(),
rethrowAsApiError,
},
{
hasHeartbeatExecutor: harness.deps?.hasHeartbeatExecutor ?? false,
heartbeatMonitor: harness.deps?.heartbeatMonitor,
isHeartbeatMonitorForProject: harness.deps?.isHeartbeatMonitorForProject ?? vi.fn(() => false),
resolveHeartbeatMonitor: harness.deps?.resolveHeartbeatMonitor ?? vi.fn(() => undefined),
},
);
const app = express();
app.use(express.json());
app.use("/api", router);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? 500).json({ error: err?.message ?? String(err) });
});
return { app };
}
const tempRoots: string[] = [];
function createFakeSourceCheckout(buildScriptBody: string): string {
const root = mkdtempSync(join(tmpdir(), "fusion-system-routes-"));
tempRoots.push(root);
mkdirSync(join(root, "scripts"), { recursive: true });
writeFileSync(join(root, "scripts", "dev-prebuild-client.mjs"), buildScriptBody);
return root;
}
afterAll(() => {
for (const root of tempRoots) {
rmSync(root, { recursive: true, force: true });
}
});
beforeEach(() => {
__resetSystemJobsForTests();
agentStoreState.agents = [];
});
describe("GET /system/info", () => {
it("reports no capabilities when the host wired nothing", async () => {
const { app } = createApp();
const res = await getJson(app, "/api/system/info");
expect(res.status).toBe(200);
expect(res.body.restartSupported).toBe(false);
expect(res.body.rebuildSupported).toBe(false);
expect(res.body.logsSupported).toBe(false);
expect(res.body.engineAvailable).toBe(false);
expect(res.body.pid).toBe(process.pid);
});
it("reflects injected systemControl and systemLogs capabilities", async () => {
const { app } = createApp({
options: {
systemControl: { supervised: true, requestRestart: vi.fn(() => true), sourceWorkspaceRoot: "/checkout" },
systemLogs: { getRecent: vi.fn(() => []), subscribe: vi.fn(() => () => {}) },
engineManager: {},
},
});
const res = await getJson(app, "/api/system/info");
expect(res.body.restartSupported).toBe(true);
expect(res.body.rebuildSupported).toBe(true);
expect(res.body.sourceWorkspaceRoot).toBe("/checkout");
expect(res.body.logsSupported).toBe(true);
expect(res.body.engineAvailable).toBe(true);
});
});
describe("POST /system/restart", () => {
it("409s when the host did not wire system control", async () => {
const { app } = createApp();
const res = await postJson(app, "/api/system/restart");
expect(res.status).toBe(409);
});
it("409s when there is no supervising parent to respawn", async () => {
const requestRestart = vi.fn(() => false);
const { app } = createApp({ options: { systemControl: { supervised: false, requestRestart } } });
const res = await postJson(app, "/api/system/restart");
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/supervis/i);
});
it("202s and forwards the reason to the host restart hook", async () => {
const requestRestart = vi.fn(() => true);
const { app } = createApp({ options: { systemControl: { supervised: true, requestRestart } } });
const res = await postJson(app, "/api/system/restart", { reason: "test-restart" });
expect(res.status).toBe(202);
expect(res.body.scheduled).toBe(true);
expect(requestRestart).toHaveBeenCalledWith("test-restart");
});
});
describe("POST /system/rebuild", () => {
it("409s when not running from a source checkout", async () => {
const { app } = createApp({ options: { systemControl: { supervised: true, requestRestart: vi.fn(() => true) } } });
const res = await postJson(app, "/api/system/rebuild", { scope: "app" });
expect(res.status).toBe(409);
});
it("runs the build script, buffers output, and schedules a restart on success", async () => {
const root = createFakeSourceCheckout("console.log('build-line-1');\nconsole.log('build-line-2');\n");
const requestRestart = vi.fn(() => true);
const { app } = createApp({
options: { systemControl: { supervised: true, requestRestart, sourceWorkspaceRoot: root } },
});
const started = await postJson(app, "/api/system/rebuild", { scope: "app", restart: true });
expect(started.status).toBe(202);
expect(started.body.status).toBe("running");
await vi.waitFor(async () => {
const current = await getJson(app, "/api/system/rebuild/current");
expect(current.body.job.status).toBe("succeeded");
}, { timeout: 10_000, interval: 100 });
const current = await getJson(app, "/api/system/rebuild/current");
const lineTexts = current.body.job.lines.map((line: { text: string }) => line.text);
expect(lineTexts).toContain("build-line-1");
expect(lineTexts).toContain("build-line-2");
expect(current.body.job.restartScheduled).toBe(true);
expect(requestRestart).toHaveBeenCalledWith("rebuild:app");
});
it("marks the job failed when the build exits non-zero and does not restart", async () => {
const root = createFakeSourceCheckout("console.error('boom');\nprocess.exit(3);\n");
const requestRestart = vi.fn(() => true);
const { app } = createApp({
options: { systemControl: { supervised: true, requestRestart, sourceWorkspaceRoot: root } },
});
const started = await postJson(app, "/api/system/rebuild", { scope: "app" });
expect(started.status).toBe(202);
await vi.waitFor(async () => {
const current = await getJson(app, "/api/system/rebuild/current");
expect(current.body.job.status).toBe("failed");
}, { timeout: 10_000, interval: 100 });
expect(requestRestart).not.toHaveBeenCalled();
});
it("rejects a second concurrent rebuild", async () => {
const root = createFakeSourceCheckout("setTimeout(() => {}, 400);\n");
const { app } = createApp({
options: { systemControl: { supervised: true, requestRestart: vi.fn(() => true), sourceWorkspaceRoot: root } },
});
const first = await postJson(app, "/api/system/rebuild", { scope: "app" });
expect(first.status).toBe(202);
const second = await postJson(app, "/api/system/rebuild", { scope: "app" });
expect(second.status).toBe(409);
// Let the short-lived build child exit before the test ends so the
// subprocess guard sees no leaked children.
await vi.waitFor(async () => {
const current = await getJson(app, "/api/system/rebuild/current");
expect(current.body.job.status).not.toBe("running");
}, { timeout: 10_000, interval: 100 });
});
});
describe("GET /system/logs", () => {
it("409s without a log provider", async () => {
const { app } = createApp();
const res = await getJson(app, "/api/system/logs");
expect(res.status).toBe(409);
});
it("returns recent entries with the requested limit", async () => {
const getRecent = vi.fn((limit?: number) => [
{ timestamp: new Date(), level: "info" as const, message: `limit=${limit}` },
]);
const { app } = createApp({
options: { systemLogs: { getRecent, subscribe: vi.fn(() => () => {}) } },
});
const res = await getJson(app, "/api/system/logs?limit=42");
expect(res.status).toBe(200);
expect(getRecent).toHaveBeenCalledWith(42);
expect(res.body.entries[0].message).toBe("limit=42");
});
});
describe("POST /system/engine/restart", () => {
it("409s when the engine manager is unavailable", async () => {
const { app } = createApp();
const res = await postJson(app, "/api/system/engine/restart");
expect(res.status).toBe(409);
});
it("pause+resumes each running project engine and reports failures", async () => {
const pauseProject = vi.fn(async () => {});
const resumeProject = vi.fn(async (id: string) => {
if (id === "p2") throw new Error("resume failed");
});
const engineManager = {
getEngine: (id: string) => (id === "p3" ? undefined : {}),
pauseProject,
resumeProject,
};
const centralCore = {
listProjects: vi.fn(async () => [{ id: "p1" }, { id: "p2" }, { id: "p3" }]),
};
const { app } = createApp({ options: { engineManager, centralCore } });
const res = await postJson(app, "/api/system/engine/restart");
expect(res.status).toBe(200);
expect(res.body.restarted).toEqual(["p1"]);
expect(res.body.failed).toEqual([{ projectId: "p2", error: "resume failed" }]);
// p3 has no running engine — must not be touched.
expect(pauseProject).toHaveBeenCalledTimes(2);
});
});
describe("POST /system/agents/restart-all", () => {
it("bounces only active agents and leaves paused agents untouched", async () => {
agentStoreState.agents = [
{ id: "agent-active", state: "active" },
{ id: "agent-paused", state: "paused" },
{ id: "agent-disabled", state: "disabled" },
];
const pauseAgent = vi.fn(async () => ({}));
const resumeAgent = vi.fn(async () => ({}));
const monitor = { pauseAgent, resumeAgent };
const { app } = createApp({
deps: {
hasHeartbeatExecutor: true,
heartbeatMonitor: monitor as never,
isHeartbeatMonitorForProject: vi.fn(() => true),
resolveHeartbeatMonitor: vi.fn(() => monitor as never),
},
});
const res = await postJson(app, "/api/system/agents/restart-all");
expect(res.status).toBe(200);
expect(res.body.restarted).toEqual(["agent-active"]);
expect(pauseAgent).toHaveBeenCalledTimes(1);
expect(pauseAgent).toHaveBeenCalledWith("agent-active", expect.objectContaining({ stopActiveRun: true }));
expect(resumeAgent).toHaveBeenCalledWith("agent-active", expect.objectContaining({ clearPauseReason: true }));
});
it("409s when no lifecycle monitor is available", async () => {
agentStoreState.agents = [{ id: "agent-active", state: "active" }];
const { app } = createApp();
const res = await postJson(app, "/api/system/agents/restart-all");
expect(res.status).toBe(409);
});
});
describe("POST /system/plugins/reload-all", () => {
it("reloads only started plugins", async () => {
const reloadPlugin = vi.fn(async () => {});
const { app } = createApp({
store: {
getPluginStore: () => ({
listPlugins: vi.fn(async () => [
{ id: "plugin-started", state: "started" },
{ id: "plugin-stopped", state: "stopped" },
]),
}),
},
options: { pluginRunner: { reloadPlugin } },
});
const res = await postJson(app, "/api/system/plugins/reload-all");
expect(res.status).toBe(200);
expect(res.body.reloaded).toEqual(["plugin-started"]);
expect(reloadPlugin).toHaveBeenCalledTimes(1);
});
it("409s when the plugin runner is unavailable", async () => {
const { app } = createApp({
store: { getPluginStore: () => ({ listPlugins: vi.fn(async () => []) }) },
});
const res = await postJson(app, "/api/system/plugins/reload-all");
expect(res.status).toBe(409);
});
});

View File

@@ -0,0 +1,503 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { Response } from "express";
import { superviseSpawn, AgentStore } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { writeSSEEvent } from "../sse-buffer.js";
import type { ApiRoutesContext } from "./types.js";
import type { SystemLogEntry } from "../server.js";
/*
FNXC:SystemPanel 2026-07-12-11:20:
Operator "System panel" API (Command Center → System tab). Gives operators
in-dashboard debug/maintenance controls:
- GET /system/info capability discovery (what the host process supports)
- POST /system/restart graceful process restart via the supervising parent
- POST /system/rebuild workspace/plugin rebuild job with streamed output,
optional restart-on-success
- GET /system/rebuild/current snapshot of the active/last rebuild job
- GET /system/jobs/:id/stream SSE live output of a rebuild job (replays buffered lines)
- GET /system/logs recent host-process log entries (ring buffer)
- GET /system/logs/stream SSE live tail of host-process logs
- POST /system/engine/restart bounce all running project engines in-process
- POST /system/agents/restart-all pause+resume every active agent (stops active runs)
- POST /system/plugins/reload-all hot-reload every started plugin
Restart/rebuild only work when the host CLI injected `systemControl`
(supervised process + source checkout); every route degrades to an explicit
409 with a reason instead of failing silently, so the UI can disable controls.
Rebuild jobs are serialized — one at a time — because concurrent workspace
builds would corrupt each other's dist output.
*/
const JOB_LINE_CAP = 4_000;
const REBUILD_MAX_LIFETIME_MS = 30 * 60_000;
type RebuildScope = "app" | "full" | "plugins";
interface SystemJobLine {
i: number;
ts: number;
stream: "stdout" | "stderr" | "system";
text: string;
}
interface SystemJob {
id: string;
kind: "rebuild";
scope: RebuildScope;
restartAfter: boolean;
status: "running" | "succeeded" | "failed";
startedAt: number;
finishedAt?: number;
exitCode?: number | null;
error?: string;
restartScheduled?: boolean;
pluginsReloaded?: string[];
droppedLines: number;
lines: SystemJobLine[];
subscribers: Set<Response>;
}
let activeJob: SystemJob | null = null;
let lastJob: SystemJob | null = null;
const jobsById = new Map<string, SystemJob>();
/** Test-only: clear module-level job state between tests. */
export function __resetSystemJobsForTests(): void {
activeJob = null;
lastJob = null;
jobsById.clear();
}
function jobSnapshot(job: SystemJob, includeLines: boolean): Record<string, unknown> {
return {
id: job.id,
kind: job.kind,
scope: job.scope,
restartAfter: job.restartAfter,
status: job.status,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
exitCode: job.exitCode,
error: job.error,
restartScheduled: job.restartScheduled,
pluginsReloaded: job.pluginsReloaded,
droppedLines: job.droppedLines,
lineCount: job.droppedLines + job.lines.length,
...(includeLines ? { lines: job.lines } : {}),
};
}
function appendJobLine(job: SystemJob, stream: SystemJobLine["stream"], text: string): void {
const line: SystemJobLine = {
i: job.droppedLines + job.lines.length,
ts: Date.now(),
stream,
text,
};
job.lines.push(line);
if (job.lines.length > JOB_LINE_CAP) {
job.droppedLines += job.lines.length - JOB_LINE_CAP;
job.lines.splice(0, job.lines.length - JOB_LINE_CAP);
}
for (const res of job.subscribers) {
writeSSEEvent(res, "line", JSON.stringify(line), line.i);
}
}
function finishJob(job: SystemJob, status: "succeeded" | "failed", extra: Partial<SystemJob> = {}): void {
job.status = status;
job.finishedAt = Date.now();
Object.assign(job, extra);
if (activeJob === job) {
activeJob = null;
lastJob = job;
}
for (const res of job.subscribers) {
writeSSEEvent(res, "end", JSON.stringify(jobSnapshot(job, false)));
try {
res.end();
} catch {
// Already closed.
}
}
job.subscribers.clear();
}
/** Split a chunked stream into lines, keeping partials until the next chunk. */
function createLineSplitter(onLine: (text: string) => void): { push(chunk: Buffer | string): void; flush(): void } {
let partial = "";
return {
push(chunk) {
partial += chunk.toString();
const parts = partial.split(/\r?\n/);
partial = parts.pop() ?? "";
for (const part of parts) onLine(part);
},
flush() {
if (partial.length > 0) {
onLine(partial);
partial = "";
}
},
};
}
interface SystemRouteDeps {
hasHeartbeatExecutor: boolean;
heartbeatMonitor: import("../server.js").ServerOptions["heartbeatMonitor"];
isHeartbeatMonitorForProject: (scopedStore: import("@fusion/core").TaskStore) => boolean;
resolveHeartbeatMonitor: (scopedStore: import("@fusion/core").TaskStore) => import("../server.js").ServerOptions["heartbeatMonitor"];
}
interface AgentLifecycleMonitor {
pauseAgent?: (agentId: string, options?: { pauseReason?: string; stopActiveRun?: boolean }) => Promise<unknown>;
resumeAgent?: (agentId: string, options?: { triggerDetail?: string; triggerSource?: string; clearPauseReason?: boolean }) => Promise<unknown>;
}
export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDeps): void {
const { router, options, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
const log = runtimeLogger.child("system");
const systemControl = options?.systemControl;
const systemLogs = options?.systemLogs;
const rebuildScopes: Record<RebuildScope, { args: string[]; label: string }> = {
// Keep in sync with the `pnpm dev dashboard` client prebuild — same scripts.
app: { args: ["scripts/dev-prebuild-client.mjs"], label: "core + engine + dashboard + changed plugins" },
full: { args: ["scripts/build-workspace.mjs"], label: "full workspace" },
plugins: { args: ["scripts/build-workspace.mjs", "--plugins-only"], label: "changed plugins" },
};
const reloadStartedPlugins = async (): Promise<{ reloaded: string[]; failed: Array<{ id: string; error: string }> }> => {
const pluginStore = ctx.store.getPluginStore();
const reloadPlugin = options?.pluginRunner?.reloadPlugin;
if (!reloadPlugin) {
throw new ApiError(409, "Plugin runner not available in this mode");
}
const plugins = await pluginStore.listPlugins();
const reloaded: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
for (const plugin of plugins) {
if (plugin.state !== "started") continue;
try {
await reloadPlugin(plugin.id);
reloaded.push(plugin.id);
} catch (err) {
failed.push({ id: plugin.id, error: err instanceof Error ? err.message : String(err) });
}
}
return { reloaded, failed };
};
/** GET /api/system/info — capability discovery for the System panel. */
router.get("/system/info", (_req, res) => {
res.json({
supervised: systemControl?.supervised ?? false,
restartSupported: systemControl?.supervised ?? false,
rebuildSupported: Boolean(systemControl?.sourceWorkspaceRoot),
sourceWorkspaceRoot: systemControl?.sourceWorkspaceRoot,
logsSupported: Boolean(systemLogs),
engineAvailable: Boolean(options?.engineManager),
pluginReloadSupported: Boolean(options?.pluginRunner?.reloadPlugin),
pid: process.pid,
uptimeSeconds: Math.floor(process.uptime()),
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
memoryRssBytes: process.memoryUsage().rss,
activeRebuild: activeJob ? jobSnapshot(activeJob, false) : null,
lastRebuild: lastJob ? jobSnapshot(lastJob, false) : null,
});
});
/** POST /api/system/restart — graceful restart via the supervising parent. */
router.post("/system/restart", (req, res) => {
if (!systemControl) {
throw new ApiError(409, "Restart is not available: host process did not wire system control");
}
const reason = typeof (req.body as { reason?: unknown })?.reason === "string"
? (req.body as { reason: string }).reason
: "operator-request";
const accepted = systemControl.requestRestart(reason);
if (!accepted) {
throw new ApiError(
409,
"Restart is not available: no supervising parent. Start via `pnpm dev` or `fn dashboard --supervise`.",
);
}
log.info("System restart scheduled", { reason });
res.status(202).json({ scheduled: true });
});
/** POST /api/system/rebuild — start a rebuild job. Body: { scope?, restart? } */
router.post("/system/rebuild", (req, res) => {
const root = systemControl?.sourceWorkspaceRoot;
if (!root) {
throw new ApiError(409, "Rebuild is only available when running from a Fusion source checkout");
}
if (activeJob) {
throw new ApiError(409, `A ${activeJob.scope} rebuild is already running`);
}
const body = (req.body ?? {}) as { scope?: unknown; restart?: unknown };
const scope = (body.scope ?? "app") as RebuildScope;
if (!(scope in rebuildScopes)) {
throw badRequest(`Invalid scope "${String(body.scope)}". Expected one of: app, full, plugins.`);
}
const restartAfter = body.restart !== false && scope !== "plugins";
const { args, label } = rebuildScopes[scope];
const scriptPath = join(root, args[0]);
if (!existsSync(scriptPath)) {
throw new ApiError(409, `Build script missing: ${scriptPath}`);
}
const job: SystemJob = {
id: randomUUID(),
kind: "rebuild",
scope,
restartAfter,
status: "running",
startedAt: Date.now(),
droppedLines: 0,
lines: [],
subscribers: new Set(),
};
activeJob = job;
jobsById.set(job.id, job);
// Bound the job registry — keep only the most recent handful.
if (jobsById.size > 5) {
const oldest = jobsById.keys().next().value;
if (oldest && oldest !== job.id) jobsById.delete(oldest);
}
appendJobLine(job, "system", `Starting ${label} build (${scope})…`);
log.info("System rebuild started", { jobId: job.id, scope, restartAfter });
let child: ReturnType<typeof superviseSpawn>;
try {
child = superviseSpawn(process.execPath, args.map((a, i) => (i === 0 ? scriptPath : a)), {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
maxLifetimeMs: REBUILD_MAX_LIFETIME_MS,
env: { ...process.env, FUSION_SKIP_STARTUP_UPDATE_PREFLIGHT: "1", FORCE_COLOR: "0" },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
appendJobLine(job, "system", `Failed to spawn build: ${message}`);
finishJob(job, "failed", { error: message });
rethrowAsApiError(err, "Failed to start rebuild");
return; // unreachable — rethrowAsApiError always throws
}
const stdout = createLineSplitter((text) => appendJobLine(job, "stdout", text));
const stderr = createLineSplitter((text) => appendJobLine(job, "stderr", text));
child.child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
child.child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
void child.waitExit().then(async (exit) => {
stdout.flush();
stderr.flush();
const code = exit.code ?? (exit.signal ? 1 : 0);
if (code !== 0) {
appendJobLine(job, "system", `Build failed (exit ${exit.code ?? exit.signal ?? "unknown"})`);
finishJob(job, "failed", { exitCode: exit.code, error: `Build exited with ${exit.code ?? exit.signal}` });
log.warn("System rebuild failed", { jobId: job.id, scope, exitCode: exit.code, signal: exit.signal ?? undefined });
return;
}
appendJobLine(job, "system", "Build succeeded.");
if (scope === "plugins") {
try {
const result = await reloadStartedPlugins();
appendJobLine(
job,
"system",
`Reloaded ${result.reloaded.length} plugin(s)${result.failed.length ? `, ${result.failed.length} failed` : ""}.`,
);
for (const failure of result.failed) {
appendJobLine(job, "system", `Plugin reload failed: ${failure.id} — ${failure.error}`);
}
finishJob(job, "succeeded", { exitCode: 0, pluginsReloaded: result.reloaded });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
appendJobLine(job, "system", `Plugin reload unavailable: ${message}`);
finishJob(job, "succeeded", { exitCode: 0, pluginsReloaded: [] });
}
return;
}
let restartScheduled = false;
if (restartAfter && systemControl) {
restartScheduled = systemControl.requestRestart(`rebuild:${scope}`);
appendJobLine(
job,
"system",
restartScheduled
? "Restarting server…"
: "Restart not available (no supervising parent) — restart manually to pick up the build.",
);
}
finishJob(job, "succeeded", { exitCode: 0, restartScheduled });
log.info("System rebuild succeeded", { jobId: job.id, scope, restartScheduled });
});
res.status(202).json(jobSnapshot(job, false));
});
/** GET /api/system/rebuild/current — active job, falling back to the last finished one. */
router.get("/system/rebuild/current", (_req, res) => {
const job = activeJob ?? lastJob;
res.json({ job: job ? jobSnapshot(job, true) : null });
});
/** GET /api/system/jobs/:id/stream — SSE output stream with Last-Event-ID replay. */
router.get("/system/jobs/:id/stream", (req, res) => {
const job = jobsById.get(req.params.id as string);
if (!job) {
throw notFound("Unknown system job");
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
res.write(": connected\n\n");
const lastEventId = Number.parseInt(String(req.headers["last-event-id"] ?? ""), 10);
const replayFrom = Number.isFinite(lastEventId) ? lastEventId + 1 : 0;
for (const line of job.lines) {
if (line.i >= replayFrom) {
writeSSEEvent(res, "line", JSON.stringify(line), line.i);
}
}
if (job.status !== "running") {
writeSSEEvent(res, "end", JSON.stringify(jobSnapshot(job, false)));
res.end();
return;
}
job.subscribers.add(res);
req.on("close", () => {
job.subscribers.delete(res);
});
});
/** GET /api/system/logs?limit=500 — recent host-process log entries. */
router.get("/system/logs", (req, res) => {
if (!systemLogs) {
throw new ApiError(409, "Host-process logs are not available in this mode");
}
const rawLimit = Number.parseInt(String(req.query.limit ?? ""), 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 1000) : 500;
res.json({ entries: systemLogs.getRecent(limit) });
});
/** GET /api/system/logs/stream — SSE live tail of host-process logs. */
router.get("/system/logs/stream", (req, res) => {
if (!systemLogs) {
throw new ApiError(409, "Host-process logs are not available in this mode");
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
res.write(": connected\n\n");
for (const entry of systemLogs.getRecent(200)) {
writeSSEEvent(res, "log", JSON.stringify(entry));
}
const unsubscribe = systemLogs.subscribe((entry: SystemLogEntry) => {
writeSSEEvent(res, "log", JSON.stringify(entry));
});
req.on("close", unsubscribe);
});
/** POST /api/system/engine/restart — bounce all running project engines. */
router.post("/system/engine/restart", async (_req, res) => {
const engineManager = options?.engineManager;
const centralCore = options?.centralCore;
if (!engineManager || !centralCore) {
throw new ApiError(409, "Engine manager is unavailable");
}
try {
const projects = await centralCore.listProjects();
const runningIds = projects.filter((p) => engineManager.getEngine(p.id)).map((p) => p.id);
const restarted: string[] = [];
const failed: Array<{ projectId: string; error: string }> = [];
for (const projectId of runningIds) {
try {
// pause+resume is the only manager path that cleanly tears down the
// engine (map removal + singleton lock release) before restarting.
await engineManager.pauseProject(projectId);
await engineManager.resumeProject(projectId);
restarted.push(projectId);
} catch (err) {
failed.push({ projectId, error: err instanceof Error ? err.message : String(err) });
}
}
log.info("Engine restart completed", { restartedCount: restarted.length, failedCount: failed.length });
res.json({ restarted, failed });
} catch (err) {
rethrowAsApiError(err, "Failed to restart engines");
}
});
/** POST /api/system/agents/restart-all — pause+resume every active agent. */
router.post("/system/agents/restart-all", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const monitor =
deps.hasHeartbeatExecutor && deps.heartbeatMonitor && deps.isHeartbeatMonitorForProject(scopedStore)
? deps.heartbeatMonitor
: deps.resolveHeartbeatMonitor(scopedStore);
const lifecycle = monitor as AgentLifecycleMonitor | undefined;
if (!lifecycle?.pauseAgent || !lifecycle?.resumeAgent) {
throw new ApiError(409, "Agent lifecycle control is unavailable in this mode");
}
// Only bounce agents that are currently active — an operator-paused or
// disabled agent must stay exactly as the operator left it.
const agents = await agentStore.listAgents({ includeEphemeral: false });
const restarted: string[] = [];
const failed: Array<{ agentId: string; error: string }> = [];
for (const agent of agents) {
if (agent.state !== "active") continue;
try {
await lifecycle.pauseAgent(agent.id, { pauseReason: "system-restart-all", stopActiveRun: true });
await lifecycle.resumeAgent(agent.id, {
triggerDetail: "Restarted from the System panel",
triggerSource: "system-restart-all",
clearPauseReason: true,
});
restarted.push(agent.id);
} catch (err) {
failed.push({ agentId: agent.id, error: err instanceof Error ? err.message : String(err) });
}
}
log.info("Agent restart-all completed", { restartedCount: restarted.length, failedCount: failed.length });
res.json({ restarted, failed });
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to restart agents");
}
});
/** POST /api/system/plugins/reload-all — hot-reload every started plugin. */
router.post("/system/plugins/reload-all", async (_req, res) => {
try {
const result = await reloadStartedPlugins();
log.info("Plugin reload-all completed", { reloadedCount: result.reloaded.length, failedCount: result.failed.length });
res.json(result);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to reload plugins");
}
});
}

View File

@@ -501,6 +501,33 @@ export interface ServerOptions {
key: string | Buffer;
ca?: string | Buffer | Array<string | Buffer>;
};
/*
FNXC:SystemPanel 2026-07-12-11:15:
Host-process control surface for the dashboard System panel (Command Center →
System). The host CLI injects these; the /api/system routes consume them.
`requestRestart` returns false when no supervising parent will respawn the
process (then the UI disables restart actions). `sourceWorkspaceRoot` is set
only when running from a Fusion source checkout, which gates the
"Rebuild & restart" controls.
*/
systemControl?: {
supervised: boolean;
requestRestart: (reason: string) => boolean;
sourceWorkspaceRoot?: string;
};
/** Bounded host-process log history + live tail for the System panel log viewer. */
systemLogs?: {
getRecent(limit?: number): SystemLogEntry[];
subscribe(listener: (entry: SystemLogEntry) => void): () => void;
};
}
/** System panel log entry shape (mirrors the CLI log sink's ring buffer). */
export interface SystemLogEntry {
timestamp: Date;
level: "info" | "warn" | "error";
message: string;
prefix?: string;
}
function hasDashboardEngine(options?: ServerOptions): boolean {

View File

@@ -100,6 +100,42 @@ async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
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");
@@ -259,6 +295,7 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
...(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)");

View File

@@ -4,6 +4,7 @@ import type { Server } from "node:http";
import { resolveDesktopRuntimePrimaryProject } from "./engine-runtime.js";
import { resolveDesktopBundlePluginDirs } from "./bundled-plugin-dirs.js";
import { resolveDesktopSystemControl } from "./local-runtime.js";
/*
* FNXC:DesktopRuntime 2026-07-07-12:00:
@@ -173,6 +174,9 @@ export class DesktopLocalServerManager {
...(pluginStore && pluginLoader ? { pluginStore: pluginStore as never, pluginLoader, pluginRunner: pluginLoader } : {}),
...(ensureBundledPluginInstalledCallback ? { ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback } : {}),
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
// FNXC:SystemPanel 2026-07-12-14:20: System panel restart via Electron
// app.relaunch(); see resolveDesktopSystemControl in local-runtime.ts.
...(await resolveDesktopSystemControl()),
});
server = app.listen(0);

View File

@@ -32,8 +32,18 @@ export function hasHostOverride(args) {
}
export function buildForwardedDevArgs(args) {
const needsDevHostInjection = args[0] === "dashboard" && !hasHostOverride(args);
return needsDevHostInjection ? [...args, "--host", "0.0.0.0"] : args;
/*
FNXC:DevWorkflow 2026-07-12-10:20:
`pnpm dev` and `pnpm start` with no command must behave exactly like
`pnpm dev dashboard` (client prebuild + LAN host injection), not fall through
to the CLI's bare default. Normalize empty/flag-only invocations to an
explicit "dashboard" command so every downstream decision (prebuild mode,
host injection) sees the same shape.
*/
const hasCommand = args.length > 0 && !String(args[0]).startsWith("-");
const normalized = hasCommand ? args : ["dashboard", ...args];
const needsDevHostInjection = normalized[0] === "dashboard" && !hasHostOverride(normalized);
return needsDevHostInjection ? [...normalized, "--host", "0.0.0.0"] : normalized;
}
export function parseDevWrapperArgs(rawArgs, env = process.env) {

View File

@@ -59,6 +59,19 @@ const ENTRY = path.resolve(process.cwd(), "packages/cli/src/bin.ts");
// process and there's no parent/child wrapper consuming --inspect.
// Inspector flags are CLI args here so they apply only to this process and
// don't propagate to grandchildren via NODE_OPTIONS.
/*
FNXC:SystemPanel 2026-07-12-10:45:
This wrapper is the supervising parent for `pnpm dev` / `pnpm start`, so it is
where the dashboard System panel's "Restart"/"Rebuild & restart" actions land:
the child exits with FUSION_RESTART_EXIT_CODE (86 — keep in sync with
packages/core/src/process-supervisor.ts) and we respawn the same command
immediately, keeping the same terminal/TTY so the TUI comes back seamlessly.
FUSION_RESTART_SUPERVISED=1 tells the child a respawning parent exists, which
is what makes the dashboard advertise restart support. Any other exit code
propagates unchanged (no crash-restart loop here — `--supervise` owns that).
*/
const RESTART_EXIT_CODE = 86;
function runApp(extraArgs) {
const tsx = spawn(process.execPath, buildDevNodeArgs({
inspectFlags,
@@ -66,8 +79,15 @@ function runApp(extraArgs) {
loader: LOADER,
entry: ENTRY,
args: extraArgs,
}), { stdio: "inherit" });
tsx.on("close", (c) => process.exit(c ?? 1));
}), { stdio: "inherit", env: { ...process.env, FUSION_RESTART_SUPERVISED: "1" } });
tsx.on("close", (c) => {
if (c === RESTART_EXIT_CODE) {
console.log("[fusion:dev] restart requested — restarting…");
runApp(extraArgs);
return;
}
process.exit(c ?? 1);
});
}
async function warnIfSourceVersionBehind() {