fix(ci): stabilize ui i18n and gateway watch checks

This commit is contained in:
Vincent Koc
2026-04-05 23:19:03 +01:00
parent 2ed2dbba00
commit 1a3eb38aaf
18 changed files with 670 additions and 78 deletions

View File

@@ -2,6 +2,7 @@
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
@@ -19,6 +20,14 @@ const DEFAULTS = {
skipBuild: false,
};
const WATCH_GATEWAY_SKIP_ENV = {
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
};
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let i = 0; i < argv.length; i += 1) {
@@ -67,6 +76,10 @@ function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function removePathIfExists(targetPath) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
function normalizePath(filePath) {
return filePath.replaceAll("\\", "/");
}
@@ -212,15 +225,40 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir) {
async function allocateLoopbackPort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate watch regression port")));
return;
}
const { port } = address;
server.close((closeErr) => {
if (closeErr) {
reject(closeErr);
return;
}
resolve(port);
});
});
});
}
function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir, port) {
const shellSource = [
'echo "$$" > "$OPENCLAW_WATCH_PID_FILE"',
"exec node scripts/watch-node.mjs gateway --force --allow-unconfigured",
'mkdir -p "$OPENCLAW_HOME/.openclaw"',
`printf '%s\n' '{"gateway":{"controlUi":{"enabled":false}}}' > "$OPENCLAW_HOME/.openclaw/openclaw.json"`,
`exec node scripts/watch-node.mjs gateway --force --allow-unconfigured --port ${String(port)} --token watch-regression-token`,
].join("\n");
const env = {
OPENCLAW_WATCH_PID_FILE: pidFilePath,
HOME: isolatedHomeDir,
OPENCLAW_HOME: isolatedHomeDir,
...WATCH_GATEWAY_SKIP_ENV,
};
if (process.platform === "darwin") {
@@ -274,7 +312,17 @@ async function runTimedWatch(options, outputDir) {
fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8");
const stdoutPath = path.join(outputDir, "watch.stdout.log");
const stderrPath = path.join(outputDir, "watch.stderr.log");
const { command, args, env } = buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir);
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
removePathIfExists(stalePath);
}
const port = await allocateLoopbackPort();
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
const { command, args, env } = buildTimedWatchCommand(
pidFilePath,
timeFilePath,
isolatedHomeDir,
port,
);
const child = spawn(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...env },
@@ -371,6 +419,10 @@ function fail(message) {
console.error(`FAIL: ${message}`);
}
function warn(message) {
console.error(`WARN: ${message}`);
}
function detectWatchBuildReason(stdout, stderr) {
const combined = `${stdout}\n${stderr}`;
const match = combined.match(/Building TypeScript \(dist is stale: ([a-z_]+)/);
@@ -479,6 +531,7 @@ async function main() {
console.log(JSON.stringify(summary, null, 2));
const failures = [];
const warnings = [];
if (watchTriggeredBuild && watchBuildReason === "dirty_watched_tree") {
failures.push(
"gateway:watch invalid local run: dirty watched source tree forced a rebuild during the watch window",
@@ -501,11 +554,15 @@ async function main() {
`LOUD ALARM: gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above loud-alarm threshold ${options.cpuFailMs}ms`,
);
} else if (cpuMs > options.cpuWarnMs) {
failures.push(
warnings.push(
`gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above target ${options.cpuWarnMs}ms`,
);
}
for (const message of warnings) {
warn(message);
}
if (failures.length > 0) {
for (const message of failures) {
fail(message);

View File

@@ -331,7 +331,7 @@ const runOpenClaw = async (deps) => {
const syncRuntimeArtifacts = (deps) => {
try {
runRuntimePostBuild({ cwd: deps.cwd });
deps.runRuntimePostBuild({ cwd: deps.cwd });
} catch (error) {
logRunner(
`Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`,
@@ -355,6 +355,8 @@ const writeBuildStamp = (deps) => {
}
};
const shouldSkipCleanWatchRuntimeSync = (deps) => deps.env.OPENCLAW_WATCH_MODE === "1";
export async function runNodeMain(params = {}) {
const deps = {
spawn: params.spawn ?? spawn,
@@ -366,6 +368,7 @@ export async function runNodeMain(params = {}) {
cwd: params.cwd ?? process.cwd(),
args: params.args ?? process.argv.slice(2),
env: params.env ? { ...params.env } : { ...process.env },
runRuntimePostBuild: params.runRuntimePostBuild ?? runRuntimePostBuild,
};
deps.distRoot = path.join(deps.cwd, "dist");
@@ -379,7 +382,7 @@ export async function runNodeMain(params = {}) {
const buildRequirement = resolveBuildRequirement(deps);
if (!buildRequirement.shouldBuild) {
if (!syncRuntimeArtifacts(deps)) {
if (!shouldSkipCleanWatchRuntimeSync(deps) && !syncRuntimeArtifacts(deps)) {
return 1;
}
return await runOpenClaw(deps);

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
@@ -11,6 +10,7 @@ const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
const WATCH_RESTART_SIGNAL = "SIGTERM";
const WATCH_RESTARTABLE_CHILD_EXIT_CODES = new Set([143]);
const WATCH_RESTARTABLE_CHILD_SIGNALS = new Set(["SIGTERM"]);
const WATCH_IGNORED_PATH_SEGMENTS = new Set([".git", "dist", "node_modules"]);
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
@@ -27,6 +27,13 @@ const resolveRepoPath = (filePath, cwd) => {
return normalizePath(rawPath);
};
const hasIgnoredPathSegment = (repoPath) =>
normalizePath(repoPath)
.split("/")
.some((segment) => WATCH_IGNORED_PATH_SEGMENTS.has(segment));
const looksLikeDirectoryPath = (repoPath) => path.posix.extname(normalizePath(repoPath)) === "";
const isDirectoryLikeWatchedPath = (repoPath, watchPaths) => {
const normalizedRepoPath = normalizePath(repoPath).replace(/\/$/, "");
return watchPaths.some((watchPath) => {
@@ -41,17 +48,15 @@ const isDirectoryLikeWatchedPath = (repoPath, watchPaths) => {
});
};
const isIgnoredWatchPath = (filePath, cwd, watchPaths) => {
const isIgnoredWatchPath = (filePath, cwd, watchPaths, stats) => {
const repoPath = resolveRepoPath(filePath, cwd);
const statPath = path.isAbsolute(String(filePath ?? ""))
? String(filePath ?? "")
: path.join(cwd, String(filePath ?? ""));
try {
if (fs.statSync(statPath).isDirectory() && isDirectoryLikeWatchedPath(repoPath, watchPaths)) {
if (hasIgnoredPathSegment(repoPath)) {
return true;
}
if (isDirectoryLikeWatchedPath(repoPath, watchPaths)) {
if (stats?.isDirectory?.() || looksLikeDirectoryPath(repoPath)) {
return false;
}
} catch {
// Fall through to path-based filtering for deleted paths and other transient races.
}
return !isRestartRelevantRunNodePath(repoPath);
};
@@ -109,7 +114,8 @@ export async function runWatchMain(params = {}) {
const watcher = deps.createWatcher(deps.watchPaths, {
ignoreInitial: true,
ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd, deps.watchPaths),
ignored: (watchPath, stats) =>
isIgnoredWatchPath(watchPath, deps.cwd, deps.watchPaths, stats),
});
const settle = (code) => {