From 594920f8cc9693e4b2f8bba2512711ab0f2f201f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 16:19:27 -0400 Subject: [PATCH] Scripts: rebuild on extension and tsdown config changes (#47571) Merged via squash. Prepared head SHA: edd8ed825469128bbe85f86e2e1341f6c57687d7 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + README.md | 2 +- docs/help/debugging.md | 12 +- docs/start/setup.md | 3 +- scripts/run-node.d.mts | 2 + scripts/run-node.mjs | 127 ++++++++++-- scripts/watch-node.mjs | 28 +-- src/infra/run-node.test.ts | 362 +++++++++++++++++++++++++++++++++++ src/infra/watch-node.test.ts | 41 +++- 9 files changed, 539 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b85cd40bb3..0f77551f4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. +- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras. ## 2026.3.13 diff --git a/README.md b/README.md index d5a22313f27..fee53d83065 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ pnpm build pnpm openclaw onboard --install-daemon -# Dev loop (auto-reload on TS changes) +# Dev loop (auto-reload on source/config changes) pnpm gateway:watch ``` diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 61539ec39a3..04fd150ef20 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -40,11 +40,17 @@ pnpm gateway:watch This maps to: ```bash -node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force +node scripts/watch-node.mjs gateway --force ``` -Add any gateway CLI flags after `gateway:watch` and they will be passed through -on each restart. +The watcher restarts on build-relevant files under `src/`, extension source files, +extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`, +`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the +gateway without forcing a `tsdown` rebuild; source and config changes still +rebuild `dist` first. + +Add any gateway CLI flags after `gateway:watch` and they will be passed through on +each restart. ## Dev profile + dev gateway (--dev) diff --git a/docs/start/setup.md b/docs/start/setup.md index 205f14d20a5..bf127cc0ad0 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -96,7 +96,8 @@ pnpm install pnpm gateway:watch ``` -`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes. +`gateway:watch` runs the gateway in watch mode and reloads on relevant source, +config, and bundled-plugin metadata changes. ### 2) Point the macOS app at your running Gateway diff --git a/scripts/run-node.d.mts b/scripts/run-node.d.mts index 1fc9a1437e0..e86c269d4d3 100644 --- a/scripts/run-node.d.mts +++ b/scripts/run-node.d.mts @@ -1,4 +1,6 @@ export const runNodeWatchedPaths: string[]; +export function isBuildRelevantRunNodePath(repoPath: string): boolean; +export function isRestartRelevantRunNodePath(repoPath: string): boolean; export function runNodeMain(params?: { spawn?: ( diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 90e7c137209..0e3acd763b9 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -8,7 +8,63 @@ import { pathToFileURL } from "node:url"; const compiler = "tsdown"; const compilerArgs = ["exec", compiler, "--no-clean"]; -export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"]; +const runNodeSourceRoots = ["src", "extensions"]; +const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; +export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; +const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; +const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); + +const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); + +const isIgnoredSourcePath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + return ( + normalizedPath.endsWith(".test.ts") || + normalizedPath.endsWith(".test.tsx") || + normalizedPath.endsWith("test-helpers.ts") + ); +}; + +const isBuildRelevantSourcePath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); +}; + +export const isBuildRelevantRunNodePath = (repoPath) => { + const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith("extensions/")) { + return isBuildRelevantSourcePath(normalizedPath.slice("extensions/".length)); + } + return false; +}; + +const isRestartRelevantExtensionPath = (relativePath) => { + const normalizedPath = normalizePath(relativePath); + if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { + return true; + } + return isBuildRelevantSourcePath(normalizedPath); +}; + +export const isRestartRelevantRunNodePath = (repoPath) => { + const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith("extensions/")) { + return isRestartRelevantExtensionPath(normalizedPath.slice("extensions/".length)); + } + return false; +}; const statMtime = (filePath, fsImpl = fs) => { try { @@ -18,16 +74,12 @@ const statMtime = (filePath, fsImpl = fs) => { } }; -const isExcludedSource = (filePath, srcRoot) => { - const relativePath = path.relative(srcRoot, filePath); +const isExcludedSource = (filePath, sourceRoot, sourceRootName) => { + const relativePath = normalizePath(path.relative(sourceRoot, filePath)); if (relativePath.startsWith("..")) { return false; } - return ( - relativePath.endsWith(".test.ts") || - relativePath.endsWith(".test.tsx") || - relativePath.endsWith(`test-helpers.ts`) - ); + return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath)); }; const findLatestMtime = (dirPath, shouldSkip, deps) => { @@ -89,15 +141,39 @@ const resolveGitHead = (deps) => { return head || null; }; +const readGitStatus = (deps) => { + try { + const result = deps.spawnSync( + "git", + ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], + { + cwd: deps.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + if (result.status !== 0) { + return null; + } + return result.stdout ?? ""; + } catch { + return null; + } +}; + +const parseGitStatusPaths = (output) => + output + .split("\n") + .flatMap((line) => line.slice(3).split(" -> ")) + .map((entry) => normalizePath(entry.trim())) + .filter(Boolean); + const hasDirtySourceTree = (deps) => { - const output = runGit( - ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], - deps, - ); + const output = readGitStatus(deps); if (output === null) { return null; } - return output.length > 0; + return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath)); }; const readBuildStamp = (deps) => { @@ -119,12 +195,18 @@ const readBuildStamp = (deps) => { }; const hasSourceMtimeChanged = (stampMtime, deps) => { - const srcMtime = findLatestMtime( - deps.srcRoot, - (candidate) => isExcludedSource(candidate, deps.srcRoot), - deps, - ); - return srcMtime != null && srcMtime > stampMtime; + let latestSourceMtime = null; + for (const sourceRoot of deps.sourceRoots) { + const sourceMtime = findLatestMtime( + sourceRoot.path, + (candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name), + deps, + ); + if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) { + latestSourceMtime = sourceMtime; + } + } + return latestSourceMtime != null && latestSourceMtime > stampMtime; }; const shouldBuild = (deps) => { @@ -223,8 +305,11 @@ export async function runNodeMain(params = {}) { deps.distRoot = path.join(deps.cwd, "dist"); deps.distEntry = path.join(deps.distRoot, "/entry.js"); deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); - deps.srcRoot = path.join(deps.cwd, "src"); - deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")]; + deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({ + name: sourceRoot, + path: path.join(deps.cwd, sourceRoot), + })); + deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); if (!shouldBuild(deps)) { return await runOpenClaw(deps); diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 891e07439a1..e4598ae79fe 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -1,26 +1,32 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import chokidar from "chokidar"; -import { runNodeWatchedPaths } from "./run-node.mjs"; +import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs"; const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; const WATCH_RESTART_SIGNAL = "SIGTERM"; const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args]; -const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); +const normalizePath = (filePath) => + String(filePath ?? "") + .replaceAll("\\", "/") + .replace(/^\.\/+/, ""); -const isIgnoredWatchPath = (filePath) => { - const normalizedPath = normalizePath(filePath); - return ( - normalizedPath.endsWith(".test.ts") || - normalizedPath.endsWith(".test.tsx") || - normalizedPath.endsWith("test-helpers.ts") - ); +const resolveRepoPath = (filePath, cwd) => { + const rawPath = String(filePath ?? ""); + if (path.isAbsolute(rawPath)) { + return normalizePath(path.relative(cwd, rawPath)); + } + return normalizePath(rawPath); }; +const isIgnoredWatchPath = (filePath, cwd) => + !isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd)); + export async function runWatchMain(params = {}) { const deps = { spawn: params.spawn ?? spawn, @@ -52,7 +58,7 @@ export async function runWatchMain(params = {}) { const watcher = deps.createWatcher(deps.watchPaths, { ignoreInitial: true, - ignored: (watchPath) => isIgnoredWatchPath(watchPath), + ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd), }); const settle = (code) => { @@ -89,7 +95,7 @@ export async function runWatchMain(params = {}) { }; const requestRestart = (changedPath) => { - if (shuttingDown || isIgnoredWatchPath(changedPath)) { + if (shuttingDown || isIgnoredWatchPath(changedPath, deps.cwd)) { return; } if (!watchProcess) { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 0b8cf1090bc..7ba07fdaf2d 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -24,6 +24,12 @@ function createExitedProcess(code: number | null, signal: string | null = null) }; } +function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) { + return platform === "win32" + ? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"] + : ["pnpm", "exec", "tsdown", "--no-clean"]; +} + describe("run-node script", () => { it.runIf(process.platform !== "win32")( "preserves control-ui assets by building with tsdown --no-clean", @@ -161,4 +167,360 @@ describe("run-node script", () => { expect(exitCode).toBe(23); }); }); + + it("rebuilds when extension sources are newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const extensionPath = path.join(tmp, "extensions", "demo", "src", "index.ts"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + await fs.mkdir(path.dirname(extensionPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(extensionPath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + }); + }); + + it("skips rebuilding when extension package metadata is newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const packagePath = path.join(tmp, "extensions", "demo", "package.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(packagePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile( + packagePath, + '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', + "utf-8", + ); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(tsdownConfigPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(packagePath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding for dirty non-source files under extensions", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const readmePath = path.join(tmp, "extensions", "demo", "README.md"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(readmePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(readmePath, "# demo\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(readmePath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: " M extensions/demo/README.md\n" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(manifestPath, '{"id":"demo"}\n', "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + await fs.utimes(srcPath, stampTime, stampTime); + await fs.utimes(manifestPath, stampTime, stampTime); + await fs.utimes(tsconfigPath, stampTime, stampTime); + await fs.utimes(packageJsonPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, stampTime, stampTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: " M extensions/demo/openclaw.plugin.json\n" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const readmePath = path.join(tmp, "extensions", "demo", "README.md"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(readmePath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(readmePath, "# demo\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(srcPath, oldTime, oldTime); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(tsdownConfigPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(readmePath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = () => ({ status: 1, stdout: "" }); + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); + }); + }); + + it("rebuilds when tsdown config is newer than the build stamp", async () => { + await withTempDir(async (tmp) => { + const srcPath = path.join(tmp, "src", "index.ts"); + const distEntryPath = path.join(tmp, "dist", "entry.js"); + const buildStampPath = path.join(tmp, "dist", ".buildstamp"); + const tsconfigPath = path.join(tmp, "tsconfig.json"); + const packageJsonPath = path.join(tmp, "package.json"); + const tsdownConfigPath = path.join(tmp, "tsdown.config.ts"); + await fs.mkdir(path.dirname(srcPath), { recursive: true }); + await fs.mkdir(path.dirname(distEntryPath), { recursive: true }); + await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8"); + await fs.writeFile(tsconfigPath, "{}\n", "utf-8"); + await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8"); + await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8"); + await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8"); + await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8"); + + const oldTime = new Date("2026-03-13T10:00:00.000Z"); + const stampTime = new Date("2026-03-13T12:00:00.000Z"); + const newTime = new Date("2026-03-13T12:00:01.000Z"); + await fs.utimes(srcPath, oldTime, oldTime); + await fs.utimes(tsconfigPath, oldTime, oldTime); + await fs.utimes(packageJsonPath, oldTime, oldTime); + await fs.utimes(distEntryPath, stampTime, stampTime); + await fs.utimes(buildStampPath, stampTime, stampTime); + await fs.utimes(tsdownConfigPath, newTime, newTime); + + const spawnCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + spawnCalls.push([cmd, ...args]); + return createExitedProcess(0); + }; + const spawnSync = (cmd: string, args: string[]) => { + if (cmd === "git" && args[0] === "rev-parse") { + return { status: 0, stdout: "abc123\n" }; + } + if (cmd === "git" && args[0] === "status") { + return { status: 0, stdout: "" }; + } + return { status: 1, stdout: "" }; + }; + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + spawnSync, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [process.execPath, "openclaw.mjs", "status"], + ]); + }); + }); }); diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 89ec4b79ef2..8fa92bae1df 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -44,10 +44,17 @@ describe("watch-node script", () => { { ignoreInitial: boolean; ignored: (watchPath: string) => boolean }, ]; expect(watchPaths).toEqual(runNodeWatchedPaths); + expect(watchPaths).toContain("extensions"); + expect(watchPaths).toContain("tsdown.config.ts"); expect(watchOptions.ignoreInitial).toBe(true); expect(watchOptions.ignored("src/infra/watch-node.test.ts")).toBe(true); expect(watchOptions.ignored("src/infra/watch-node.test.tsx")).toBe(true); expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).toBe(true); + expect(watchOptions.ignored("extensions/voice-call/README.md")).toBe(true); + expect(watchOptions.ignored("extensions/voice-call/openclaw.plugin.json")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/package.json")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/index.ts")).toBe(false); + expect(watchOptions.ignored("extensions/voice-call/src/runtime.ts")).toBe(false); expect(watchOptions.ignored("src/infra/watch-node.ts")).toBe(false); expect(watchOptions.ignored("tsconfig.json")).toBe(false); @@ -120,9 +127,24 @@ describe("watch-node script", () => { }), }); const childB = Object.assign(new EventEmitter(), { + kill: vi.fn(function () { + queueMicrotask(() => childB.emit("exit", 0, null)); + }), + }); + const childC = Object.assign(new EventEmitter(), { + kill: vi.fn(function () { + queueMicrotask(() => childC.emit("exit", 0, null)); + }), + }); + const childD = Object.assign(new EventEmitter(), { kill: vi.fn(() => {}), }); - const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB); + const spawn = vi + .fn() + .mockReturnValueOnce(childA) + .mockReturnValueOnce(childB) + .mockReturnValueOnce(childC) + .mockReturnValueOnce(childD); const watcher = Object.assign(new EventEmitter(), { close: vi.fn(async () => {}), }); @@ -151,11 +173,26 @@ describe("watch-node script", () => { expect(spawn).toHaveBeenCalledTimes(1); expect(childA.kill).not.toHaveBeenCalled(); - watcher.emit("change", "src/infra/watch-node.ts"); + watcher.emit("change", "extensions/voice-call/README.md"); + await new Promise((resolve) => setImmediate(resolve)); + expect(spawn).toHaveBeenCalledTimes(1); + expect(childA.kill).not.toHaveBeenCalled(); + + watcher.emit("change", "extensions/voice-call/openclaw.plugin.json"); await new Promise((resolve) => setImmediate(resolve)); expect(childA.kill).toHaveBeenCalledWith("SIGTERM"); expect(spawn).toHaveBeenCalledTimes(2); + watcher.emit("change", "extensions/voice-call/package.json"); + await new Promise((resolve) => setImmediate(resolve)); + expect(childB.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawn).toHaveBeenCalledTimes(3); + + watcher.emit("change", "src/infra/watch-node.ts"); + await new Promise((resolve) => setImmediate(resolve)); + expect(childC.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawn).toHaveBeenCalledTimes(4); + fakeProcess.emit("SIGINT"); const exitCode = await runPromise; expect(exitCode).toBe(130);