diff --git a/.changeset/system-panel-controls.md b/.changeset/system-panel-controls.md new file mode 100644 index 0000000000..608577b829 --- /dev/null +++ b/.changeset/system-panel-controls.md @@ -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. diff --git a/package.json b/package.json index 5ca81615f9..909f14cadc 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index 306b6b2031..25e1d65db1 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -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", diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 4c86cb6801..d1fa6a95e0 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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 Terminal-UI locale for this run (en, zh-CN, zh-TW, fr, es, ko); the browser dashboard resolves its own language --attach Attach file(s) on task create (repeatable) --depends 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) { diff --git a/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts b/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts new file mode 100644 index 0000000000..21ce4f33bd --- /dev/null +++ b/packages/cli/src/commands/__tests__/dashboard-supervise.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index e960a5ec1b..b9fa31472d 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -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 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; + 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" }), }), ); }); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts index 95ea5247e6..c7fdca1c8f 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts +++ b/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts @@ -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"]); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/log-sink.ts b/packages/cli/src/commands/dashboard-tui/log-sink.ts index 4fd582ae28..5a314caa65 100644 --- a/packages/cli/src/commands/dashboard-tui/log-sink.ts +++ b/packages/cli/src/commands/dashboard-tui/log-sink.ts @@ -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); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 7239ec132d..8a6ca08755 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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): Promise { @@ -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[1] = {}, ): Promise { - // 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 | 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 } { + const child = spawn(command, args, { + stdio: "inherit", + env: { ...process.env, FUSION_RESTART_SUPERVISED: "1" }, + }); + const waitExit = new Promise((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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a42aeaa8b..66b4248491 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/process-supervisor.ts b/packages/core/src/process-supervisor.ts index 2888c7e767..e5e3a84de7 100644 --- a/packages/core/src/process-supervisor.ts +++ b/packages/core/src/process-supervisor.ts @@ -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; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 9434bf784a..25db4c998b 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -11380,3 +11380,112 @@ export interface ResearchStatsResponse { export function getResearchStats(projectId?: string): Promise { return api(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 { + return api("/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 { + return api("/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}`); +} diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 8e1d9dfc8c..fc6e1170d4 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -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 ; case "system": - return ; + /* + 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 ( + <> + + + + ); + 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 {})} projectId={projectId} />; case "nodes": return ; case "reliability": diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 65bba24254..544cb81858 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -1029,8 +1029,8 @@ describe("CommandCenter shell", () => { render(); 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); diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.css b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.css new file mode 100644 index 0000000000..86eecf08ab --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.css @@ -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; +} diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx new file mode 100644 index 0000000000..6c5813cf78 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx @@ -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(null); + const [infoError, setInfoError] = useState(null); + const [busyAction, setBusyAction] = useState(null); + + const [job, setJob] = useState(null); + const [jobLines, setJobLines] = useState([]); + const jobOutputRef = useRef(null); + + const [restartPhase, setRestartPhase] = useState(null); + const prevPidRef = useRef(null); + + const [logsOpen, setLogsOpen] = useState(false); + const [logEntries, setLogEntries] = useState([]); + const logOutputRef = useRef(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) => { + 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", + "", + "", + "", + "### 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 ( + <> +
+
+

{t("systemControls.title", "System controls")}

+ +
+ + {infoError ? ( +

+ {t("systemControls.infoError", "Failed to load system info: {{error}}", { error: infoError })} +

+ ) : null} + + {restartPhase === "waiting" ? ( +
+ + {t("systemControls.restartWaiting", "Restarting server — waiting for it to come back…")} +
+ ) : null} + {restartPhase === "back" ? ( +
+ {t("systemControls.restartBack", "Server is back online — reloading…")} + +
+ ) : null} + +
+ {controls.filter((control) => !("hidden" in control && control.hidden)).map((control) => ( +
+
+ + {control.title} +
+

{control.description}

+ {control.note ?

{control.note}

: null} + +
+ ))} +
+
+ + {job ? ( +
+
+

{t("systemControls.rebuildOutput", "Rebuild output")}

+ + {jobStatusLabel} + +
+
+            {jobLines.map((line) => `${line.stream === "stderr" ? "! " : ""}${line.text}`).join("\n")}
+          
+ {job.status === "failed" && job.error ? ( +

{job.error}

+ ) : null} +
+ ) : null} + +
+
+

+ + {t("systemControls.serverLogs", "Server logs")} +

+ +
+ {info && !info.logsSupported ? ( +

+ {t("systemControls.logsUnavailable", "Host-process logs are not available in this mode.")} +

+ ) : null} + {logsOpen && info?.logsSupported ? ( +
+ {logEntries.map((entry, index) => ( +
+ {formatLogTimestamp(entry.timestamp)} + {entry.prefix ? [{entry.prefix}] : null} + {entry.message} +
+ ))} + {logEntries.length === 0 ? ( +
{t("systemControls.noLogs", "No log entries yet.")}
+ ) : null} +
+ ) : null} +
+ + ); +} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 2ede55740a..55120bfa7e 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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); diff --git a/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts new file mode 100644 index 0000000000..ac791a91ed --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts @@ -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[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(); + return { + ...actual, + AgentStore: class { + constructor(_opts: unknown) {} + async init(): Promise {} + async listAgents(): Promise> { + return agentStoreState.agents; + } + }, + }; +}); + +function createLogger(): never { + const logger: Record = { + 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; + deps?: Partial[1]>; + store?: Record; +} + +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); + }); +}); diff --git a/packages/dashboard/src/routes/register-system-routes.ts b/packages/dashboard/src/routes/register-system-routes.ts new file mode 100644 index 0000000000..574b6b937b --- /dev/null +++ b/packages/dashboard/src/routes/register-system-routes.ts @@ -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; +} + +let activeJob: SystemJob | null = null; +let lastJob: SystemJob | null = null; +const jobsById = new Map(); + +/** 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 { + 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 = {}): 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; + resumeAgent?: (agentId: string, options?: { triggerDetail?: string; triggerSource?: string; clearPauseReason?: boolean }) => Promise; +} + +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 = { + // 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; + 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"); + } + }); +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 8275ca3575..03ea715d8f 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -501,6 +501,33 @@ export interface ServerOptions { key: string | Buffer; ca?: string | Buffer | Array; }; + /* + 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 { diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index 1e5256c455..c27ddb9ea0 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -100,6 +100,42 @@ async function createStoreDefault(rootDir: string): Promise { 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> { + 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)"); diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index 62f4d0ebb2..dfa63f004f 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -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); diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index b58a34913e..54cb0bf321 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -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) { diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index 46bb94b8be..c2f57b41c1 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -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() {