diff --git a/docs/help/debugging.md b/docs/help/debugging.md index d680e35c7a..61539ec39a 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -34,13 +34,13 @@ Examples: For fast iteration, run the gateway under the file watcher: ```bash -pnpm gateway:watch --force +pnpm gateway:watch ``` This maps to: ```bash -tsx watch src/entry.ts gateway --force +node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force ``` Add any gateway CLI flags after `gateway:watch` and they will be passed through @@ -113,13 +113,13 @@ This is the best way to see whether reasoning is arriving as plain text deltas Enable it via CLI: ```bash -pnpm gateway:watch --force --raw-stream +pnpm gateway:watch --raw-stream ``` Optional path override: ```bash -pnpm gateway:watch --force --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl +pnpm gateway:watch --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl ``` Equivalent env vars: diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 9f922949eb..90e7c13720 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -8,7 +8,7 @@ import { pathToFileURL } from "node:url"; const compiler = "tsdown"; const compilerArgs = ["exec", compiler, "--no-clean"]; -const gitWatchedPaths = ["src", "tsconfig.json", "package.json"]; +export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"]; const statMtime = (filePath, fsImpl = fs) => { try { @@ -91,7 +91,7 @@ const resolveGitHead = (deps) => { const hasDirtySourceTree = (deps) => { const output = runGit( - ["status", "--porcelain", "--untracked-files=normal", "--", ...gitWatchedPaths], + ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], deps, ); if (output === null) { diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index ad644b8727..e554796f03 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -1,65 +1,92 @@ #!/usr/bin/env node -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { runNodeWatchedPaths } from "./run-node.mjs"; -const args = process.argv.slice(2); -const env = { ...process.env }; -const cwd = process.cwd(); -const compiler = "tsdown"; -const watchSession = `${Date.now()}-${process.pid}`; -env.OPENCLAW_WATCH_MODE = "1"; -env.OPENCLAW_WATCH_SESSION = watchSession; -if (args.length > 0) { - env.OPENCLAW_WATCH_COMMAND = args.join(" "); +const WATCH_NODE_RUNNER = "scripts/run-node.mjs"; + +const buildWatchArgs = (args) => [ + ...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]), + "--watch-preserve-output", + WATCH_NODE_RUNNER, + ...args, +]; + +export async function runWatchMain(params = {}) { + const deps = { + spawn: params.spawn ?? spawn, + process: params.process ?? process, + cwd: params.cwd ?? process.cwd(), + args: params.args ?? process.argv.slice(2), + env: params.env ? { ...params.env } : { ...process.env }, + now: params.now ?? Date.now, + }; + + const childEnv = { ...deps.env }; + const watchSession = `${deps.now()}-${deps.process.pid}`; + childEnv.OPENCLAW_WATCH_MODE = "1"; + childEnv.OPENCLAW_WATCH_SESSION = watchSession; + if (deps.args.length > 0) { + childEnv.OPENCLAW_WATCH_COMMAND = deps.args.join(" "); + } + + const watchProcess = deps.spawn(deps.process.execPath, buildWatchArgs(deps.args), { + cwd: deps.cwd, + env: childEnv, + stdio: "inherit", + }); + + let settled = false; + let onSigInt; + let onSigTerm; + + const settle = (resolve, code) => { + if (settled) { + return; + } + settled = true; + if (onSigInt) { + deps.process.off("SIGINT", onSigInt); + } + if (onSigTerm) { + deps.process.off("SIGTERM", onSigTerm); + } + resolve(code); + }; + + return await new Promise((resolve) => { + onSigInt = () => { + if (typeof watchProcess.kill === "function") { + watchProcess.kill("SIGTERM"); + } + settle(resolve, 130); + }; + onSigTerm = () => { + if (typeof watchProcess.kill === "function") { + watchProcess.kill("SIGTERM"); + } + settle(resolve, 143); + }; + + deps.process.on("SIGINT", onSigInt); + deps.process.on("SIGTERM", onSigTerm); + + watchProcess.on("exit", (code, signal) => { + if (signal) { + settle(resolve, 1); + return; + } + settle(resolve, code ?? 1); + }); + }); } -const initialBuild = spawnSync("pnpm", ["exec", compiler], { - cwd, - env, - stdio: "inherit", -}); - -if (initialBuild.status !== 0) { - process.exit(initialBuild.status ?? 1); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + void runWatchMain() + .then((code) => process.exit(code)) + .catch((err) => { + console.error(err); + process.exit(1); + }); } - -const compilerProcess = spawn("pnpm", ["exec", compiler, "--watch"], { - cwd, - env, - stdio: "inherit", -}); - -const nodeProcess = spawn(process.execPath, ["--watch", "openclaw.mjs", ...args], { - cwd, - env, - stdio: "inherit", -}); - -let exiting = false; - -function cleanup(code = 0) { - if (exiting) { - return; - } - exiting = true; - nodeProcess.kill("SIGTERM"); - compilerProcess.kill("SIGTERM"); - process.exit(code); -} - -process.on("SIGINT", () => cleanup(130)); -process.on("SIGTERM", () => cleanup(143)); - -compilerProcess.on("exit", (code) => { - if (exiting) { - return; - } - cleanup(code ?? 1); -}); - -nodeProcess.on("exit", (code, signal) => { - if (signal || exiting) { - return; - } - cleanup(code ?? 1); -}); diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts new file mode 100644 index 0000000000..c7f75c662e --- /dev/null +++ b/src/infra/watch-node.test.ts @@ -0,0 +1,77 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { runNodeWatchedPaths } from "../../scripts/run-node.mjs"; +import { runWatchMain } from "../../scripts/watch-node.mjs"; + +const createFakeProcess = () => + Object.assign(new EventEmitter(), { + pid: 4242, + execPath: "/usr/local/bin/node", + }) as unknown as NodeJS.Process; + +describe("watch-node script", () => { + it("wires node watch to run-node with watched source/config paths", async () => { + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(), + }); + const spawn = vi.fn(() => child); + const fakeProcess = createFakeProcess(); + + const runPromise = runWatchMain({ + args: ["gateway", "--force"], + cwd: "/tmp/openclaw", + env: { PATH: "/usr/bin" }, + now: () => 1700000000000, + process: fakeProcess, + spawn, + }); + + queueMicrotask(() => child.emit("exit", 0, null)); + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledWith( + "/usr/local/bin/node", + [ + ...runNodeWatchedPaths.flatMap((watchPath) => ["--watch-path", watchPath]), + "--watch-preserve-output", + "scripts/run-node.mjs", + "gateway", + "--force", + ], + expect.objectContaining({ + cwd: "/tmp/openclaw", + stdio: "inherit", + env: expect.objectContaining({ + PATH: "/usr/bin", + OPENCLAW_WATCH_MODE: "1", + OPENCLAW_WATCH_SESSION: "1700000000000-4242", + OPENCLAW_WATCH_COMMAND: "gateway --force", + }), + }), + ); + }); + + it("terminates child on SIGINT and returns shell interrupt code", async () => { + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(), + }); + const spawn = vi.fn(() => child); + const fakeProcess = createFakeProcess(); + + const runPromise = runWatchMain({ + args: ["gateway", "--force"], + process: fakeProcess, + spawn, + }); + + fakeProcess.emit("SIGINT"); + const exitCode = await runPromise; + + expect(exitCode).toBe(130); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); + }); +});