diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs index 238bc68e742..ae7aea4693d 100644 --- a/scripts/check-gateway-watch-regression.mjs +++ b/scripts/check-gateway-watch-regression.mjs @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { resolveBuildRequirement } from "./run-node.mjs"; const DEFAULTS = { outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"), @@ -370,6 +371,32 @@ function fail(message) { console.error(`FAIL: ${message}`); } +function detectWatchBuildReason(stdout, stderr) { + const combined = `${stdout}\n${stderr}`; + const match = combined.match(/Building TypeScript \(dist is stale: ([a-z_]+)/); + return match?.[1] ?? null; +} + +function buildRunNodeDeps(env) { + const cwd = process.cwd(); + return { + cwd, + env, + fs, + spawnSync, + distRoot: path.join(cwd, "dist"), + distEntry: path.join(cwd, "dist", "/entry.js"), + buildStampPath: path.join(cwd, "dist", ".buildstamp"), + sourceRoots: ["src", "extensions"].map((sourceRoot) => ({ + name: sourceRoot, + path: path.join(cwd, sourceRoot), + })), + configFiles: ["tsconfig.json", "package.json", "tsdown.config.ts"].map((filePath) => + path.join(cwd, filePath), + ), + }; +} + async function main() { const options = parseArgs(process.argv.slice(2)); ensureDir(options.outputDir); @@ -377,6 +404,29 @@ async function main() { runCheckedCommand("pnpm", ["build"]); } + const preflightBuildRequirement = resolveBuildRequirement(buildRunNodeDeps(process.env)); + if ( + preflightBuildRequirement.shouldBuild && + preflightBuildRequirement.reason === "dirty_watched_tree" + ) { + const summary = { + windowMs: options.windowMs, + invalidated: true, + invalidationReason: preflightBuildRequirement.reason, + invalidationMessage: + "gateway-watch-regression cannot run on a dirty watched tree because run-node will intentionally rebuild during the watch window.", + }; + fs.writeFileSync( + path.join(options.outputDir, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + console.log(JSON.stringify(summary, null, 2)); + fail( + "gateway-watch-regression invalid local run: dirty watched source tree would force a rebuild inside the watch window", + ); + process.exit(1); + } + const preDir = path.join(options.outputDir, "pre"); const pre = writeSnapshot(preDir); @@ -397,14 +447,17 @@ async function main() { const watchTriggeredBuild = fs .readFileSync(watchResult.stderrPath, "utf8") - .includes("Building TypeScript (dist is stale).") || - fs - .readFileSync(watchResult.stdoutPath, "utf8") - .includes("Building TypeScript (dist is stale)."); + .includes("Building TypeScript (dist is stale") || + fs.readFileSync(watchResult.stdoutPath, "utf8").includes("Building TypeScript (dist is stale"); + const watchBuildReason = detectWatchBuildReason( + fs.readFileSync(watchResult.stdoutPath, "utf8"), + fs.readFileSync(watchResult.stderrPath, "utf8"), + ); const summary = { windowMs: options.windowMs, watchTriggeredBuild, + watchBuildReason, cpuMs, cpuWarnMs: options.cpuWarnMs, cpuFailMs: options.cpuFailMs, @@ -426,6 +479,11 @@ async function main() { console.log(JSON.stringify(summary, null, 2)); const failures = []; + if (watchTriggeredBuild && watchBuildReason === "dirty_watched_tree") { + failures.push( + "gateway:watch invalid local run: dirty watched source tree forced a rebuild during the watch window", + ); + } if (distRuntimeFileGrowth > options.distRuntimeFileGrowthMax) { failures.push( `dist-runtime file growth ${distRuntimeFileGrowth} exceeded max ${options.distRuntimeFileGrowthMax}`, @@ -452,9 +510,11 @@ async function main() { for (const message of failures) { fail(message); } - fail( - "Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.", - ); + if (!failures.every((message) => message.includes("dirty watched source tree"))) { + fail( + "Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.", + ); + } process.exit(1); } diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index aa85c55518b..b0966c01a15 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -194,48 +194,62 @@ const hasSourceMtimeChanged = (stampMtime, deps) => { return latestSourceMtime != null && latestSourceMtime > stampMtime; }; -const shouldBuild = (deps) => { +export const resolveBuildRequirement = (deps) => { if (deps.env.OPENCLAW_FORCE_BUILD === "1") { - return true; + return { shouldBuild: true, reason: "force_build" }; } const stamp = readBuildStamp(deps); if (stamp.mtime == null) { - return true; + return { shouldBuild: true, reason: "missing_build_stamp" }; } if (statMtime(deps.distEntry, deps.fs) == null) { - return true; + return { shouldBuild: true, reason: "missing_dist_entry" }; } for (const filePath of deps.configFiles) { const mtime = statMtime(filePath, deps.fs); if (mtime != null && mtime > stamp.mtime) { - return true; + return { shouldBuild: true, reason: "config_newer" }; } } const currentHead = resolveGitHead(deps); if (currentHead && !stamp.head) { - return true; + return { shouldBuild: true, reason: "build_stamp_missing_head" }; } if (currentHead && stamp.head && currentHead !== stamp.head) { - return true; + return { shouldBuild: true, reason: "git_head_changed" }; } if (currentHead) { const dirty = hasDirtySourceTree(deps); if (dirty === true) { - return true; + return { shouldBuild: true, reason: "dirty_watched_tree" }; } if (dirty === false) { - return false; + return { shouldBuild: false, reason: "clean" }; } } if (hasSourceMtimeChanged(stamp.mtime, deps)) { - return true; + return { shouldBuild: true, reason: "source_mtime_newer" }; } - return false; + return { shouldBuild: false, reason: "clean" }; }; +const BUILD_REASON_LABELS = { + force_build: "forced by OPENCLAW_FORCE_BUILD", + missing_build_stamp: "build stamp missing", + missing_dist_entry: "dist entry missing", + config_newer: "config newer than build stamp", + build_stamp_missing_head: "build stamp missing git head", + git_head_changed: "git head changed", + dirty_watched_tree: "dirty watched source tree", + source_mtime_newer: "source mtime newer than build stamp", + clean: "clean", +}; + +const formatBuildReason = (reason) => BUILD_REASON_LABELS[reason] ?? reason; + const logRunner = (message, deps) => { if (deps.env.OPENCLAW_RUNNER_LOG === "0") { return; @@ -307,14 +321,18 @@ export async function runNodeMain(params = {}) { })); deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); - if (!shouldBuild(deps)) { + const buildRequirement = resolveBuildRequirement(deps); + if (!buildRequirement.shouldBuild) { if (!syncRuntimeArtifacts(deps)) { return 1; } return await runOpenClaw(deps); } - logRunner("Building TypeScript (dist is stale).", deps); + logRunner( + `Building TypeScript (dist is stale: ${buildRequirement.reason} - ${formatBuildReason(buildRequirement.reason)}).`, + deps, + ); const buildCmd = deps.execPath; const buildArgs = compilerArgs; const build = deps.spawn(buildCmd, buildArgs, { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index f8a73224922..730726298c4 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runNodeMain } from "../../scripts/run-node.mjs"; +import { resolveBuildRequirement, runNodeMain } from "../../scripts/run-node.mjs"; import { bundledDistPluginFile, bundledPluginFile, @@ -131,6 +131,39 @@ function createSpawnRecorder( return { spawnCalls, spawn, spawnSync }; } +function createBuildRequirementDeps( + tmp: string, + options: { + gitHead?: string; + gitStatus?: string; + env?: Record; + } = {}, +) { + const { spawnSync } = createSpawnRecorder({ + gitHead: options.gitHead, + gitStatus: options.gitStatus, + }); + return { + cwd: tmp, + env: { + ...process.env, + ...options.env, + }, + fs: fsSync, + spawnSync, + distRoot: path.join(tmp, "dist"), + distEntry: path.join(tmp, DIST_ENTRY), + buildStampPath: path.join(tmp, BUILD_STAMP), + sourceRoots: [path.join(tmp, "src"), path.join(tmp, bundledPluginRoot())].map((sourceRoot) => ({ + name: path.relative(tmp, sourceRoot).replaceAll("\\", "/"), + path: sourceRoot, + })), + configFiles: [ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN].map((filePath) => + path.join(tmp, filePath), + ), + }; +} + async function runStatusCommand(params: { tmp: string; spawn: (cmd: string, args: string[]) => ReturnType; @@ -433,6 +466,53 @@ describe("run-node script", () => { }); }); + it("reports dirty watched source trees as an explicit build reason", async () => { + await withTempDir(async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + }, + buildPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, DIST_ENTRY, BUILD_STAMP], + }); + + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: ` M ${ROOT_SRC}\n`, + }), + ); + + expect(requirement).toEqual({ + shouldBuild: true, + reason: "dirty_watched_tree", + }); + }); + }); + + it("reports a clean tree explicitly when dist is current", async () => { + await withTempDir(async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: "", + }), + ); + + expect(requirement).toEqual({ + shouldBuild: false, + reason: "clean", + }); + }); + }); + it("repairs missing bundled plugin metadata without rerunning tsdown", async () => { await withTempDir(async (tmp) => { await setupTrackedProject(tmp, {