FN-8226: install dependencies before full rebuilds

Full System rebuilds now refresh workspace dependencies before compiling and restarting.

- Run and stream pnpm install before full builds
- Abort builds and restarts when dependency installation fails
- Cover install sequencing, failure handling, and scoped rebuild behavior

Files changed:
 .changeset/FN-8226-full-rebuild-install.md         |   7 +
 .../__tests__/register-system-routes.test.ts       |  94 ++++++++++-
 .../dashboard/src/routes/register-system-routes.ts | 180 ++++++++++++++-------
 3 files changed, 220 insertions(+), 61 deletions(-)

Fusion-Task-Id: FN-8226

Fusion-Task-Lineage: 15d178d6-5052-445a-8376-bd5f32fc38c4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 12:59:34 -07:00
parent 2e9c255268
commit 9b9d6a2e72
3 changed files with 232 additions and 73 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -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<typeof superviseSpawn>;
try {
child = superviseSpawn(process.execPath, args.map((a, i) => (i === 0 ? scriptPath : a)), {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
maxLifetimeMs: REBUILD_MAX_LIFETIME_MS,
env: { ...process.env, FUSION_SKIP_STARTUP_UPDATE_PREFLIGHT: "1", FORCE_COLOR: "0" },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
appendJobLine(job, "system", `Failed to spawn build: ${message}`);
finishJob(job, "failed", { error: message });
rethrowAsApiError(err, "Failed to start rebuild");
return; // unreachable — rethrowAsApiError always throws
}
const stdout = createLineSplitter((text) => appendJobLine(job, "stdout", text));
const stderr = createLineSplitter((text) => appendJobLine(job, "stderr", text));
child.child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
child.child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
void child.waitExit().then(async (exit) => {
stdout.flush();
stderr.flush();
const code = exit.code ?? (exit.signal ? 1 : 0);
if (code !== 0) {
appendJobLine(job, "system", `Build failed (exit ${exit.code ?? exit.signal ?? "unknown"})`);
finishJob(job, "failed", { exitCode: exit.code, error: `Build exited with ${exit.code ?? exit.signal}` });
log.warn("System rebuild failed", { jobId: job.id, scope, exitCode: exit.code, signal: exit.signal ?? undefined });
return;
}
appendJobLine(job, "system", "Build succeeded.");
if (scope === "plugins") {
try {
const result = await reloadStartedPlugins();
appendJobLine(
job,
"system",
`Reloaded ${result.reloaded.length} plugin(s)${result.failed.length ? `, ${result.failed.length} failed` : ""}.`,
);
for (const failure of result.failed) {
appendJobLine(job, "system", `Plugin reload failed: ${failure.id} — ${failure.error}`);
}
finishJob(job, "succeeded", { exitCode: 0, pluginsReloaded: result.reloaded });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
appendJobLine(job, "system", `Plugin reload unavailable: ${message}`);
finishJob(job, "succeeded", { exitCode: 0, pluginsReloaded: [] });
}
return;
}
let restartScheduled = false;
if (restartAfter && systemControl) {
restartScheduled = systemControl.requestRestart(`rebuild:${scope}`);
appendJobLine(
job,
"system",
restartScheduled
? "Restarting server…"
: "Restart not available (no supervising parent) — restart manually to pick up the build.",
);
}
finishJob(job, "succeeded", { exitCode: 0, restartScheduled });
log.info("System rebuild succeeded", { jobId: job.id, scope, restartScheduled });
}).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<typeof superviseSpawn>;
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<typeof superviseSpawn>;
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));
});