scripts: split run-node rebuild and restart triggers

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 20:03:27 +00:00
parent 09a0e91739
commit eaa468a8dd
8 changed files with 51 additions and 23 deletions

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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

View File

@@ -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?: (

View File

@@ -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;
};

View File

@@ -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 = {

View File

@@ -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"]]);
});
});

View File

@@ -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);