fix: make Windows updates and CE personas reliable (#2340)
## Summary Windows installs with slow native dependencies now get five minutes to finish, and a real timeout is reported as an actionable terminal retry instead of a wall of preceding npm deprecation warnings. Registry `ETIMEDOUT` errors keep their network diagnosis, including after the legacy-bin `--force` retry. Compound Engineering personas are now included in the published CLI bundle, with complete source-to-staged coverage for all persona definitions and a clear startup error if the bundled assets are missing or empty. The PostgreSQL statement visible in the report was validated by the existing real-Postgres schema reapply test. Its actual `caused by` detail was truncated, so this PR deliberately makes no speculative database change. ## Validation - Dashboard updater tests: 22 passed - CLI updater tests: 16 passed - CE persona installer tests: 7 passed - Published bundle persona assertion: passed against every source persona - CLI and CE plugin typechecks: passed - Changed production/config lint and strict changeset validation: passed - Real PostgreSQL schema reapply integration test: passed --- [](https://github.com/EveryInc/compound-engineering-plugin) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Windows CLI and dashboard updates now allow up to five minutes for installation and restore Compound Engineering agent personas during npm installs. * Update failures now surface clearer, terminal timeout guidance (while preserving specific network connection diagnostics) and avoid misleading “deprecated”/generic timeout text. * Persona assets are reliably included in plugin builds and bunded persona installation now errors clearly when definitions are missing or empty. * **Tests** * Expanded update and bundling coverage for the new 5-minute timeout and error-handling scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -313,6 +313,35 @@ describe("CLI bundle output", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-compound-engineering/ ships agent persona definitions", () => {
|
||||
const sourceAgentsRoot = join(
|
||||
workspaceRoot,
|
||||
"plugins",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"src",
|
||||
"agents",
|
||||
);
|
||||
const stagedAgentsRoot = join(
|
||||
cliRoot,
|
||||
"dist",
|
||||
"plugins",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"agents",
|
||||
);
|
||||
const sourceAgentFiles = readdirSync(sourceAgentsRoot)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort();
|
||||
const stagedAgentFiles = readdirSync(stagedAgentsRoot)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort();
|
||||
|
||||
expect(stagedAgentFiles).toEqual(sourceAgentFiles);
|
||||
for (const agentFile of stagedAgentFiles) {
|
||||
const stagedAgentPath = join(stagedAgentsRoot, agentFile);
|
||||
expect(readFileSync(stagedAgentPath, "utf-8")).toMatch(/^---[\s\S]*?name:\s*\S+/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not create skills directories for bundled plugins without skill sources", () => {
|
||||
const pluginId = "fusion-plugin-roadmap";
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("runUpdate", () => {
|
||||
|
||||
await runUpdate();
|
||||
|
||||
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", expect.objectContaining({ timeout: 120_000 }));
|
||||
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", expect.objectContaining({ timeout: 300_000 }));
|
||||
expect(logSpy).toHaveBeenCalledWith("Update complete.");
|
||||
});
|
||||
|
||||
@@ -184,6 +184,59 @@ describe("runUpdate", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error installing update: network down");
|
||||
});
|
||||
|
||||
it("reports a timeout instead of npm deprecation warnings", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error("Command failed"), {
|
||||
killed: true,
|
||||
signal: "SIGTERM",
|
||||
stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 5 minutes.*terminal/i));
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).toContain(
|
||||
"npm install -g @runfusion/fusion@latest",
|
||||
);
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("npm install --force");
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("deprecated");
|
||||
});
|
||||
|
||||
it("reports a timeout when the forced collision retry stalls", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock
|
||||
.mockRejectedValueOnce(new Error("npm ERR! code EEXIST\nnpm ERR! path /usr/local/bin/fn\nnpm ERR! File exists"))
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Command failed"), {
|
||||
killed: true,
|
||||
stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(execAsyncMock).toHaveBeenCalledTimes(2);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 5 minutes.*terminal/i));
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).toContain(
|
||||
"npm install --force -g @runfusion/fusion@latest",
|
||||
);
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("deprecated");
|
||||
});
|
||||
|
||||
it("preserves a registry ETIMEDOUT diagnosis", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error("connect ETIMEDOUT 10.0.0.1:443"), { killed: false }),
|
||||
);
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("connect ETIMEDOUT"));
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toMatch(/timed out after 5 minutes/i);
|
||||
});
|
||||
|
||||
|
||||
it("uses identical comparison semantics for CLI update notifications", async () => {
|
||||
/*
|
||||
|
||||
@@ -9,6 +9,7 @@ const execAsync = promisify(exec);
|
||||
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
|
||||
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
|
||||
const LOCAL_INSTALL_COMMAND = "npm install @runfusion/fusion@latest";
|
||||
const INSTALL_TIMEOUT_MS = 300_000;
|
||||
|
||||
export type RunUpdateOptions = {
|
||||
check?: boolean;
|
||||
@@ -98,7 +99,27 @@ function getInstallCommand(globalInstall: boolean, force = false): string {
|
||||
return force ? baseCommand.replace("npm install", "npm install --force") : baseCommand;
|
||||
}
|
||||
|
||||
type InstallError = Error & { stdout?: string; stderr?: string };
|
||||
type InstallError = Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
killed?: boolean;
|
||||
};
|
||||
|
||||
function isInstallTimeoutError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
/*
|
||||
FNXC:UpdateInstall 2026-07-19-09:50:
|
||||
Native npm dependencies can take longer than two minutes to install on Windows. Allow five minutes, and classify only an exec-killed process as that ceiling so registry ETIMEDOUT failures retain their real diagnosis.
|
||||
*/
|
||||
return installError?.killed === true;
|
||||
}
|
||||
|
||||
function installTimeoutError(globalInstall: boolean, force = false): Error {
|
||||
const command = getInstallCommand(globalInstall, force);
|
||||
return new Error(
|
||||
`Update timed out after ${INSTALL_TIMEOUT_MS / 60_000} minutes. Retry from a terminal with: ${command}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isBinCollisionInstallError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
@@ -143,11 +164,14 @@ function printCollisionRemediation(binaryPath: string | null): void {
|
||||
async function installLatest(globalInstall: boolean, resolveBinaryPath: () => string | null = detectRunningBinaryPath): Promise<void> {
|
||||
try {
|
||||
await execAsync(getInstallCommand(globalInstall), {
|
||||
timeout: 120_000,
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isInstallTimeoutError(error)) {
|
||||
throw installTimeoutError(globalInstall);
|
||||
}
|
||||
if (!isBinCollisionInstallError(error)) {
|
||||
throw error;
|
||||
}
|
||||
@@ -156,11 +180,14 @@ async function installLatest(globalInstall: boolean, resolveBinaryPath: () => st
|
||||
|
||||
try {
|
||||
await execAsync(getInstallCommand(globalInstall, true), {
|
||||
timeout: 120_000,
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
} catch (forceError) {
|
||||
if (isInstallTimeoutError(forceError)) {
|
||||
throw installTimeoutError(globalInstall, true);
|
||||
}
|
||||
printCollisionRemediation(resolveBinaryPath());
|
||||
throw forceError;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user