diff --git a/extensions/browser/package.json b/extensions/browser/package.json index ed4523c57bcb..d8c35b94077f 100644 --- a/extensions/browser/package.json +++ b/extensions/browser/package.json @@ -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" } } diff --git a/extensions/canvas/package.json b/extensions/canvas/package.json index f8b2ccfc5fb0..d55ddec923cd 100644 --- a/extensions/canvas/package.json +++ b/extensions/canvas/package.json @@ -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" } } diff --git a/scripts/lib/static-extension-assets.d.mts b/scripts/lib/static-extension-assets.d.mts index 8a756b356a2d..477dca78af0e 100644 --- a/scripts/lib/static-extension-assets.d.mts +++ b/scripts/lib/static-extension-assets.d.mts @@ -14,6 +14,10 @@ export function listStaticExtensionAssetOutputs(params?: Record * Lists source file paths for declared static extension assets. */ export function listStaticExtensionAssetSources(params?: Record): unknown; +/** + * Lists source-tree outputs generated by extension asset build hooks. + */ +export function listGeneratedExtensionAssetSources(params?: Record): string[]; /** * Copies declared static extension assets from source packages into root dist. */ diff --git a/scripts/lib/static-extension-assets.mjs b/scripts/lib/static-extension-assets.mjs index 261e0fb9dd8d..9ad560f4ae53 100644 --- a/scripts/lib/static-extension-assets.mjs +++ b/scripts/lib/static-extension-assets.mjs @@ -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. */ diff --git a/scripts/run-node-watch-paths.d.mts b/scripts/run-node-watch-paths.d.mts index aceec2963e3f..a77d44873a36 100644 --- a/scripts/run-node-watch-paths.d.mts +++ b/scripts/run-node-watch-paths.d.mts @@ -7,6 +7,15 @@ export const runNodeWatchedPaths: string[]; /** Plugin metadata files that require a runtime restart even without source edits. */ export const extensionRestartMetadataFiles: Set; +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. */ diff --git a/scripts/run-node-watch-paths.mjs b/scripts/run-node-watch-paths.mjs index b858ebd06f8a..49ce4179d892 100644 --- a/scripts/run-node-watch-paths.mjs +++ b/scripts/run-node-watch-paths.mjs @@ -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); diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index c47587c90a16..459bdd6636a1 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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", diff --git a/test/scripts/bundled-plugin-assets.test.ts b/test/scripts/bundled-plugin-assets.test.ts index 9480d2c93f78..5f5370646394 100644 --- a/test/scripts/bundled-plugin-assets.test.ts +++ b/test/scripts/bundled-plugin-assets.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) { 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({