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 8520d7d2e1e..04fd150ef20 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -45,7 +45,9 @@ node scripts/watch-node.mjs gateway --force 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`. +`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. 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 55a26dd2cd2..e86c269d4d3 100644 --- a/scripts/run-node.d.mts +++ b/scripts/run-node.d.mts @@ -1,5 +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 4fca85affd5..0e3acd763b9 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -12,7 +12,7 @@ const runNodeSourceRoots = ["src", "extensions"]; const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; -const extensionBuildMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); +const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); @@ -25,11 +25,8 @@ const isIgnoredSourcePath = (relativePath) => { ); }; -const isBuildRelevantExtensionPath = (relativePath) => { +const isBuildRelevantSourcePath = (relativePath) => { const normalizedPath = normalizePath(relativePath); - if (extensionBuildMetadataFiles.has(path.posix.basename(normalizedPath))) { - return true; - } return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); }; @@ -42,7 +39,29 @@ export const isBuildRelevantRunNodePath = (repoPath) => { return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); } if (normalizedPath.startsWith("extensions/")) { - return isBuildRelevantExtensionPath(normalizedPath.slice("extensions/".length)); + 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; }; diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 8893d7fdeeb..e4598ae79fe 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import chokidar from "chokidar"; -import { isBuildRelevantRunNodePath, 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"; @@ -25,7 +25,7 @@ const resolveRepoPath = (filePath, cwd) => { }; const isIgnoredWatchPath = (filePath, cwd) => - !isBuildRelevantRunNodePath(resolveRepoPath(filePath, cwd)); + !isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd)); export async function runWatchMain(params = {}) { const deps = { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 71c61ab359d..7ba07fdaf2d 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -220,7 +220,7 @@ describe("run-node script", () => { }); }); - it("rebuilds when extension package metadata is newer than the build stamp", async () => { + 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"); @@ -273,10 +273,7 @@ describe("run-node script", () => { }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBuildSpawn(), - [process.execPath, "openclaw.mjs", "status"], - ]); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); }); }); @@ -343,7 +340,7 @@ describe("run-node script", () => { }); }); - it("rebuilds for dirty extension manifests consumed by the build graph", async () => { + 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"); @@ -402,10 +399,7 @@ describe("run-node script", () => { }); expect(exitCode).toBe(0); - expect(spawnCalls).toEqual([ - expectedBuildSpawn(), - [process.execPath, "openclaw.mjs", "status"], - ]); + expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]); }); }); diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index eabf43e092d..8fa92bae1df 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -132,13 +132,19 @@ describe("watch-node script", () => { }), }); 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) - .mockReturnValueOnce(childC); + .mockReturnValueOnce(childC) + .mockReturnValueOnce(childD); const watcher = Object.assign(new EventEmitter(), { close: vi.fn(async () => {}), }); @@ -177,11 +183,16 @@ describe("watch-node script", () => { expect(childA.kill).toHaveBeenCalledWith("SIGTERM"); expect(spawn).toHaveBeenCalledTimes(2); - watcher.emit("change", "src/infra/watch-node.ts"); + 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);