diff --git a/CHANGELOG.md b/CHANGELOG.md index 99842926ff4..b78845c8804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Onboarding: pin the final QuickStart health check to the just-configured setup token so stale `OPENCLAW_GATEWAY_TOKEN` values or older config tokens do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev. - Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9. - Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby. +- Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar. - CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk. - CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge. - Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah. diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 7a5ccdc4944..ee68a183e81 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { collectBundledPluginBuildEntries } from "./lib/bundled-plugin-build-entries.mjs"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; import { resolvePnpmRunner } from "./pnpm-runner.mjs"; import { @@ -21,6 +22,7 @@ const DEFAULT_CAPTURE_BYTES = 8 * 1024 * 1024; const DEFAULT_HEARTBEAT_MS = 30_000; const TERMINATION_GRACE_MS = 5_000; const TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"]; +const DIST_RUNTIME_DEPS_ROOT = "extensions"; function removeDistPluginNodeModulesSymlinks(rootDir) { const extensionsDir = path.join(rootDir, "extensions"); @@ -54,9 +56,17 @@ function pruneStaleRuntimeSymlinks() { export function cleanTsdownOutputRoots(params = {}) { const cwd = params.cwd ?? process.cwd(); + const stagedRuntimeDependencyPluginIds = collectStagedRuntimeDependencyPluginIds({ + cwd, + env: params.env ?? process.env, + }); const fsImpl = params.fs ?? fs; for (const root of TSDOWN_OUTPUT_ROOTS) { const rootPath = path.join(cwd, root); + if (root === "dist") { + cleanDistOutputRoot(rootPath, stagedRuntimeDependencyPluginIds, fsImpl); + continue; + } try { fsImpl.rmSync(rootPath, { force: true, recursive: true }); } catch { @@ -65,6 +75,86 @@ export function cleanTsdownOutputRoots(params = {}) { } } +function collectStagedRuntimeDependencyPluginIds(params) { + try { + return new Set( + collectBundledPluginBuildEntries(params) + .filter(({ packageJson }) => shouldStageBundledPluginRuntimeDependencies(packageJson)) + .map(({ id }) => id), + ); + } catch { + return new Set(); + } +} + +function shouldStageBundledPluginRuntimeDependencies(packageJson) { + return packageJson?.openclaw?.bundle?.stageRuntimeDependencies === true; +} + +function cleanDistOutputRoot(distRoot, stagedRuntimeDependencyPluginIds, fsImpl) { + let entries = []; + try { + entries = fsImpl.readdirSync(distRoot, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const entryPath = path.join(distRoot, entry.name); + try { + if (entry.isDirectory() && entry.name === DIST_RUNTIME_DEPS_ROOT) { + cleanDistExtensionsRoot(entryPath, stagedRuntimeDependencyPluginIds, fsImpl); + continue; + } + fsImpl.rmSync(entryPath, { force: true, recursive: true }); + } catch { + // Best-effort cleanup. tsdown will overwrite or recreate generated output. + } + } +} + +function cleanDistExtensionsRoot(extensionsDistRoot, stagedRuntimeDependencyPluginIds, fsImpl) { + let entries = []; + try { + entries = fsImpl.readdirSync(extensionsDistRoot, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const pluginDistRoot = path.join(extensionsDistRoot, entry.name); + try { + if (!entry.isDirectory() || !stagedRuntimeDependencyPluginIds.has(entry.name)) { + fsImpl.rmSync(pluginDistRoot, { force: true, recursive: true }); + continue; + } + cleanDistPluginOutputRoot(pluginDistRoot, fsImpl); + } catch { + // Best-effort cleanup. Runtime postbuild validates current plugin metadata next. + } + } +} + +function cleanDistPluginOutputRoot(pluginDistRoot, fsImpl) { + let entries = []; + try { + entries = fsImpl.readdirSync(pluginDistRoot, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.isDirectory() && entry.name === "node_modules") { + continue; + } + try { + fsImpl.rmSync(path.join(pluginDistRoot, entry.name), { force: true, recursive: true }); + } catch { + // Best-effort cleanup. tsdown/runtime-postbuild will rewrite generated files. + } + } +} + export function pruneStaleRootChunkFiles(params = {}) { const cwd = params.cwd ?? process.cwd(); const fsImpl = params.fs ?? fs; diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index 2203d74a15c..a35b4e1b611 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -106,21 +106,82 @@ describe("resolveTsdownBuildInvocation", () => { ).rejects.toThrow(); }); - it("cleans tsdown output roots before using tsdown --no-clean", async () => { + it("cleans tsdown output roots before using tsdown --no-clean without deleting staged runtime deps", async () => { const rootDir = createTempDir("openclaw-tsdown-clean-"); const distFile = path.join(rootDir, "dist", "stale.js"); + const pluginManifest = path.join(rootDir, "extensions", "telegram", "openclaw.plugin.json"); + const pluginSourceManifest = path.join(rootDir, "extensions", "telegram", "package.json"); + const pluginGeneratedFile = path.join(rootDir, "dist", "extensions", "telegram", "index.js"); + const pluginRuntimeDepFile = path.join( + rootDir, + "dist", + "extensions", + "telegram", + "node_modules", + "grammy", + "package.json", + ); + const stalePluginRuntimeDepFile = path.join( + rootDir, + "dist", + "extensions", + "old-plugin", + "node_modules", + "left-pad", + "package.json", + ); + const unstagedPluginSourceManifest = path.join( + rootDir, + "extensions", + "unstaged-plugin", + "package.json", + ); + const unstagedPluginRuntimeDepFile = path.join( + rootDir, + "dist", + "extensions", + "unstaged-plugin", + "node_modules", + "left-pad", + "package.json", + ); const distRuntimeFile = path.join(rootDir, "dist-runtime", "stale.js"); const unrelatedFile = path.join(rootDir, "tmp", "keep.js"); await fsPromises.mkdir(path.dirname(distFile), { recursive: true }); + await fsPromises.mkdir(path.dirname(pluginManifest), { recursive: true }); + await fsPromises.mkdir(path.dirname(pluginSourceManifest), { recursive: true }); + await fsPromises.mkdir(path.dirname(pluginGeneratedFile), { recursive: true }); + await fsPromises.mkdir(path.dirname(pluginRuntimeDepFile), { recursive: true }); + await fsPromises.mkdir(path.dirname(stalePluginRuntimeDepFile), { recursive: true }); + await fsPromises.mkdir(path.dirname(unstagedPluginSourceManifest), { recursive: true }); + await fsPromises.mkdir(path.dirname(unstagedPluginRuntimeDepFile), { recursive: true }); await fsPromises.mkdir(path.dirname(distRuntimeFile), { recursive: true }); await fsPromises.mkdir(path.dirname(unrelatedFile), { recursive: true }); await fsPromises.writeFile(distFile, "stale\n"); + await fsPromises.writeFile(pluginManifest, '{"id":"telegram"}\n'); + await fsPromises.writeFile( + pluginSourceManifest, + '{"openclaw":{"bundle":{"stageRuntimeDependencies":true}}}\n', + ); + await fsPromises.writeFile(pluginGeneratedFile, "generated\n"); + await fsPromises.writeFile(pluginRuntimeDepFile, "{}\n"); + await fsPromises.writeFile(stalePluginRuntimeDepFile, "{}\n"); + await fsPromises.writeFile(unstagedPluginSourceManifest, "{}\n"); + await fsPromises.writeFile(unstagedPluginRuntimeDepFile, "{}\n"); await fsPromises.writeFile(distRuntimeFile, "stale\n"); await fsPromises.writeFile(unrelatedFile, "keep\n"); cleanTsdownOutputRoots({ cwd: rootDir }); - await expect(fsPromises.stat(path.join(rootDir, "dist"))).rejects.toThrow(); + await expect(fsPromises.stat(distFile)).rejects.toThrow(); + await expect(fsPromises.stat(pluginGeneratedFile)).rejects.toThrow(); + await expect(fsPromises.readFile(pluginRuntimeDepFile, "utf8")).resolves.toBe("{}\n"); + await expect( + fsPromises.stat(path.join(rootDir, "dist", "extensions", "old-plugin")), + ).rejects.toThrow(); + await expect( + fsPromises.stat(path.join(rootDir, "dist", "extensions", "unstaged-plugin")), + ).rejects.toThrow(); await expect(fsPromises.stat(path.join(rootDir, "dist-runtime"))).rejects.toThrow(); await expect(fsPromises.readFile(unrelatedFile, "utf8")).resolves.toBe("keep\n"); });