diff --git a/scripts/run-node-watch-paths.mjs b/scripts/run-node-watch-paths.mjs new file mode 100644 index 00000000000..c04af8e25d9 --- /dev/null +++ b/scripts/run-node-watch-paths.mjs @@ -0,0 +1,63 @@ +import path from "node:path"; +import { + BUNDLED_PLUGIN_PATH_PREFIX, + BUNDLED_PLUGIN_ROOT_DIR, +} from "./lib/bundled-plugin-paths.mjs"; + +export const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR]; +export const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; +export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; +export const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); + +const ignoredRunNodeRepoPaths = new Set([ + "src/canvas-host/a2ui/.bundle.hash", + "src/canvas-host/a2ui/a2ui.bundle.js", +]); +const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; + +export const normalizeRunNodePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); + +const isIgnoredSourcePath = (relativePath) => { + const normalizedPath = normalizeRunNodePath(relativePath); + return ( + normalizedPath.endsWith(".test.ts") || + normalizedPath.endsWith(".test.tsx") || + normalizedPath.endsWith("test-helpers.ts") + ); +}; + +const isBuildRelevantSourcePath = (relativePath) => { + const normalizedPath = normalizeRunNodePath(relativePath); + return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); +}; + +const isRestartRelevantExtensionPath = (relativePath) => { + const normalizedPath = normalizeRunNodePath(relativePath); + if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { + return true; + } + return isBuildRelevantSourcePath(normalizedPath); +}; + +const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { + const normalizedPath = normalizeRunNodePath(repoPath).replace(/^\.\/+/, ""); + if (ignoredRunNodeRepoPaths.has(normalizedPath)) { + return false; + } + if (runNodeConfigFiles.includes(normalizedPath)) { + return true; + } + if (normalizedPath.startsWith("src/")) { + return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); + } + if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { + return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); + } + return false; +}; + +export const isBuildRelevantRunNodePath = (repoPath) => + isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath); + +export const isRestartRelevantRunNodePath = (repoPath) => + isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath); diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 0f536e3772a..7b485805527 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -16,14 +16,22 @@ import { writeRuntimePostBuildStamp as writeDistRuntimePostBuildStamp, } from "./lib/local-build-metadata.mjs"; import { listStaticExtensionAssetSources } from "./lib/static-extension-assets.mjs"; +import { + extensionRestartMetadataFiles, + isBuildRelevantRunNodePath, + isRestartRelevantRunNodePath, + normalizeRunNodePath as normalizePath, + runNodeConfigFiles, + runNodeSourceRoots, + runNodeWatchedPaths, +} from "./run-node-watch-paths.mjs"; import { runRuntimePostBuild } from "./runtime-postbuild.mjs"; +export { isBuildRelevantRunNodePath, isRestartRelevantRunNodePath, runNodeWatchedPaths }; + const buildScript = "scripts/tsdown-build.mjs"; const compilerArgs = [buildScript, "--no-clean"]; -const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR]; -const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; -export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; const runtimePostBuildWatchedPaths = [ "scripts/copy-bundled-plugin-metadata.mjs", "scripts/copy-plugin-sdk-root-alias.mjs", @@ -40,63 +48,10 @@ const runtimePostBuildWatchedPaths = [ "src/plugin-sdk/root-alias.cjs", BUNDLED_PLUGIN_ROOT_DIR, ]; -const ignoredRunNodeRepoPaths = new Set([ - "src/canvas-host/a2ui/.bundle.hash", - "src/canvas-host/a2ui/a2ui.bundle.js", -]); const runtimePostBuildScriptPaths = new Set( runtimePostBuildWatchedPaths.filter((entry) => entry.startsWith("scripts/")), ); const runtimePostBuildStaticAssetPaths = new Set(listStaticExtensionAssetSources()); -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); -}; - -const isRestartRelevantExtensionPath = (relativePath) => { - const normalizedPath = normalizePath(relativePath); - if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { - return true; - } - return isBuildRelevantSourcePath(normalizedPath); -}; - -const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { - const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); - if (ignoredRunNodeRepoPaths.has(normalizedPath)) { - return false; - } - if (runNodeConfigFiles.includes(normalizedPath)) { - return true; - } - if (normalizedPath.startsWith("src/")) { - return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); - } - if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { - return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); - } - return false; -}; - -export const isBuildRelevantRunNodePath = (repoPath) => - isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath); - -export const isRestartRelevantRunNodePath = (repoPath) => - isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath); const statMtime = (filePath, fsImpl = fs) => { try { diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 92131ff39de..6cb65d6dbc9 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -5,7 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs"; +import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node-watch-paths.mjs"; const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; const WATCH_RESTART_SIGNAL = "SIGTERM"; @@ -255,19 +255,6 @@ const releaseWatchLock = (lockHandle) => { * }} [params] */ export async function runWatchMain(params = {}) { - let createWatcher = params.createWatcher; - if (!createWatcher) { - try { - const chokidarModule = await (params.loadChokidar ?? loadChokidar)(); - createWatcher = (watchPaths, options) => chokidarModule.watch(watchPaths, options); - } catch (err) { - if (isInvalidPackageConfigError(err)) { - printFriendlyWatchStartupError(err); - } - throw err; - } - } - const deps = { spawn: params.spawn ?? spawn, process: params.process ?? process, @@ -278,7 +265,8 @@ export async function runWatchMain(params = {}) { sleep: params.sleep ?? sleep, signalProcess: params.signalProcess ?? ((pid, signal) => process.kill(pid, signal)), lockDisabled: params.lockDisabled === true, - createWatcher, + createWatcher: params.createWatcher, + loadChokidar: params.loadChokidar ?? loadChokidar, watchPaths: params.watchPaths ?? runNodeWatchedPaths, }; @@ -293,7 +281,7 @@ export async function runWatchMain(params = {}) { childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" "); } - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { let settled = false; let shuttingDown = false; let restartRequested = false; @@ -357,6 +345,38 @@ export async function runWatchMain(params = {}) { settle(1); }; + const rejectWatcherStartupError = (err) => { + if (settled) { + return; + } + settled = true; + shuttingDown = true; + if (watchProcess && typeof watchProcess.kill === "function") { + watchProcess.kill(WATCH_RESTART_SIGNAL); + } + releaseWatchLock(lockHandle); + watcher?.close?.().catch?.(() => {}); + if (onSigInt) { + deps.process.off("SIGINT", onSigInt); + } + if (onSigTerm) { + deps.process.off("SIGTERM", onSigTerm); + } + reject(err); + }; + + const resolveCreateWatcher = async () => { + try { + const chokidarModule = await deps.loadChokidar(); + return (watchPaths, options) => chokidarModule.watch(watchPaths, options); + } catch (err) { + if (isInvalidPackageConfigError(err)) { + printFriendlyWatchStartupError(err); + } + throw err; + } + }; + const runAutoDoctorAndRestart = () => { autoDoctorAttempted = true; logWatcher( @@ -405,8 +425,11 @@ export async function runWatchMain(params = {}) { } }; - const startWatcher = () => { - watcher = deps.createWatcher(deps.watchPaths, { + const attachWatcher = (createWatcher) => { + if (settled) { + return; + } + watcher = createWatcher(deps.watchPaths, { ignoreInitial: true, ignored: (watchPath, stats) => isIgnoredWatchPath(watchPath, deps.cwd, deps.watchPaths, stats), @@ -417,6 +440,14 @@ export async function runWatchMain(params = {}) { watcher.on("error", handleWatcherError); }; + const startWatcher = () => { + if (deps.createWatcher) { + attachWatcher(deps.createWatcher); + return; + } + void resolveCreateWatcher().then(attachWatcher).catch(rejectWatcherStartupError); + }; + onSigInt = () => { shuttingDown = true; if (watchProcess && typeof watchProcess.kill === "function") { diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 3c573da5e43..3e6f787e3c9 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -155,6 +155,49 @@ describe("watch-node script", () => { }); }); + it("starts the runner before loading chokidar", async () => { + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => {}), + }); + const spawn = vi.fn(() => child); + const watcher = Object.assign(new EventEmitter(), { + close: vi.fn(async () => {}), + }); + const watch = vi.fn(() => watcher); + let resolveLoadChokidar: (value: { watch: typeof watch }) => void = () => {}; + const loadChokidar = vi.fn( + () => + new Promise<{ watch: typeof watch }>((resolve) => { + resolveLoadChokidar = resolve; + }), + ); + const fakeProcess = createFakeProcess(); + + const runPromise = runWatch({ + args: ["gateway", "--force"], + loadChokidar, + lockDisabled: true, + process: fakeProcess, + spawn, + }); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(loadChokidar).toHaveBeenCalledTimes(1); + expect(spawn.mock.invocationCallOrder[0]).toBeLessThan( + loadChokidar.mock.invocationCallOrder[0], + ); + + resolveLoadChokidar({ watch }); + await new Promise((resolve) => setImmediate(resolve)); + expect(watch).toHaveBeenCalledTimes(1); + + fakeProcess.emit("SIGINT"); + const exitCode = await runPromise; + expect(exitCode).toBe(130); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(watcher.close).toHaveBeenCalledTimes(1); + }); + it("terminates child on SIGINT and returns shell interrupt code", async () => { const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness(); @@ -412,6 +455,10 @@ describe("watch-node script", () => { ), { code: "ERR_INVALID_PACKAGE_CONFIG" }, ); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => {}), + }); + const spawn = vi.fn(() => child); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { @@ -423,9 +470,12 @@ describe("watch-node script", () => { throw error; }), process: createFakeProcess(), + spawn, }), ).rejects.toBe(error); + expect(spawn).toHaveBeenCalledTimes(1); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); expect(errorSpy.mock.calls).toEqual([ [""], [ @@ -450,6 +500,10 @@ describe("watch-node script", () => { const error = Object.assign(new Error("Cannot find package 'chokidar'"), { code: "ERR_MODULE_NOT_FOUND", }); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => {}), + }); + const spawn = vi.fn(() => child); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { @@ -459,9 +513,12 @@ describe("watch-node script", () => { throw error; }), process: createFakeProcess(), + spawn, }), ).rejects.toBe(error); + expect(spawn).toHaveBeenCalledTimes(1); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); expect(errorSpy).not.toHaveBeenCalled(); } finally { errorSpy.mockRestore();