mirror of https://github.com/openclaw/openclaw.git
fix(dev): classify dirty-tree watch invalidations
This commit is contained in:
parent
622bdfdad1
commit
cd8d0881ed
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
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<typeof createExitedProcess>;
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Reference in New Issue