diff --git a/eslint.config.js b/eslint.config.js index 6fe150b24..86d1cd57f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -101,7 +101,7 @@ export default tseslint.config( // Scripts directory - Node.js environment { - files: ["scripts/**/*.js", "scripts/**/*.mjs"], + files: ["scripts/**/*.js", "scripts/**/*.mjs", "packages/*/scripts/**/*.js"], languageOptions: { globals: { console: "readonly", diff --git a/packages/ao/bin/postinstall.js b/packages/ao/bin/postinstall.js index f43de6ad6..feb8f21e2 100644 --- a/packages/ao/bin/postinstall.js +++ b/packages/ao/bin/postinstall.js @@ -10,16 +10,17 @@ * 2. Verifies the prebuilt binary is compatible with the current Node.js version. * If not (common with nvm/fnm/volta), rebuilds from source via npx node-gyp. * See: https://github.com/ComposioHQ/agent-orchestrator/issues/987 + * + * 3. Clears stale Next.js runtime cache (.next/cache) from @composio/ao-web + * after a version upgrade, so `ao start` serves fresh dashboard assets. + * Writes a version stamp (.next/AO_VERSION) to skip cleanup on subsequent runs. */ -import { chmodSync, existsSync } from "node:fs"; +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -// No-op on Windows — different PTY mechanism -if (process.platform === "win32") process.exit(0); - const __dirname = dirname(fileURLToPath(import.meta.url)); function findPackageUp(startDir, ...segments) { @@ -34,50 +35,92 @@ function findPackageUp(startDir, ...segments) { return null; } -const nodePtyDir = findPackageUp(__dirname, "node-pty"); -if (!nodePtyDir) process.exit(0); +function resolveNodeModulesPackage(fromDir, ...segments) { + const packageDir = resolve(fromDir, "node_modules", ...segments); + return existsSync(resolve(packageDir, "package.json")) ? packageDir : null; +} -// Step 1: Fix spawn-helper permissions -const spawnHelper = resolve( - nodePtyDir, - "prebuilds", - `${process.platform}-${process.arch}`, - "spawn-helper", -); +function findWebDir() { + const directWebDir = findPackageUp(__dirname, "@aoagents", "ao-web"); + if (directWebDir) return directWebDir; -if (existsSync(spawnHelper)) { - try { - chmodSync(spawnHelper, 0o755); - console.log("\u2713 node-pty spawn-helper permissions set"); - } catch { - console.warn("\u26a0\ufe0f Could not set spawn-helper permissions (non-critical)"); + const cliDir = findPackageUp(__dirname, "@aoagents", "ao-cli"); + if (!cliDir) return null; + + return resolveNodeModulesPackage(cliDir, "@aoagents", "ao-web"); +} + +// --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) --- +if (process.platform !== "win32") { + const nodePtyDir = findPackageUp(__dirname, "node-pty"); + if (nodePtyDir) { + const spawnHelper = resolve( + nodePtyDir, + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ); + + if (existsSync(spawnHelper)) { + try { + chmodSync(spawnHelper, 0o755); + console.log("✓ node-pty spawn-helper permissions set"); + } catch { + console.warn("⚠️ Could not set spawn-helper permissions (non-critical)"); + } + } + + // Verify the prebuilt binary actually works with this Node.js version. + // If it doesn't (ABI mismatch from nvm/fnm/volta version switching), rebuild. + // We exercise pty.spawn() — not just require() — because the posix_spawnp + // failure only surfaces when the helper binary is actually executed. + try { + execSync( + "node -e \"var p=require('node-pty');var t=p.spawn('/bin/sh',['-c','exit 0'],{});t.kill();process.exit(0);\"", + { + cwd: resolve(nodePtyDir, ".."), + stdio: "ignore", + timeout: 10000, + }, + ); + } catch { + console.log("⚠️ node-pty prebuilt binary incompatible with Node.js " + process.version + ", rebuilding..."); + try { + execSync("npx --yes node-gyp rebuild", { + cwd: nodePtyDir, + stdio: "inherit", + timeout: 120000, + }); + console.log("✓ node-pty rebuilt successfully"); + } catch { + console.warn("⚠️ node-pty rebuild failed — web terminal may not work"); + console.warn(" Manual fix: cd " + nodePtyDir + " && npx node-gyp rebuild"); + } + } } } -// Step 2: Verify the prebuilt binary actually works with this Node.js version. -// If it doesn't (ABI mismatch from nvm/fnm/volta version switching), rebuild. -// We exercise pty.spawn() — not just require() — because the posix_spawnp -// failure only surfaces when the helper binary is actually executed. +// --- 3. Clear stale Next.js runtime cache after version upgrade --- try { - execSync( - "node -e \"var p=require('node-pty');var t=p.spawn('/bin/sh',['-c','exit 0'],{});t.kill();process.exit(0);\"", - { - cwd: resolve(nodePtyDir, ".."), - stdio: "ignore", - timeout: 10000, - }, - ); -} catch { - console.log("\u26a0\ufe0f node-pty prebuilt binary incompatible with Node.js " + process.version + ", rebuilding..."); - try { - execSync("npx --yes node-gyp rebuild", { - cwd: nodePtyDir, - stdio: "inherit", - timeout: 120000, - }); - console.log("\u2713 node-pty rebuilt successfully"); - } catch { - console.warn("\u26a0\ufe0f node-pty rebuild failed — web terminal may not work"); - console.warn(" Manual fix: cd " + nodePtyDir + " && npx node-gyp rebuild"); + const webDir = findWebDir(); + if (webDir) { + const pkgPath = resolve(webDir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + const version = pkg.version; + const cacheDir = resolve(webDir, ".next", "cache"); + const stampPath = resolve(webDir, ".next", "AO_VERSION"); + + if (existsSync(cacheDir)) { + rmSync(cacheDir, { recursive: true, force: true }); + console.log("✓ Cleared stale .next/cache"); + } + if (existsSync(resolve(webDir, ".next"))) { + writeFileSync(stampPath, version, "utf8"); + console.log(`✓ Dashboard version stamp set to ${version}`); + } + } } +} catch (err) { + console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`); } diff --git a/packages/cli/__tests__/commands/dashboard.test.ts b/packages/cli/__tests__/commands/dashboard.test.ts index 82d8da28e..881e80c2e 100644 --- a/packages/cli/__tests__/commands/dashboard.test.ts +++ b/packages/cli/__tests__/commands/dashboard.test.ts @@ -169,6 +169,64 @@ describe("rebuildDashboardProductionArtifacts", () => { }); }); +describe("clearStaleCacheIfNeeded", () => { + it("clears .next/cache and writes stamp when version differs", async () => { + const webDir = join(tmpDir, "web"); + mkdirSync(join(webDir, ".next", "cache", "webpack"), { recursive: true }); + writeFileSync(join(webDir, ".next", "AO_VERSION"), "0.1.0"); + writeFileSync(join(webDir, "package.json"), JSON.stringify({ version: "0.2.0" })); + + const { clearStaleCacheIfNeeded } = await import("../../src/lib/dashboard-rebuild.js"); + await clearStaleCacheIfNeeded(webDir); + + expect(existsSync(join(webDir, ".next", "cache"))).toBe(false); + expect(existsSync(join(webDir, ".next", "AO_VERSION"))).toBe(true); + const { readFileSync } = await import("node:fs"); + expect(readFileSync(join(webDir, ".next", "AO_VERSION"), "utf8")).toBe("0.2.0"); + }); + + it("clears cache when stamp is missing (upgrade from old version)", async () => { + const webDir = join(tmpDir, "web"); + mkdirSync(join(webDir, ".next", "cache"), { recursive: true }); + writeFileSync(join(webDir, "package.json"), JSON.stringify({ version: "0.2.0" })); + + const { clearStaleCacheIfNeeded } = await import("../../src/lib/dashboard-rebuild.js"); + await clearStaleCacheIfNeeded(webDir); + + expect(existsSync(join(webDir, ".next", "cache"))).toBe(false); + expect(existsSync(join(webDir, ".next", "AO_VERSION"))).toBe(true); + }); + + it("is a no-op when version matches", async () => { + const webDir = join(tmpDir, "web"); + mkdirSync(join(webDir, ".next", "cache", "webpack"), { recursive: true }); + writeFileSync(join(webDir, ".next", "AO_VERSION"), "0.2.0"); + writeFileSync(join(webDir, "package.json"), JSON.stringify({ version: "0.2.0" })); + + const { clearStaleCacheIfNeeded } = await import("../../src/lib/dashboard-rebuild.js"); + await clearStaleCacheIfNeeded(webDir); + + // cache should still exist + expect(existsSync(join(webDir, ".next", "cache", "webpack"))).toBe(true); + }); + + it("leaves .next/server and .next/static intact", async () => { + const webDir = join(tmpDir, "web"); + mkdirSync(join(webDir, ".next", "cache"), { recursive: true }); + mkdirSync(join(webDir, ".next", "server"), { recursive: true }); + mkdirSync(join(webDir, ".next", "static"), { recursive: true }); + writeFileSync(join(webDir, ".next", "AO_VERSION"), "0.1.0"); + writeFileSync(join(webDir, "package.json"), JSON.stringify({ version: "0.2.0" })); + + const { clearStaleCacheIfNeeded } = await import("../../src/lib/dashboard-rebuild.js"); + await clearStaleCacheIfNeeded(webDir); + + expect(existsSync(join(webDir, ".next", "cache"))).toBe(false); + expect(existsSync(join(webDir, ".next", "server"))).toBe(true); + expect(existsSync(join(webDir, ".next", "static"))).toBe(true); + }); +}); + describe("looksLikeStaleBuild pattern matching", () => { // We can't import the private function directly, so we replicate the patterns // to ensure the detection logic catches the actual error messages seen in production. diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 58d0b1d14..a3fc07867 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -160,6 +160,7 @@ vi.mock("../../src/lib/web-dir.js", () => ({ })); vi.mock("../../src/lib/dashboard-rebuild.js", () => ({ + clearStaleCacheIfNeeded: vi.fn().mockResolvedValue(undefined), findRunningDashboardPid: vi.fn().mockResolvedValue(null), rebuildDashboardProductionArtifacts: vi.fn().mockResolvedValue(undefined), waitForPortFree: vi.fn(), diff --git a/packages/cli/__tests__/lib/dashboard-rebuild.test.ts b/packages/cli/__tests__/lib/dashboard-rebuild.test.ts new file mode 100644 index 000000000..551166479 --- /dev/null +++ b/packages/cli/__tests__/lib/dashboard-rebuild.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockExistsSync, mockReadFileSync, mockRmSync, mockWriteFileSync } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockRmSync: vi.fn(), + mockWriteFileSync: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + rmSync: mockRmSync, + writeFileSync: mockWriteFileSync, +})); + +vi.mock("ora", () => ({ + default: () => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + }), +})); + +vi.mock("../../src/lib/shell.js", () => ({ + exec: vi.fn(), + execSilent: vi.fn(), +})); + +import { clearStaleCacheIfNeeded, isInstalledUnderNodeModules } from "../../src/lib/dashboard-rebuild.js"; + +beforeEach(() => { + mockExistsSync.mockReset(); + mockReadFileSync.mockReset(); + mockRmSync.mockReset(); + mockWriteFileSync.mockReset(); +}); + +describe("isInstalledUnderNodeModules", () => { + it("returns true for paths with node_modules segment", () => { + expect(isInstalledUnderNodeModules("/usr/local/lib/node_modules/@composio/ao-web")).toBe(true); + }); + + it("returns false for monorepo paths", () => { + expect(isInstalledUnderNodeModules("/home/user/agent-orchestrator/packages/web")).toBe(false); + }); +}); + +describe("clearStaleCacheIfNeeded", () => { + it("does nothing when package.json does not exist", async () => { + mockExistsSync.mockReturnValue(false); + await clearStaleCacheIfNeeded("/web"); + expect(mockRmSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("does nothing when stamp matches current version", async () => { + // existsSync: package.json → true, AO_VERSION → true + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((path: string) => { + if (path.includes("package.json")) return JSON.stringify({ version: "0.2.2" }); + if (path.includes("AO_VERSION")) return "0.2.2"; + return ""; + }); + + await clearStaleCacheIfNeeded("/web"); + expect(mockRmSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("clears cache and updates stamp when version differs", async () => { + // existsSync: package.json, AO_VERSION, .next/cache, .next → all true + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((path: string) => { + if (path.includes("package.json")) return JSON.stringify({ version: "0.3.0" }); + if (path.includes("AO_VERSION")) return "0.2.2"; + return ""; + }); + + await clearStaleCacheIfNeeded("/web"); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("cache"), + { recursive: true, force: true }, + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining("AO_VERSION"), + "0.3.0", + "utf8", + ); + }); + + it("clears cache when stamp file is missing (upgrade from old version)", async () => { + mockExistsSync.mockImplementation((path: string) => { + if (path.includes("AO_VERSION")) return false; + return true; // package.json, .next/cache, .next all exist + }); + mockReadFileSync.mockImplementation((path: string) => { + if (path.includes("package.json")) return JSON.stringify({ version: "0.3.0" }); + return ""; + }); + + await clearStaleCacheIfNeeded("/web"); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("cache"), + { recursive: true, force: true }, + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining("AO_VERSION"), + "0.3.0", + "utf8", + ); + }); + + it("writes stamp but skips rmSync when no cache dir exists", async () => { + mockExistsSync.mockImplementation((path: string) => { + if (path.includes("cache")) return false; + if (path.includes("AO_VERSION")) return false; + return true; // package.json, .next exist + }); + mockReadFileSync.mockImplementation((path: string) => { + if (path.includes("package.json")) return JSON.stringify({ version: "0.3.0" }); + return ""; + }); + + await clearStaleCacheIfNeeded("/web"); + expect(mockRmSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining("AO_VERSION"), + "0.3.0", + "utf8", + ); + }); +}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index b92734e1b..2883a410a 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -5,6 +5,7 @@ import type { Command } from "commander"; import { loadConfig } from "@aoagents/ao-core"; import { findWebDir, buildDashboardEnv, waitForPortAndOpen } from "../lib/web-dir.js"; import { + clearStaleCacheIfNeeded, findRunningDashboardPid, isInstalledUnderNodeModules, rebuildDashboardProductionArtifacts, @@ -54,6 +55,7 @@ export function registerDashboard(program: Command): void { // Fall through to start the dashboard on this port. } else { await preflight.checkBuilt(localWebDir); + await clearStaleCacheIfNeeded(localWebDir); } const webDir = localWebDir; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 293cf1a7b..e216faef7 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -58,7 +58,10 @@ import { findFreePort, MAX_PORT_SCAN, } from "../lib/web-dir.js"; -import { rebuildDashboardProductionArtifacts } from "../lib/dashboard-rebuild.js"; +import { + clearStaleCacheIfNeeded, + rebuildDashboardProductionArtifacts, +} from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; import { register, @@ -1258,6 +1261,7 @@ async function runStartup( await rebuildDashboardProductionArtifacts(webDir); } else if (!willUseDevServer) { await preflight.checkBuilt(webDir); + await clearStaleCacheIfNeeded(webDir); } spinner.start("Starting dashboard"); diff --git a/packages/cli/src/lib/dashboard-rebuild.ts b/packages/cli/src/lib/dashboard-rebuild.ts index 453776c4a..02acbb500 100644 --- a/packages/cli/src/lib/dashboard-rebuild.ts +++ b/packages/cli/src/lib/dashboard-rebuild.ts @@ -4,7 +4,7 @@ */ import { resolve } from "node:path"; -import { existsSync, rmSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import ora from "ora"; import { exec, execSilent } from "./shell.js"; @@ -69,6 +69,52 @@ export async function cleanNextCache(webDir: string): Promise { } } +/** + * Compare the .next/AO_VERSION stamp against the current web package version. + * If they differ (or the stamp is missing), clear the .next/cache directory + * so Next.js doesn't serve stale pages after a version upgrade. + */ +export async function clearStaleCacheIfNeeded(webDir: string): Promise { + try { + const stampPath = resolve(webDir, ".next", "AO_VERSION"); + const pkgPath = resolve(webDir, "package.json"); + + if (!existsSync(pkgPath)) return; + + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }; + const currentVersion = pkg.version; + + if (!currentVersion) return; + + let needsClear = false; + + if (!existsSync(stampPath)) { + needsClear = true; + } else { + const stamp = readFileSync(stampPath, "utf8").trim(); + needsClear = stamp !== currentVersion; + } + + if (needsClear) { + const cacheDir = resolve(webDir, ".next", "cache"); + if (existsSync(cacheDir)) { + const spinner = ora(); + spinner.start("Clearing stale Next.js cache (version upgrade detected)"); + rmSync(cacheDir, { recursive: true, force: true }); + spinner.succeed(`Cleared stale .next cache → v${currentVersion}`); + } + // Update stamp so subsequent starts skip the check + const nextDir = resolve(webDir, ".next"); + if (existsSync(nextDir)) { + writeFileSync(stampPath, currentVersion, "utf8"); + } + } + } catch (err) { + // Best-effort cache cleanup — never prevent dashboard from starting + console.debug("Cache version check skipped:", err); + } +} + /** * Rebuild dashboard production artifacts (Next.js build + server compilation) * from a source checkout. Throws if called from an npm global install. diff --git a/packages/web/package.json b/packages/web/package.json index dd86c297e..e6251c89b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -16,7 +16,7 @@ "dev:next": "next dev -p ${PORT:-3000}", "dev:direct-terminal": "node scripts/dev-direct-terminal.mjs", "prebuild": "rimraf .next dist-server", - "build": "next build && tsc -p tsconfig.server.json", + "build": "next build && tsc -p tsconfig.server.json && node scripts/stamp-version.js", "start": "next start", "start:all": "node dist-server/start-all.js", "dev:optimized": "rimraf .next dist-server && next build && tsc -p tsconfig.server.json && node dist-server/start-all.js", diff --git a/packages/web/scripts/stamp-version.js b/packages/web/scripts/stamp-version.js new file mode 100644 index 000000000..dbd1c548c --- /dev/null +++ b/packages/web/scripts/stamp-version.js @@ -0,0 +1,30 @@ +/** + * Writes the current package version to .next/AO_VERSION after a Next.js build. + * This stamp is compared at `ao start` to detect stale runtime caches from + * a previous version and clear them automatically. + */ + +import { writeFileSync, readFileSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const nextDir = resolve(__dirname, "..", ".next"); + +if (!existsSync(nextDir)) { + console.warn("stamp-version: .next directory not found — skipping stamp"); + process.exit(0); +} + +const pkgPath = resolve(__dirname, "..", "package.json"); +if (!existsSync(pkgPath)) { + console.warn("stamp-version: package.json not found — skipping stamp"); + process.exit(0); +} + +const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); +if (!pkg.version) { + console.warn("stamp-version: no version field in package.json — skipping stamp"); + process.exit(0); +} +writeFileSync(resolve(nextDir, "AO_VERSION"), pkg.version, "utf8");