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:
7
.changeset/system-panel-controls.md
Normal file
7
.changeset/system-panel-controls.md
Normal 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.
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
503
packages/dashboard/src/routes/register-system-routes.ts
Normal file
503
packages/dashboard/src/routes/register-system-routes.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user