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:
7
.changeset/FN-8226-full-rebuild-install.md
Normal file
7
.changeset/FN-8226-full-rebuild-install.md
Normal 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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user