diff --git a/.changeset/fix-windows-update-ce-assets.md b/.changeset/fix-windows-update-ce-assets.md new file mode 100644 index 0000000000..ec430ec9d6 --- /dev/null +++ b/.changeset/fix-windows-update-ce-assets.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Windows updates actionable and restore Compound Engineering agent personas in npm installs. +category: fix +dev: Extends npm install timeouts to five minutes, preserves timeout causes, and stages CE persona assets. diff --git a/packages/cli/src/__tests__/bundle-output.test.ts b/packages/cli/src/__tests__/bundle-output.test.ts index 145a706b19..b5cb1d5879 100644 --- a/packages/cli/src/__tests__/bundle-output.test.ts +++ b/packages/cli/src/__tests__/bundle-output.test.ts @@ -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"; diff --git a/packages/cli/src/commands/__tests__/update.test.ts b/packages/cli/src/commands/__tests__/update.test.ts index 2f68b82e77..5681ac0052 100644 --- a/packages/cli/src/commands/__tests__/update.test.ts +++ b/packages/cli/src/commands/__tests__/update.test.ts @@ -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 () => { /* diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index f588d3828d..1f47f4b156 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -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 { 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; } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 948dd4a383..3f42bfdd8e 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -237,6 +237,17 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal console.log(`Staged plugin skills for ${pluginId} to dist/plugins/${pluginId}/skills`); } + /* + FNXC:BundledPluginAssets 2026-07-19-09:50: + Published plugins must stage persona definitions beside bundled.js so Compound Engineering skills can resolve every reviewer and research agent after an npm install. + */ + const agentsSourceDir = join(srcDir, "src", "agents"); + if (existsSync(agentsSourceDir)) { + const agentsDestDir = join(destDir, "agents"); + cpSync(agentsSourceDir, agentsDestDir, { recursive: true }); + console.log(`Staged plugin agents for ${pluginId} to dist/plugins/${pluginId}/agents`); + } + if (withMcpAsset) { const mcpServerAsset = join(srcDir, "src", "mcp-schema-server.cjs"); if (!existsSync(mcpServerAsset)) { diff --git a/packages/dashboard/src/__tests__/update-check.test.ts b/packages/dashboard/src/__tests__/update-check.test.ts index 08e5a5b967..c9cc516498 100644 --- a/packages/dashboard/src/__tests__/update-check.test.ts +++ b/packages/dashboard/src/__tests__/update-check.test.ts @@ -191,7 +191,7 @@ describe("update-check", () => { const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir }); expect(execFake).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", { - timeout: 120_000, + timeout: 300_000, maxBuffer: 10 * 1024 * 1024, }); expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true }); @@ -227,6 +227,58 @@ describe("update-check", () => { expect(execFake).toHaveBeenCalledTimes(1); }); + it("performUpdateInstall reports a timeout instead of npm deprecation warnings", async () => { + const execFake = vi.fn().mockRejectedValue( + Object.assign(new Error("Command failed"), { + killed: true, + signal: "SIGTERM", + stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.", + }), + ); + + const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir }); + + expect(result).toEqual({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updated: false, + error: expect.stringMatching(/timed out after 5 minutes.*terminal/i), + }); + expect(result.error).toContain("npm install -g @runfusion/fusion@latest"); + expect(result.error).not.toContain("npm install --force"); + expect(result.error).not.toContain("deprecated"); + }); + + it("performUpdateInstall reports a timeout when the forced collision retry stalls", async () => { + const collision = new Error("npm ERR! code EEXIST\nnpm ERR! path /usr/local/bin/fn\nnpm ERR! File exists"); + const timeout = Object.assign(new Error("Command failed"), { + killed: true, + stderr: "npm warn deprecated prebuild-install@7.1.3: No longer maintained.", + }); + const execFake = vi.fn().mockRejectedValueOnce(collision).mockRejectedValueOnce(timeout); + + const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir }); + + expect(execFake).toHaveBeenCalledTimes(2); + expect(result.error).toMatch(/timed out after 5 minutes.*terminal/i); + expect(result.error).toContain("npm install --force -g @runfusion/fusion@latest"); + expect(result.error).not.toContain("deprecated"); + }); + + it("performUpdateInstall preserves a registry ETIMEDOUT diagnosis", async () => { + const execFake = vi.fn().mockRejectedValue( + Object.assign(new Error("Command failed"), { + killed: false, + stderr: "npm error network request failed, reason: connect ETIMEDOUT 10.0.0.1:443", + }), + ); + + const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir }); + + expect(result.error).toContain("connect ETIMEDOUT"); + expect(result.error).not.toMatch(/timed out after 5 minutes/i); + }); + // FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir // (from `sudo npm i -g`) makes the non-root in-app updater fail with EACCES/ // EPERM. It must surface actionable remediation, not raw npm stderr, and must diff --git a/packages/dashboard/src/update-check.ts b/packages/dashboard/src/update-check.ts index 55b2ebd7ea..f8ad734ab0 100644 --- a/packages/dashboard/src/update-check.ts +++ b/packages/dashboard/src/update-check.ts @@ -9,7 +9,7 @@ const CACHE_FILENAME = "update-check.json"; const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion"; const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest"; const FORCE_INSTALL_COMMAND = "npm install --force -g @runfusion/fusion@latest"; -const INSTALL_TIMEOUT_MS = 120_000; +const INSTALL_TIMEOUT_MS = 300_000; const INSTALL_MAX_BUFFER = 10 * 1024 * 1024; const DAY_MS = 24 * 60 * 60 * 1000; @@ -39,7 +39,12 @@ type ExecInstall = ( options: { timeout: number; maxBuffer: number }, ) => Promise<{ stdout: string; stderr: string }>; -type InstallError = Error & { stdout?: string; stderr?: string }; +type InstallError = Error & { + stdout?: string; + stderr?: string; + code?: string | number | null; + killed?: boolean; +}; /** * Cache TTL in ms for the given frequency. Frequencies that don't expire by @@ -105,6 +110,19 @@ function getInstallErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function isInstallTimeoutError(error: unknown): boolean { + const installError = error as InstallError; + return installError?.killed === true; +} + +function getInstallTimeoutMessage(force = false): string { + const command = force ? FORCE_INSTALL_COMMAND : INSTALL_COMMAND; + return ( + `Update timed out after ${INSTALL_TIMEOUT_MS / 60_000} minutes. ` + + `Close Fusion and retry from a terminal with: ${command}` + ); +} + /* FNXC:UpdateInstallPermissions 2026-07-10-14:00: The in-app "Update now" button runs `npm install -g @runfusion/fusion@latest` as the @@ -116,7 +134,7 @@ EACCES, mirroring the CLI's Homebrew-path awareness. (`--force` cannot grant wri permission, so we do not retry it for this class — unlike bin-collision errors.) */ function isPermissionInstallError(error: unknown): boolean { - const installError = error as InstallError & { code?: string }; + const installError = error as InstallError; if (installError?.code === "EACCES" || installError?.code === "EPERM") return true; const message = [installError?.message, installError?.stderr, installError?.stdout] .filter((part): part is string => typeof part === "string" && part.length > 0) @@ -238,6 +256,19 @@ export async function performUpdateInstall( updated: true, }; } catch (error) { + /* + FNXC:UpdateInstall 2026-07-19-09:50: + Native npm dependencies can take longer than two minutes to install on Windows. Allow five minutes, and when exec kills a slow install, report the timeout metadata before npm's preceding deprecation warnings. + */ + if (isInstallTimeoutError(error)) { + return { + currentVersion, + latestVersion, + updated: false, + error: getInstallTimeoutMessage(), + }; + } + // FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir // (from `sudo npm i -g`) yields EACCES/EPERM the non-root updater cannot // recover from — return actionable guidance rather than raw npm stderr. @@ -272,7 +303,9 @@ export async function performUpdateInstall( currentVersion, latestVersion, updated: false, - error: getInstallErrorMessage(forceError), + error: isInstallTimeoutError(forceError) + ? getInstallTimeoutMessage(true) + : getInstallErrorMessage(forceError), }; } } diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/agent-installation.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/agent-installation.test.ts index 43933d94bc..cd64ed2e4e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/agent-installation.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/agent-installation.test.ts @@ -38,6 +38,25 @@ describe("compound engineering bundled agent-persona install", () => { } }); + it("fails loudly when a published package omits all bundled persona defs", () => { + const missingSourceRoot = join(tmp, "missing-agents"); + + expect(() => installBundledCeAgents({ + sourceRoot: missingSourceRoot, + targetRoot: join(tmp, ".fusion-ce-agents"), + })).toThrow(/bundled agent persona source.*missing/i); + }); + + it("fails loudly when the bundled persona directory is empty", () => { + const emptySourceRoot = join(tmp, "empty-agents"); + mkdirSync(emptySourceRoot); + + expect(() => installBundledCeAgents({ + sourceRoot: emptySourceRoot, + targetRoot: join(tmp, ".fusion-ce-agents"), + })).toThrow(/contains no markdown definitions/i); + }); + it("is idempotent when the plugin-local install provenance is current", () => { const targetRoot = join(tmp, ".fusion-ce-agents"); const first = installBundledCeAgents({ targetRoot }); diff --git a/plugins/fusion-plugin-compound-engineering/src/agent-installation.ts b/plugins/fusion-plugin-compound-engineering/src/agent-installation.ts index e14ff6980f..f0e1294cb9 100644 --- a/plugins/fusion-plugin-compound-engineering/src/agent-installation.ts +++ b/plugins/fusion-plugin-compound-engineering/src/agent-installation.ts @@ -141,9 +141,23 @@ export function installBundledCeAgents( const sourceRoot = options.sourceRoot ? resolve(options.sourceRoot) : resolveBundledAgentsRoot(); const installIsCurrent = isCurrentInstalledProvenance(targetRoot); - const sourceFiles = existsSync(sourceRoot) - ? readdirSync(sourceRoot).filter((f) => f.endsWith(".md")) - : []; + let sourceFiles: string[]; + /* + FNXC:CompoundEngineeringAgents 2026-07-19-09:50: + A published package without bundled persona definitions is corrupt. Fail at plugin startup instead of silently installing zero agents and breaking later skill fanout. + */ + try { + sourceFiles = readdirSync(sourceRoot).filter((file) => file.endsWith(".md")); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new Error(`Bundled agent persona source directory is missing: ${sourceRoot}`, { cause: error }); + } + throw error; + } + if (sourceFiles.length === 0) { + throw new Error(`Bundled agent persona source directory contains no markdown definitions: ${sourceRoot}`); + } const results = sourceFiles.map((file) => { const agentId = file.replace(/\.md$/, "");