diff --git a/.changeset/FN-8226-full-rebuild-install.md b/.changeset/FN-8226-full-rebuild-install.md new file mode 100644 index 0000000000..3ee2dfa1ec --- /dev/null +++ b/.changeset/FN-8226-full-rebuild-install.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Refresh workspace dependencies before a full System panel rebuild. +category: feature +dev: Full rebuilds run pnpm install before building and restarting. diff --git a/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts index e32ec18ab5..a16dd9eff2 100644 --- a/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; import express from "express"; -import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { request as performRequest } from "../../test-request.js"; import { registerSystemRoutes, __resetSystemJobsForTests } from "../register-system-routes.js"; @@ -134,14 +134,31 @@ function createApp(harness: HarnessOptions = {}) { const tempRoots: string[] = []; -function createFakeSourceCheckout(buildScriptBody: string): string { +function createFakeSourceCheckout(buildScriptBody: string, fullBuildScriptBody = buildScriptBody): 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); + writeFileSync(join(root, "scripts", "build-workspace.mjs"), fullBuildScriptBody); return root; } +function configureFakePnpmInstall(root: string): void { + const fakeInstall = join(root, "scripts", "fake-pnpm-install.mjs"); + writeFileSync( + fakeInstall, + "console.log('FAKE_PNPM_INSTALL_OK');\nconsole.error('FAKE_PNPM_INSTALL_STDERR');\nif (process.env.FAKE_PNPM_INSTALL_FAIL === '1') process.exit(7);\n", + ); + process.env.FUSION_SYSTEM_PNPM_BIN = process.execPath; + process.env.FUSION_SYSTEM_PNPM_ARGS = JSON.stringify([fakeInstall]); +} + +afterEach(() => { + delete process.env.FUSION_SYSTEM_PNPM_BIN; + delete process.env.FUSION_SYSTEM_PNPM_ARGS; + delete process.env.FAKE_PNPM_INSTALL_FAIL; +}); + afterAll(() => { for (const root of tempRoots) { rmSync(root, { recursive: true, force: true }); @@ -278,6 +295,79 @@ describe("POST /system/rebuild", () => { expect(requestRestart).not.toHaveBeenCalled(); }); + it("runs full install then build, streams both outputs through SSE, and schedules restart", async () => { + const root = createFakeSourceCheckout("console.log('APP_BUILD_RAN');\n", "console.log('FAKE_BUILD_RAN');\n"); + configureFakePnpmInstall(root); + const requestRestart = vi.fn(() => true); + const { app } = createApp({ + options: { systemControl: { supervised: true, requestRestart, sourceWorkspaceRoot: root } }, + }); + + const started = await postJson(app, "/api/system/rebuild", { scope: "full", restart: true }); + expect(started.status).toBe(202); + + 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("FAKE_PNPM_INSTALL_OK"); + expect(lineTexts).toContain("FAKE_BUILD_RAN"); + expect(lineTexts.indexOf("FAKE_PNPM_INSTALL_OK")).toBeLessThan(lineTexts.indexOf("FAKE_BUILD_RAN")); + expect(requestRestart).toHaveBeenCalledWith("rebuild:full"); + + const stream = openSseStream(app, `/api/system/jobs/${started.body.id}/stream`); + const replay = stream.text(); + expect(replay).toContain('event: line'); + expect(replay).toContain('"stream":"stdout","text":"FAKE_PNPM_INSTALL_OK"'); + expect(replay).toContain('event: end'); + }); + + it("fails full rebuild when dependency install fails without building or restarting", async () => { + const root = createFakeSourceCheckout("console.log('APP_BUILD_RAN');\n", "console.log('FAKE_BUILD_RAN');\n"); + configureFakePnpmInstall(root); + process.env.FAKE_PNPM_INSTALL_FAIL = "1"; + const requestRestart = vi.fn(() => true); + const { app } = createApp({ + options: { systemControl: { supervised: true, requestRestart, sourceWorkspaceRoot: root } }, + }); + + await postJson(app, "/api/system/rebuild", { scope: "full" }); + 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 }); + + const current = await getJson(app, "/api/system/rebuild/current"); + const lineTexts = current.body.job.lines.map((line: { text: string }) => line.text); + expect(lineTexts).toContain("FAKE_PNPM_INSTALL_OK"); + expect(lineTexts).not.toContain("FAKE_BUILD_RAN"); + expect(requestRestart).not.toHaveBeenCalled(); + }); + + it("keeps app and plugins rebuilds as single build commands", async () => { + const root = createFakeSourceCheckout("console.log('APP_BUILD_RAN');\n", "console.log('FAKE_BUILD_RAN');\n"); + configureFakePnpmInstall(root); + const { app } = createApp({ + options: { systemControl: { supervised: true, requestRestart: vi.fn(() => true), sourceWorkspaceRoot: root } }, + }); + + for (const [scope, expectedOutput] of [["app", "APP_BUILD_RAN"], ["plugins", "FAKE_BUILD_RAN"]] as const) { + await postJson(app, "/api/system/rebuild", { scope, restart: false }); + 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(expectedOutput); + expect(lineTexts).not.toContain("Installing workspace dependencies (pnpm install)…"); + expect(lineTexts).not.toContain("FAKE_PNPM_INSTALL_OK"); + } + }); + it("rejects a second concurrent rebuild", async () => { const root = createFakeSourceCheckout("setTimeout(() => {}, 400);\n"); const { app } = createApp({ diff --git a/packages/dashboard/src/routes/register-system-routes.ts b/packages/dashboard/src/routes/register-system-routes.ts index 340da44163..0f1ac32f8a 100644 --- a/packages/dashboard/src/routes/register-system-routes.ts +++ b/packages/dashboard/src/routes/register-system-routes.ts @@ -356,85 +356,147 @@ export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDep 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 }); + const spawnOptions = { + cwd: root, + stdio: ["ignore", "pipe", "pipe"] as Array<"ignore" | "pipe">, + maxLifetimeMs: REBUILD_MAX_LIFETIME_MS, + env: { ...process.env, FUSION_SKIP_STARTUP_UPDATE_PREFLIGHT: "1", FORCE_COLOR: "0" }, + }; - 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 }); - }).catch((err) => { + const failPostProcessing = (err: unknown): void => { // Never let an unexpected throw in the completion chain strand activeJob // (which would 409 every subsequent rebuild until process restart). const message = err instanceof Error ? err.message : String(err); appendJobLine(job, "system", `Rebuild post-processing failed: ${message}`); finishJob(job, "failed", { error: message }); log.error("System rebuild post-processing failed", { jobId: job.id, scope, error: message }); - }); + }; + const startBuild = (): void => { + appendJobLine(job, "system", `Starting ${label} build (${scope})…`); + let child: ReturnType; + try { + child = superviseSpawn(process.execPath, args.map((a, i) => (i === 0 ? scriptPath : a)), spawnOptions); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + appendJobLine(job, "system", `Failed to spawn build: ${message}`); + finishJob(job, "failed", { error: message }); + return; + } + + 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 }); + }).catch(failPostProcessing); + }; + + /* + FNXC:SystemPanel 2026-07-17-12:35: + A full rebuild must refresh workspace dependencies before compiling so a changed lockfile or new dependency cannot build against stale node_modules. A failed install aborts both the build and restart. FUSION_SYSTEM_PNPM_BIN and FUSION_SYSTEM_PNPM_ARGS provide a validated test-only command seam; production resolves to `pnpm install`. + */ + if (scope === "full") { + const pnpmArgsRaw = process.env.FUSION_SYSTEM_PNPM_ARGS; + let pnpmPreArgs: string[] = []; + if (pnpmArgsRaw) { + try { + const parsed: unknown = JSON.parse(pnpmArgsRaw); + if (!Array.isArray(parsed) || !parsed.every((arg) => typeof arg === "string")) { + throw new Error("FUSION_SYSTEM_PNPM_ARGS must be a JSON array of strings"); + } + pnpmPreArgs = parsed; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + appendJobLine(job, "system", `Dependency install configuration failed: ${message}`); + finishJob(job, "failed", { error: message }); + res.status(202).json(jobSnapshot(job, false)); + return; + } + } + + appendJobLine(job, "system", "Installing workspace dependencies (pnpm install)…"); + let install: ReturnType; + try { + install = superviseSpawn(process.env.FUSION_SYSTEM_PNPM_BIN ?? "pnpm", [...pnpmPreArgs, "install"], { + ...spawnOptions, + shell: process.platform === "win32", + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + appendJobLine(job, "system", `Failed to spawn dependency install: ${message}`); + finishJob(job, "failed", { error: message }); + res.status(202).json(jobSnapshot(job, false)); + return; + } + + const installStdout = createLineSplitter((text) => appendJobLine(job, "stdout", text)); + const installStderr = createLineSplitter((text) => appendJobLine(job, "stderr", text)); + install.child.stdout?.on("data", (chunk: Buffer) => installStdout.push(chunk)); + install.child.stderr?.on("data", (chunk: Buffer) => installStderr.push(chunk)); + void install.waitExit().then((exit) => { + installStdout.flush(); + installStderr.flush(); + const code = exit.code ?? (exit.signal ? 1 : 0); + if (code !== 0) { + appendJobLine(job, "system", `Dependency install failed (exit ${exit.code ?? exit.signal ?? "unknown"})`); + finishJob(job, "failed", { exitCode: exit.code, error: `Dependency install exited with ${exit.code ?? exit.signal}` }); + return; + } + appendJobLine(job, "system", "Workspace dependencies installed."); + startBuild(); + }).catch(failPostProcessing); + } else { + startBuild(); + } + + log.info("System rebuild started", { jobId: job.id, scope, restartAfter }); res.status(202).json(jobSnapshot(job, false)); });