fix: clear stale Next.js cache on version upgrade (#1022)

* fix: clear stale Next.js cache on version upgrade (#986)

After upgrading @composio/ao via npm, `ao start` served the old UI because
Next.js runtime cache (.next/cache) persisted from the previous version.

Adds a hybrid fix:
- Postinstall hook clears .next/cache and writes a version stamp
- Runtime guard in `ao start` and `ao dashboard` compares stamp against
  package version; on mismatch, clears .next/cache and restamps
- Build-time script writes stamp after `next build` (monorepo path)

Only .next/cache is deleted — shipped build artifacts (.next/server,
.next/static, BUILD_ID) are never touched, keeping npm installs intact.

Closes #986

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(lint): add Node.js globals for package-level scripts

The ESLint config only covered root-level scripts/, not
packages/*/scripts/. This caused `no-undef` errors for `console`
and `process` in packages/web/scripts/stamp-version.js.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ensure postinstall cache clearing runs on all platforms

Restructure postinstall.js so the node-pty chmod fix is wrapped in a
conditional block instead of using early process.exit(0). The previous
exits on Windows, missing node-pty, or missing spawn-helper prevented
the cache-clearing code from ever running on those systems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review comments on stamp-version ordering and cache catch logging

- Move stamp-version.js after tsc in web build script so a tsc failure
  does not leave a fresh stamp paired with a stale server bundle.
- Log skipped cache version checks via console.debug instead of swallowing
  silently, to aid debugging without blocking dashboard startup.

Addresses review feedback from @illegalcall on PR #1022.

* fix: resolve ao-web in postinstall cache cleanup

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ashish Huddar 2026-05-01 12:23:51 +05:30 committed by GitHub
parent 64f67f3425
commit 2e4583b7bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 364 additions and 47 deletions

View File

@ -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",

View File

@ -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}`);
}

View File

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

View File

@ -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(),

View File

@ -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",
);
});
});

View File

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

View File

@ -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");

View File

@ -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<void> {
}
}
/**
* 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<void> {
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.

View File

@ -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",

View File

@ -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");