fix(gateway): restore runtime postbuild freshness

This commit is contained in:
Ruben Cuevas
2026-05-02 23:36:51 -04:00
committed by Peter Steinberger
parent 0a13bd5841
commit 17643e549f
3 changed files with 470 additions and 4 deletions
+171 -2
View File
@@ -4,6 +4,10 @@ import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import {
collectBundledPluginBuildEntries,
NON_PACKAGED_BUNDLED_PLUGIN_DIRS,
} from "./lib/bundled-plugin-build-entries.mjs";
import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
@@ -67,6 +71,11 @@ const resolvePrivateQaRequiredDistEntries = (distRoot) => [
path.join(distRoot, "plugin-sdk", "qa-lab.js"),
path.join(distRoot, "plugin-sdk", "qa-runtime.js"),
];
const shouldIncludePrivateQaBundledOutputs = (env = process.env) =>
env.OPENCLAW_BUILD_PRIVATE_QA === "1";
const shouldRequireBundledPluginRuntimeOutput = (pluginId, env = process.env) =>
shouldIncludePrivateQaBundledOutputs(env) || !NON_PACKAGED_BUNDLED_PLUGIN_DIRS.has(pluginId);
const isExcludedSource = (filePath, sourceRoot, sourceRootName) => {
const relativePath = normalizePath(path.relative(sourceRoot, filePath));
@@ -146,7 +155,13 @@ const hasDirtySourceTree = (deps) => {
if (output === null) {
return null;
}
return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath));
return parseGitStatusPaths(output).some((repoPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
return (
isBuildRelevantRunNodePath(normalizedPath) ||
isDirtyBundledPluginPackageEntryChangeWithoutBuiltOutputs(normalizedPath, deps)
);
});
};
const isRuntimePostBuildRelevantPath = (repoPath) => {
@@ -167,7 +182,8 @@ const isRuntimePostBuildRelevantPath = (repoPath) => {
return false;
}
const pluginRelativePath = normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length);
if (pluginRelativePath.startsWith("skills/")) {
const pluginLocalPath = pluginRelativePath.split("/").slice(1).join("/");
if (pluginLocalPath === "skills" || pluginLocalPath.startsWith("skills/")) {
return true;
}
return extensionRestartMetadataFiles.has(path.posix.basename(pluginRelativePath));
@@ -257,6 +273,143 @@ const hasRuntimePostBuildInputMtimeChanged = (stampMtime, deps) => {
return latestInputMtime != null && latestInputMtime > stampMtime;
};
const resolveRuntimePostBuildDistRoot = (deps) => deps.distRoot ?? path.join(deps.cwd, "dist");
const resolveRuntimePostBuildRuntimeRoot = (deps) => path.join(deps.cwd, "dist-runtime");
const collectRunNodeBundledPluginBuildEntries = (deps) => {
if (!deps.fs.existsSync(path.join(deps.cwd, BUNDLED_PLUGIN_ROOT_DIR))) {
return [];
}
return collectBundledPluginBuildEntries({ cwd: deps.cwd, env: deps.env });
};
const resolveBuiltBundledPluginRuntimeEntryPath = (distRoot, pluginId, sourceEntry) =>
path.join(
distRoot,
"extensions",
pluginId,
sourceEntry.replace(/^\.\//, "").replace(/\.[^.]+$/u, ".js"),
);
const listBundledPluginRuntimeEntryPaths = (pluginEntry, deps) => {
const distRoot = resolveRuntimePostBuildDistRoot(deps);
return pluginEntry.sourceEntries
.map((sourceEntry) =>
resolveBuiltBundledPluginRuntimeEntryPath(distRoot, pluginEntry.id, sourceEntry),
)
.toSorted((left, right) => left.localeCompare(right));
};
const isDirtyBundledPluginPackageEntryChangeWithoutBuiltOutputs = (normalizedPath, deps) => {
if (!normalizedPath.startsWith("extensions/") || !normalizedPath.endsWith("/package.json")) {
return false;
}
const [, pluginId] = normalizedPath.split("/");
if (!pluginId || !shouldRequireBundledPluginRuntimeOutput(pluginId, deps.env)) {
return false;
}
const pluginEntry = collectRunNodeBundledPluginBuildEntries(deps).find(
(entry) => entry.id === pluginId,
);
if (!pluginEntry) {
return false;
}
return listBundledPluginRuntimeEntryPaths(pluginEntry, deps).some(
(filePath) => !deps.fs.existsSync(filePath),
);
};
const hasMissingBuiltBundledPluginRuntimeEntryOutput = (deps) => {
return collectRunNodeBundledPluginBuildEntries(deps)
.filter(({ id }) => shouldRequireBundledPluginRuntimeOutput(id, deps.env))
.some((pluginEntry) => {
const entryPaths = listBundledPluginRuntimeEntryPaths(pluginEntry, deps);
return entryPaths.some((filePath) => !deps.fs.existsSync(filePath));
});
};
const listBuiltBundledPluginEntries = (deps) => {
return collectRunNodeBundledPluginBuildEntries(deps)
.filter(({ id }) => shouldRequireBundledPluginRuntimeOutput(id, deps.env))
.filter((pluginEntry) =>
listBundledPluginRuntimeEntryPaths(pluginEntry, deps).some((filePath) =>
deps.fs.existsSync(filePath),
),
)
.toSorted((left, right) => left.id.localeCompare(right.id));
};
const listRequiredBundledPluginMetadataOutputs = (pluginEntries, deps) =>
pluginEntries.flatMap(({ id, hasManifest, hasPackageJson }) => {
const builtPluginDir = path.join(resolveRuntimePostBuildDistRoot(deps), "extensions", id);
const requiredPaths = [];
if (hasPackageJson) {
requiredPaths.push(path.join(builtPluginDir, "package.json"));
}
if (hasManifest) {
requiredPaths.push(path.join(builtPluginDir, "openclaw.plugin.json"));
}
return requiredPaths;
});
const listRuntimeOverlaySourcePaths = (sourceDir, deps) => {
const paths = [];
const queue = [sourceDir];
while (queue.length > 0) {
const current = queue.pop();
if (!current) {
continue;
}
let entries = [];
try {
entries = deps.fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name === "node_modules") {
continue;
}
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
queue.push(entryPath);
continue;
}
if (entry.isFile() || entry.isSymbolicLink()) {
paths.push(entryPath);
}
}
}
return paths.toSorted((left, right) => left.localeCompare(right));
};
const listRequiredBundledPluginRuntimeOverlayOutputs = (pluginEntries, deps) => {
const distRoot = resolveRuntimePostBuildDistRoot(deps);
const runtimeRoot = resolveRuntimePostBuildRuntimeRoot(deps);
const runtimePaths = [];
for (const pluginEntry of pluginEntries) {
const distPluginDir = path.join(distRoot, "extensions", pluginEntry.id);
const runtimePluginDir = path.join(runtimeRoot, "extensions", pluginEntry.id);
for (const sourcePath of listRuntimeOverlaySourcePaths(distPluginDir, deps)) {
runtimePaths.push(path.join(runtimePluginDir, path.relative(distPluginDir, sourcePath)));
}
}
return [...new Set(runtimePaths)].toSorted((left, right) => left.localeCompare(right));
};
const listRequiredRuntimePostBuildOutputs = (deps) => {
const builtPluginEntries = listBuiltBundledPluginEntries(deps);
return [
...listRequiredBundledPluginMetadataOutputs(builtPluginEntries, deps),
...listRequiredBundledPluginRuntimeOverlayOutputs(builtPluginEntries, deps),
];
};
const hasMissingRequiredRuntimePostBuildOutput = (deps) =>
listRequiredRuntimePostBuildOutputs(deps).some(
(filePath) => statMtime(filePath, deps.fs) == null,
);
export const resolveBuildRequirement = (deps) => {
if (deps.env.OPENCLAW_FORCE_BUILD === "1") {
return { shouldBuild: true, reason: "force_build" };
@@ -297,10 +450,17 @@ export const resolveBuildRequirement = (deps) => {
return { shouldBuild: true, reason: "dirty_watched_tree" };
}
if (dirty === false) {
if (hasMissingBuiltBundledPluginRuntimeEntryOutput(deps)) {
return { shouldBuild: true, reason: "missing_bundled_plugin_dist_entry" };
}
return { shouldBuild: false, reason: "clean" };
}
}
if (hasMissingBuiltBundledPluginRuntimeEntryOutput(deps)) {
return { shouldBuild: true, reason: "missing_bundled_plugin_dist_entry" };
}
if (hasSourceMtimeChanged(stamp.mtime, deps)) {
return { shouldBuild: true, reason: "source_mtime_newer" };
}
@@ -338,6 +498,9 @@ export const resolveRuntimePostBuildRequirement = (deps) => {
return { shouldSync: true, reason: "dirty_runtime_postbuild_inputs" };
}
if (dirty === false) {
if (hasMissingRequiredRuntimePostBuildOutput(deps)) {
return { shouldSync: true, reason: "missing_runtime_postbuild_output" };
}
return { shouldSync: false, reason: "clean" };
}
}
@@ -346,6 +509,10 @@ export const resolveRuntimePostBuildRequirement = (deps) => {
return { shouldSync: true, reason: "runtime_postbuild_input_mtime_newer" };
}
if (hasMissingRequiredRuntimePostBuildOutput(deps)) {
return { shouldSync: true, reason: "missing_runtime_postbuild_output" };
}
return { shouldSync: false, reason: "clean" };
};
@@ -357,6 +524,7 @@ const BUILD_REASON_LABELS = {
build_stamp_missing_head: "build stamp missing git head",
git_head_changed: "git head changed",
dirty_watched_tree: "dirty watched source tree",
missing_bundled_plugin_dist_entry: "bundled plugin dist entry missing",
source_mtime_newer: "source mtime newer than build stamp",
missing_private_qa_dist: "private QA dist entry missing",
clean: "clean",
@@ -364,6 +532,7 @@ const BUILD_REASON_LABELS = {
const RUNTIME_POSTBUILD_REASON_LABELS = {
force_runtime_postbuild: "forced by OPENCLAW_FORCE_RUNTIME_POSTBUILD",
missing_runtime_postbuild_output: "required runtime postbuild output missing",
missing_runtime_postbuild_stamp: "runtime postbuild stamp missing",
missing_build_stamp: "build stamp missing",
build_stamp_newer: "build stamp newer than runtime postbuild stamp",