diff --git a/.changeset/quiet-sqlite-rebuild.md b/.changeset/quiet-sqlite-rebuild.md new file mode 100644 index 000000000..79dbfd120 --- /dev/null +++ b/.changeset/quiet-sqlite-rebuild.md @@ -0,0 +1,7 @@ +--- +"@aoagents/ao": patch +"@aoagents/ao-core": patch +"@aoagents/ao-cli": patch +--- + +Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic. diff --git a/packages/ao/bin/postinstall.js b/packages/ao/bin/postinstall.js index feb8f21e2..58caae5fc 100644 --- a/packages/ao/bin/postinstall.js +++ b/packages/ao/bin/postinstall.js @@ -11,19 +11,28 @@ * 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 + * 3. Verifies better-sqlite3 has a native binding for this Node ABI. + * Node majors can ship new NODE_MODULE_VERSION values before better-sqlite3 + * publishes matching prebuilds; global installs must rebuild from source. + * See: https://github.com/ComposioHQ/agent-orchestrator/issues/1822 + * + * 4. 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, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -function findPackageUp(startDir, ...segments) { +function isWindows() { + return process.platform === "win32"; +} + +export function findPackageUp(startDir, ...segments) { let dir = resolve(startDir); while (true) { const candidate = resolve(dir, "node_modules", ...segments); @@ -35,12 +44,12 @@ function findPackageUp(startDir, ...segments) { return null; } -function resolveNodeModulesPackage(fromDir, ...segments) { +export function resolveNodeModulesPackage(fromDir, ...segments) { const packageDir = resolve(fromDir, "node_modules", ...segments); return existsSync(resolve(packageDir, "package.json")) ? packageDir : null; } -function findWebDir() { +export function findWebDir() { const directWebDir = findPackageUp(__dirname, "@aoagents", "ao-web"); if (directWebDir) return directWebDir; @@ -50,8 +59,112 @@ function findWebDir() { return resolveNodeModulesPackage(cliDir, "@aoagents", "ao-web"); } -// --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) --- -if (process.platform !== "win32") { +export function findBetterSqlite3Dir() { + const directBetterSqlite3Dir = findPackageUp(__dirname, "better-sqlite3"); + if (directBetterSqlite3Dir) return directBetterSqlite3Dir; + + const cliDir = findPackageUp(__dirname, "@aoagents", "ao-cli"); + if (!cliDir) return null; + + const coreDir = resolveNodeModulesPackage(cliDir, "@aoagents", "ao-core"); + if (!coreDir) return null; + + return ( + resolveNodeModulesPackage(coreDir, "better-sqlite3") ?? findPackageUp(coreDir, "better-sqlite3") + ); +} + +export function betterSqlite3BindingCandidates( + packageDir, + { + platform = process.platform, + arch = process.arch, + modules = process.versions.modules, + nodeVersion = process.versions.node, + } = {}, +) { + return [ + resolve(packageDir, "build", "better_sqlite3.node"), + resolve(packageDir, "build", "Debug", "better_sqlite3.node"), + resolve(packageDir, "build", "Release", "better_sqlite3.node"), + resolve(packageDir, "out", "Debug", "better_sqlite3.node"), + resolve(packageDir, "Debug", "better_sqlite3.node"), + resolve(packageDir, "out", "Release", "better_sqlite3.node"), + resolve(packageDir, "Release", "better_sqlite3.node"), + resolve(packageDir, "build", "default", "better_sqlite3.node"), + resolve(packageDir, "compiled", nodeVersion, platform, arch, "better_sqlite3.node"), + resolve(packageDir, "addon-build", "release", "install-root", "better_sqlite3.node"), + resolve(packageDir, "addon-build", "debug", "install-root", "better_sqlite3.node"), + resolve(packageDir, "addon-build", "default", "install-root", "better_sqlite3.node"), + resolve( + packageDir, + "lib", + "binding", + `node-v${modules}-${platform}-${arch}`, + "better_sqlite3.node", + ), + ]; +} + +export function hasBetterSqlite3Binding(packageDir, options = {}) { + const fileExists = options.existsSync ?? existsSync; + return betterSqlite3BindingCandidates(packageDir, options).some((candidate) => + fileExists(candidate), + ); +} + +export function betterSqlite3RebuildCommand(packageDir, env = process.env) { + const packageManager = + `${env.npm_config_user_agent ?? ""} ${env.npm_execpath ?? ""}`.toLowerCase(); + if (packageManager.includes("npm") && !packageManager.includes("pnpm")) { + return { command: "npm", args: ["rebuild"], display: `cd ${packageDir} && npm rebuild` }; + } + return { + command: "pnpm", + args: ["--dir", packageDir, "rebuild"], + display: `pnpm --dir ${packageDir} rebuild`, + }; +} + +function checkBetterSqlite3Binding() { + const betterSqlite3Dir = findBetterSqlite3Dir(); + if (!betterSqlite3Dir) { + console.warn( + "⚠️ better-sqlite3 package not found; skipping activity-events native binding check", + ); + return; + } + + const abi = process.versions.modules; + if (hasBetterSqlite3Binding(betterSqlite3Dir)) { + console.log( + `✓ better-sqlite3 native binding present for Node ${process.version} (ABI v${abi})`, + ); + return; + } + + const { command, args, display } = betterSqlite3RebuildCommand(betterSqlite3Dir); + try { + execFileSync(command, args, { + cwd: betterSqlite3Dir, + stdio: "ignore", + timeout: 120000, + shell: isWindows(), + windowsHide: true, + }); + console.log( + `✓ better-sqlite3 native binding rebuilt for Node ${process.version} (ABI v${abi})`, + ); + } catch { + console.warn( + `⚠️ better-sqlite3 rebuild failed for Node ${process.version} (ABI v${abi}) — activity events may be unavailable. Manual fix: ${display}`, + ); + } +} + +function fixNodePty() { + if (isWindows()) return; + const nodePtyDir = findPackageUp(__dirname, "node-pty"); if (nodePtyDir) { const spawnHelper = resolve( @@ -84,7 +197,11 @@ if (process.platform !== "win32") { }, ); } catch { - console.log("⚠️ node-pty prebuilt binary incompatible with Node.js " + process.version + ", rebuilding..."); + console.log( + "⚠️ node-pty prebuilt binary incompatible with Node.js " + + process.version + + ", rebuilding...", + ); try { execSync("npx --yes node-gyp rebuild", { cwd: nodePtyDir, @@ -100,27 +217,43 @@ if (process.platform !== "win32") { } } -// --- 3. Clear stale Next.js runtime cache after version upgrade --- -try { - 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"); +function clearDashboardCache() { + try { + 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}`); + 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}`); } -} catch (err) { - console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`); +} + +export function runPostinstall() { + // --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) --- + fixNodePty(); + + // --- 3. Ensure better-sqlite3 has a native binding for this Node ABI --- + checkBetterSqlite3Binding(); + + // --- 4. Clear stale Next.js runtime cache after version upgrade --- + clearDashboardCache(); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runPostinstall(); } diff --git a/packages/cli/__tests__/scripts/postinstall.test.ts b/packages/cli/__tests__/scripts/postinstall.test.ts new file mode 100644 index 000000000..9d277e854 --- /dev/null +++ b/packages/cli/__tests__/scripts/postinstall.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + betterSqlite3BindingCandidates, + betterSqlite3RebuildCommand, + hasBetterSqlite3Binding, +} from "../../../ao/bin/postinstall.js"; + +describe("ao postinstall better-sqlite3 native binding detection", () => { + const env = { + platform: "darwin", + arch: "arm64", + modules: "141", + nodeVersion: "25.9.0", + }; + + it("checks the current Node ABI binding path", () => { + const packageDir = "virtual-better-sqlite3"; + const candidates = betterSqlite3BindingCandidates(packageDir, env); + + expect(candidates.some((candidate) => candidate.includes("node-v141-darwin-arm64"))).toBe(true); + }); + + it("reports the binding present when a mocked candidate exists", () => { + const packageDir = "virtual-better-sqlite3"; + const candidates = betterSqlite3BindingCandidates(packageDir, env); + const currentAbiBinding = candidates.find((candidate) => + candidate.includes("node-v141-darwin-arm64"), + ); + if (!currentAbiBinding) { + throw new Error("expected current ABI binding candidate"); + } + + const existingFiles = new Set([currentAbiBinding]); + + expect( + hasBetterSqlite3Binding(packageDir, { + ...env, + existsSync: (candidate: string) => existingFiles.has(candidate), + }), + ).toBe(true); + }); + + it("reports the binding missing when no mocked candidate exists", () => { + expect( + hasBetterSqlite3Binding("virtual-better-sqlite3", { + ...env, + existsSync: () => false, + }), + ).toBe(false); + }); + + it("uses pnpm to rebuild inside the resolved better-sqlite3 package", () => { + expect( + betterSqlite3RebuildCommand("virtual-better-sqlite3", { + npm_config_user_agent: "pnpm/9.15.4 npm/? node/v25.9.0 darwin arm64", + }), + ).toEqual({ + command: "pnpm", + args: ["--dir", "virtual-better-sqlite3", "rebuild"], + display: "pnpm --dir virtual-better-sqlite3 rebuild", + }); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 0b83ed14b..3ac2696d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,7 +91,7 @@ "zod": "^3.24.0" }, "optionalDependencies": { - "better-sqlite3": "^11.0.0" + "better-sqlite3": "^12.10.0" }, "devDependencies": { "@rollup/plugin-typescript": "^12.3.0", @@ -99,8 +99,8 @@ "@types/node": "^25.2.3", "@vitest/coverage-v8": "^4.0.18", "rollup": "^4.60.1", - "tsx": "^4.21.0", "tslib": "^2.8.1", + "tsx": "^4.21.0", "typescript": "^5.7.0", "vitest": "^4.0.18" }, diff --git a/packages/core/src/__tests__/events-db.test.ts b/packages/core/src/__tests__/events-db.test.ts new file mode 100644 index 000000000..f1f21237a --- /dev/null +++ b/packages/core/src/__tests__/events-db.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + __resetActivityEventsDbWarningForTests, + emitActivityEventsDbUnavailableWarning, + formatActivityEventsDbUnavailableWarning, +} from "../events-db.js"; + +describe("activity-events DB unavailable warning", () => { + const originalArgv = process.argv; + const originalDebug = process.env["AO_DEBUG"]; + + beforeEach(() => { + __resetActivityEventsDbWarningForTests(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + delete process.env["AO_DEBUG"]; + process.argv = ["node", "ao"]; + }); + + afterEach(() => { + process.argv = originalArgv; + if (originalDebug === undefined) { + delete process.env["AO_DEBUG"]; + } else { + process.env["AO_DEBUG"] = originalDebug; + } + vi.restoreAllMocks(); + }); + + it("formats missing native binding errors without the bindings search path", () => { + const message = formatActivityEventsDbUnavailableWarning( + new Error( + "Could not locate the bindings file. Tried:\n → /tmp/build/Release/better_sqlite3.node", + ), + ); + + expect(message).toBe( + `[ao] activity-events disabled: better-sqlite3 not compiled for Node ${process.version} (ABI v${process.versions.modules}). Run \`pnpm rebuild better-sqlite3\` or use a supported Node version.`, + ); + expect(message).not.toContain("Tried:"); + expect(message).not.toContain("/tmp/build/Release"); + }); + + it("prints the runtime warning once per process", () => { + process.env["AO_DEBUG"] = "1"; + const err = new Error( + "Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node", + ); + + emitActivityEventsDbUnavailableWarning(err); + emitActivityEventsDbUnavailableWarning(err); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(vi.mocked(console.warn).mock.calls[0]?.[0]).toContain( + "activity-events disabled: better-sqlite3 not compiled", + ); + }); + + it("suppresses non-events invocations unless AO_DEBUG=1", () => { + const err = new Error( + "Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node", + ); + + process.argv = ["node", "ao", "spawn", "demo"]; + emitActivityEventsDbUnavailableWarning(err); + expect(console.warn).not.toHaveBeenCalled(); + + process.argv = ["node", "ao", "events", "stats"]; + emitActivityEventsDbUnavailableWarning(err); + expect(console.warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/events-db.ts b/packages/core/src/events-db.ts index 71444a9a3..ca04f0062 100644 --- a/packages/core/src/events-db.ts +++ b/packages/core/src/events-db.ts @@ -24,6 +24,7 @@ type BetterSqlite3Database = { let _db: BetterSqlite3Database | null = null; let _dbFailed = false; let _ftsEnabled = false; +let _dbUnavailableWarningEmitted = false; const PRUNE_BATCH_SIZE = 1000; function getEventsDbPath(): string { @@ -90,14 +91,12 @@ function initFts(db: BetterSqlite3Database): void { } function pruneOldEvents(db: BetterSqlite3Database, cutoff: number): void { - db - .prepare( - `DELETE FROM activity_events + db.prepare( + `DELETE FROM activity_events WHERE rowid IN ( SELECT rowid FROM activity_events WHERE ts_epoch < ? LIMIT ? )`, - ) - .run(cutoff, PRUNE_BATCH_SIZE); + ).run(cutoff, PRUNE_BATCH_SIZE); } function openDb(): BetterSqlite3Database { @@ -161,6 +160,42 @@ export function closeDb(): void { _ftsEnabled = false; } +function isAoEventsInvocation(argv = process.argv): boolean { + return argv.slice(2).includes("events"); +} + +function isMissingBetterSqlite3Binding(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return ( + message.includes("Could not locate the bindings file") || + message.includes("better_sqlite3.node") || + message.includes("Cannot find module 'better-sqlite3'") + ); +} + +function firstErrorLine(err: unknown): string { + return (err instanceof Error ? err.message : String(err)).split(/\r?\n/, 1)[0] ?? "unknown error"; +} + +export function formatActivityEventsDbUnavailableWarning(err: unknown): string { + if (isMissingBetterSqlite3Binding(err)) { + return `[ao] activity-events disabled: better-sqlite3 not compiled for Node ${process.version} (ABI v${process.versions.modules}). Run \`pnpm rebuild better-sqlite3\` or use a supported Node version.`; + } + return `[ao] activity-events disabled: better-sqlite3 failed to load: ${firstErrorLine(err)}`; +} + +export function emitActivityEventsDbUnavailableWarning(err: unknown): void { + if (_dbUnavailableWarningEmitted) return; + if (process.env["AO_DEBUG"] !== "1" && !isAoEventsInvocation()) return; + _dbUnavailableWarningEmitted = true; + // eslint-disable-next-line no-console + console.warn(formatActivityEventsDbUnavailableWarning(err)); +} + +export function __resetActivityEventsDbWarningForTests(): void { + _dbUnavailableWarningEmitted = false; +} + /** * Get the lazily-initialized DB connection. * Returns null if better-sqlite3 failed to load or init — callers should treat null as no-op. @@ -173,8 +208,7 @@ export function getDb(): BetterSqlite3Database | null { return _db; } catch (err) { _dbFailed = true; - // Log once so operators know events are being dropped; subsequent calls return null silently. - console.warn("[ao] activity-events DB unavailable — events will be dropped:", err instanceof Error ? err.message : String(err)); + emitActivityEventsDbUnavailableWarning(err); return null; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 632a7cb12..0b7d3e9a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,8 +163,8 @@ importers: version: 3.25.76 optionalDependencies: better-sqlite3: - specifier: ^11.0.0 - version: 11.10.0 + specifier: ^12.10.0 + version: 12.10.0 devDependencies: '@rollup/plugin-typescript': specifier: ^12.3.0 @@ -2352,8 +2352,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - better-sqlite3@11.10.0: - resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -5917,7 +5918,7 @@ snapshots: dependencies: is-windows: 1.0.2 - better-sqlite3@11.10.0: + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3