fix: derive generated asset watch exclusions

This commit is contained in:
Shakker
2026-07-18 06:42:27 +01:00
committed by Shakker
parent b118e9e913
commit 643476f24d
8 changed files with 155 additions and 36 deletions
+3
View File
@@ -24,6 +24,9 @@
],
"assetScripts": {
"build": "node scripts/build-copilot-runtime.mjs",
"buildOutputs": [
"chrome-extension/modules/copilot-runtime.js"
],
"copy": "node scripts/copy-chrome-extension.mjs"
}
}
+4
View File
@@ -21,6 +21,10 @@
],
"assetScripts": {
"build": "node scripts/bundle-a2ui.mjs",
"buildOutputs": [
"src/host/a2ui/.bundle.hash",
"src/host/a2ui/a2ui.bundle.js"
],
"copy": "node scripts/copy-a2ui.mjs"
}
}
@@ -14,6 +14,10 @@ export function listStaticExtensionAssetOutputs(params?: Record<string, unknown>
* Lists source file paths for declared static extension assets.
*/
export function listStaticExtensionAssetSources(params?: Record<string, unknown>): unknown;
/**
* Lists source-tree outputs generated by extension asset build hooks.
*/
export function listGeneratedExtensionAssetSources(params?: Record<string, unknown>): string[];
/**
* Copies declared static extension assets from source packages into root dist.
*/
+51 -6
View File
@@ -70,12 +70,7 @@ function listTrackedExtensionPackageDirs(rootDir, fsImpl) {
.toSorted((left, right) => left.dirName.localeCompare(right.dirName));
}
function listExtensionPackageDirs(rootDir, fsImpl) {
const trackedDirs = listTrackedExtensionPackageDirs(rootDir, fsImpl);
if (trackedDirs) {
return trackedDirs;
}
function listFilesystemExtensionPackageDirs(rootDir, fsImpl) {
const extensionsRoot = path.join(rootDir, "extensions");
if (!fsImpl.existsSync(extensionsRoot)) {
return [];
@@ -92,6 +87,13 @@ function listExtensionPackageDirs(rootDir, fsImpl) {
.toSorted((left, right) => left.dirName.localeCompare(right.dirName));
}
function listExtensionPackageDirs(rootDir, fsImpl) {
return (
listTrackedExtensionPackageDirs(rootDir, fsImpl) ??
listFilesystemExtensionPackageDirs(rootDir, fsImpl)
);
}
function listDistExtensionPackageDirs(rootDir, fsImpl) {
const extensionsRoot = path.join(rootDir, "dist", "extensions");
if (!fsImpl.existsSync(extensionsRoot)) {
@@ -112,6 +114,16 @@ function readPackageStaticAssetEntries(packageJson) {
return Array.isArray(entries) ? entries : [];
}
function hasPackageAssetBuild(packageJson) {
const command = packageJson.openclaw?.assetScripts?.build;
return typeof command === "string" && command.trim().length > 0;
}
function readPackageGeneratedAssetOutputEntries(packageJson) {
const entries = packageJson.openclaw?.assetScripts?.buildOutputs;
return Array.isArray(entries) ? entries : [];
}
// External plugins (`bundledDist: false`) own their own dist and are excluded
// from core dist, so their static assets must not be discovered for core
// runtime postbuild copies.
@@ -205,6 +217,39 @@ export function listStaticExtensionAssetSources(params = {}) {
.toSorted((left, right) => left.localeCompare(right));
}
/**
* Lists source-tree outputs generated by extension asset build hooks.
*/
export function listGeneratedExtensionAssetSources(params = {}) {
const rootDir = params.rootDir ?? process.cwd();
const fsImpl = params.fs ?? fs;
const sources = new Set();
for (const { dirName, hasPackageJson, packageJsonPath } of listFilesystemExtensionPackageDirs(
rootDir,
fsImpl,
)) {
if (!(hasPackageJson ?? fsImpl.existsSync(packageJsonPath))) {
continue;
}
const packageJson = readJsonFile(packageJsonPath, fsImpl);
if (!hasPackageAssetBuild(packageJson)) {
continue;
}
const packageSources = [
...readPackageStaticAssetEntries(packageJson).map((entry) => entry?.source),
...readPackageGeneratedAssetOutputEntries(packageJson),
];
for (const entry of packageSources) {
const source = normalizePackageRelativePath(entry);
if (source) {
sources.add(toPosixPath(path.posix.join("extensions", dirName, source)));
}
}
}
return [...sources].toSorted((left, right) => left.localeCompare(right));
}
/**
* Copies declared static extension assets from source packages into root dist.
*/
+9
View File
@@ -7,6 +7,15 @@ export const runNodeWatchedPaths: string[];
/** Plugin metadata files that require a runtime restart even without source edits. */
export const extensionRestartMetadataFiles: Set<string>;
export interface RunNodePathClassifier {
refreshGeneratedPluginAssetPaths(): void;
isBuildRelevantRunNodePath(repoPath: unknown): boolean;
isRestartRelevantRunNodePath(repoPath: unknown): boolean;
}
/** Creates a path classifier whose generated-output metadata can be refreshed. */
export function createRunNodePathClassifier(params?: { rootDir?: string }): RunNodePathClassifier;
/** Normalizes watch paths to repository-style POSIX separators. */
export function normalizeRunNodePath(filePath: unknown): string;
/** Returns true when a repo path should trigger a dev rebuild. */
+40 -18
View File
@@ -4,6 +4,7 @@ import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
} from "./lib/bundled-plugin-paths.mjs";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs";
const RUN_NODE_PACKAGE_SOURCE_ROOTS = [
// Root runtime code imports these package sources through tsconfig aliases,
@@ -36,17 +37,6 @@ export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles
/** Plugin metadata files that require a runtime restart even without source edits. */
export const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
const ignoredRunNodeRepoPathPatterns = [
/^extensions\/[^/]+\/src\/host\/.+\/\.bundle\.hash$/u,
/^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u,
];
// Asset build hooks write these generated runtime bundles. They are outputs, not
// source inputs; watching them makes the dev build react to its own writes.
const generatedPluginAssetPaths = new Set([
"extensions/diffs-language-pack/assets/viewer-runtime.js",
"extensions/diffs/assets/viewer-runtime.js",
"extensions/discord/assets/embedded-app-sdk.mjs",
]);
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
/** Normalizes watch paths to repository-style POSIX separators. */
@@ -74,12 +64,13 @@ const isRestartRelevantExtensionPath = (relativePath) => {
return isBuildRelevantSourcePath(normalizedPath);
};
const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => {
const isRelevantRunNodePath = (
repoPath,
isRelevantBundledPluginPath,
generatedPluginAssetPaths,
) => {
const normalizedPath = normalizeRunNodePath(repoPath).replace(/^\.\/+/, "");
if (
generatedPluginAssetPaths.has(normalizedPath) ||
ignoredRunNodeRepoPathPatterns.some((pattern) => pattern.test(normalizedPath))
) {
if (generatedPluginAssetPaths.has(normalizedPath)) {
return false;
}
if (runNodeConfigFiles.includes(normalizedPath)) {
@@ -99,10 +90,41 @@ const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => {
return false;
};
/** Creates a path classifier whose generated-output metadata can be refreshed. */
export function createRunNodePathClassifier(params = {}) {
const rootDir = params.rootDir ?? process.cwd();
let generatedPluginAssetPaths = new Set();
return {
refreshGeneratedPluginAssetPaths() {
generatedPluginAssetPaths = new Set(listGeneratedExtensionAssetSources({ rootDir }));
},
isBuildRelevantRunNodePath(repoPath) {
return isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath, generatedPluginAssetPaths);
},
isRestartRelevantRunNodePath(repoPath) {
return isRelevantRunNodePath(
repoPath,
isRestartRelevantExtensionPath,
generatedPluginAssetPaths,
);
},
};
}
let defaultRunNodePathClassifier;
const getDefaultRunNodePathClassifier = () => {
if (!defaultRunNodePathClassifier) {
defaultRunNodePathClassifier = createRunNodePathClassifier();
defaultRunNodePathClassifier.refreshGeneratedPluginAssetPaths();
}
return defaultRunNodePathClassifier;
};
/** Returns true when a repo path should trigger a dev rebuild. */
export const isBuildRelevantRunNodePath = (repoPath) =>
isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath);
getDefaultRunNodePathClassifier().isBuildRelevantRunNodePath(repoPath);
/** Returns true when a repo path should restart the running dev process. */
export const isRestartRelevantRunNodePath = (repoPath) =>
isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath);
getDefaultRunNodePathClassifier().isRestartRelevantRunNodePath(repoPath);
+1
View File
@@ -1454,6 +1454,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
[
"scripts/lib/static-extension-assets.mjs",
[
"test/scripts/bundled-plugin-assets.test.ts",
"test/scripts/runtime-postbuild.test.ts",
"src/infra/run-node.test.ts",
"test/scripts/plugin-npm-runtime-build-args.test.ts",
+43 -12
View File
@@ -7,7 +7,9 @@ import {
parseBundledPluginAssetArgs,
readBundledPluginAssetHooks,
} from "../../scripts/bundled-plugin-assets.mjs";
import { listGeneratedExtensionAssetSources } from "../../scripts/lib/static-extension-assets.mjs";
import {
createRunNodePathClassifier,
isBuildRelevantRunNodePath,
isRestartRelevantRunNodePath,
} from "../../scripts/run-node-watch-paths.mjs";
@@ -26,6 +28,7 @@ async function withPluginAssetFixture(run: (rootDir: string) => Promise<void>) {
openclaw: {
assetScripts: {
build: "node scripts/bundle-a2ui.mjs",
buildOutputs: ["src/host/a2ui/a2ui.bundle.js"],
copy: "node scripts/copy-a2ui.mjs",
},
},
@@ -86,27 +89,55 @@ describe("bundled plugin assets", () => {
it("keeps build-generated static assets out of the source watcher", async () => {
const rootDir = process.cwd();
const hooks = await readBundledPluginAssetHooks({ phase: "build", rootDir });
const generatedAssetSources = hooks.flatMap((hook) => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(hook.pluginDir, "package.json"), "utf8"),
) as {
openclaw?: { build?: { staticAssets?: Array<{ source?: string }> } };
};
const pluginPath = path.relative(rootDir, hook.pluginDir).replaceAll(path.sep, "/");
return (packageJson.openclaw?.build?.staticAssets ?? []).flatMap((asset) => {
const source = asset.source?.replace(/^\.\/+/u, "");
return source ? [path.posix.join(pluginPath, source)] : [];
});
});
const generatedAssetSources = listGeneratedExtensionAssetSources({ rootDir });
for (const hook of hooks) {
const pluginPath = path.relative(rootDir, hook.pluginDir).replaceAll(path.sep, "/");
expect(
generatedAssetSources.some((source) => source.startsWith(`${pluginPath}/`)),
`${hook.pluginId} build hook must declare at least one generated output`,
).toBe(true);
}
expect(generatedAssetSources).toContain(
"extensions/browser/chrome-extension/modules/copilot-runtime.js",
);
expect(generatedAssetSources).toContain("extensions/canvas/src/host/a2ui/.bundle.hash");
expect(generatedAssetSources).toContain("extensions/canvas/src/host/a2ui/a2ui.bundle.js");
expect(generatedAssetSources).toContain("extensions/discord/assets/embedded-app-sdk.mjs");
for (const source of generatedAssetSources) {
expect(isBuildRelevantRunNodePath(source), source).toBe(false);
expect(isRestartRelevantRunNodePath(source), source).toBe(false);
}
expect(
isRestartRelevantRunNodePath("extensions/browser/scripts/copilot-runtime-entry.ts"),
).toBe(true);
expect(isRestartRelevantRunNodePath("extensions/discord/src/activities/http.ts")).toBe(true);
});
it("refreshes generated output metadata without recreating the watcher", async () => {
await withPluginAssetFixture(async (rootDir) => {
const packagePath = path.join(rootDir, "extensions", "canvas", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")) as {
openclaw: { assetScripts: { buildOutputs?: string[] } };
};
delete packageJson.openclaw.assetScripts.buildOutputs;
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
const classifier = createRunNodePathClassifier({ rootDir });
classifier.refreshGeneratedPluginAssetPaths();
const generatedPath = "extensions/canvas/src/host/a2ui/a2ui.bundle.js";
expect(classifier.isRestartRelevantRunNodePath(generatedPath)).toBe(true);
packageJson.openclaw.assetScripts.buildOutputs = ["src/host/a2ui/a2ui.bundle.js"];
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
classifier.refreshGeneratedPluginAssetPaths();
expect(classifier.isBuildRelevantRunNodePath(generatedPath)).toBe(false);
expect(classifier.isRestartRelevantRunNodePath(generatedPath)).toBe(false);
});
});
it("discovers plugin-owned asset scripts by manifest id", async () => {
await withPluginAssetFixture(async (rootDir) => {
const hooks = await readBundledPluginAssetHooks({