mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix: derive generated asset watch exclusions
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user