diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cd3ba7b9ff..01d1de22562c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1072,6 +1072,7 @@ Docs: https://docs.openclaw.ai - Memory/search: keep sqlite-vec optional in packaged installs and point missing-extension recovery at the valid `agents.defaults.memorySearch.store.vector.extensionPath` setting. Thanks @willemsej and @vincentkoc. - Gateway: keep directly requested plugin tools invokable under restrictive tool profiles while preserving explicit deny lists and the HTTP safety deny list, preventing catalog/invoke mismatches that surface as "Tool not available". Thanks @BunsDev. - Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates. +- Gateway/watch mode: detect missing bundled plugin dist and runtime-postbuild outputs before launching, so `pnpm gateway:watch` rebuilds or restages incomplete plugin artifacts instead of trusting stale stamps. Thanks @rubencu. - Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc. - Bonjour: disable LAN mDNS advertising after a repeated stuck-announcing recovery instead of repeatedly restarting ciao and saturating the Gateway event loop. - Channels/setup: label installable channel picker hints as remote npm installs and hide remote install hints for bundled plugins that already ship with OpenClaw. diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 36c7cd2c0575..558f17b652c2 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -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", diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 590898db0644..3ef23f09aeee 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -31,10 +31,21 @@ const BUILD_STAMP = `dist/${BUILD_STAMP_FILE}`; const RUNTIME_POSTBUILD_STAMP = `dist/${RUNTIME_POSTBUILD_STAMP_FILE}`; const QA_LAB_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-lab.js"; const QA_RUNTIME_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-runtime.js"; +const EXTENSION_INDEX = bundledPluginFile("demo", "index.ts"); const EXTENSION_SRC = bundledPluginFile("demo", "src/index.ts"); +const EXTENSION_EXTRA_SRC = bundledPluginFile("demo", "src/extra.ts"); +const EXTENSION_SKILL = bundledPluginFile("demo", "skills/SKILL.md"); const EXTENSION_MANIFEST = bundledPluginFile("demo", "openclaw.plugin.json"); const EXTENSION_PACKAGE = bundledPluginFile("demo", "package.json"); const EXTENSION_README = bundledPluginFile("demo", "README.md"); +const DIST_EXTENSION_INDEX = bundledDistPluginFile("demo", "index.js"); +const DIST_EXTENSION_SRC = bundledDistPluginFile("demo", "src/index.js"); +const DIST_EXTENSION_SKILL = bundledDistPluginFile("demo", "skills/SKILL.md"); +const DIST_EXTENSION_RUNTIME_SRC = "dist-runtime/extensions/demo/src/index.js"; +const DIST_RUNTIME_EXTENSION_INDEX = "dist-runtime/extensions/demo/index.js"; +const DIST_RUNTIME_EXTENSION_MANIFEST = "dist-runtime/extensions/demo/openclaw.plugin.json"; +const DIST_RUNTIME_EXTENSION_PACKAGE = "dist-runtime/extensions/demo/package.json"; +const DIST_RUNTIME_EXTENSION_SKILL = "dist-runtime/extensions/demo/skills/SKILL.md"; const DIST_EXTENSION_MANIFEST = bundledDistPluginFile("demo", "openclaw.plugin.json"); const DIST_EXTENSION_PACKAGE = bundledDistPluginFile("demo", "package.json"); @@ -1013,12 +1024,16 @@ describe("run-node script", () => { await setupTrackedProject(tmp, { files: { [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_INDEX]: "export default {};\n", [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_EXTENSION_INDEX]: "export default {};\n", [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', }, buildPaths: [ ROOT_SRC, + EXTENSION_INDEX, EXTENSION_MANIFEST, + DIST_EXTENSION_INDEX, ROOT_TSCONFIG, ROOT_PACKAGE, DIST_ENTRY, @@ -1285,13 +1300,15 @@ describe("run-node script", () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { files: { + [EXTENSION_INDEX]: "export default {};\n", [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', [EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n', [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", [DIST_EXTENSION_PACKAGE]: '{"name":"demo","openclaw":{"extensions":["./stale.js"]}}\n', }, - oldPaths: [EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], - buildPaths: [DIST_ENTRY, BUILD_STAMP, DIST_EXTENSION_PACKAGE], + oldPaths: [EXTENSION_INDEX, EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN], + buildPaths: [DIST_ENTRY, BUILD_STAMP, DIST_EXTENSION_INDEX, DIST_EXTENSION_PACKAGE], newPaths: [EXTENSION_PACKAGE], }); @@ -1341,18 +1358,22 @@ describe("run-node script", () => { await setupTrackedProject(tmp, { files: { [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_INDEX]: "export default {};\n", [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", [DIST_EXTENSION_MANIFEST]: '{"id":"stale","configSchema":{"type":"object"}}\n', }, buildPaths: [ ROOT_SRC, + EXTENSION_INDEX, EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN, DIST_ENTRY, BUILD_STAMP, + DIST_EXTENSION_INDEX, DIST_EXTENSION_MANIFEST, ], }); @@ -1451,6 +1472,144 @@ describe("run-node script", () => { }); }); + it("reports clean in sparse worktrees without bundled plugin sources", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + await fs.rm(resolvePath(tmp, "extensions"), { recursive: true, force: true }); + + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: "", + }), + ); + + expect(requirement).toEqual({ + shouldBuild: false, + reason: "clean", + }); + }); + }); + + it("rebuilds when dirty bundled package entries point at missing dist outputs", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_EXTRA_SRC]: "export const extra = true;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts","./src/extra.ts"]}}\n', + [DIST_EXTENSION_SRC]: "export default {};\n", + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_SRC, + EXTENSION_EXTRA_SRC, + EXTENSION_MANIFEST, + EXTENSION_PACKAGE, + ROOT_TSCONFIG, + ROOT_PACKAGE, + DIST_ENTRY, + DIST_EXTENSION_SRC, + BUILD_STAMP, + ], + }); + + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: ` M ${EXTENSION_PACKAGE}\n`, + }), + ); + + expect(requirement).toEqual({ + shouldBuild: true, + reason: "dirty_watched_tree", + }); + }); + }); + + it("rebuilds when clean bundled plugin dist outputs are partially missing", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_EXTRA_SRC]: "export const extra = true;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts","./src/extra.ts"]}}\n', + [DIST_EXTENSION_SRC]: "export default {};\n", + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_SRC, + EXTENSION_EXTRA_SRC, + EXTENSION_MANIFEST, + EXTENSION_PACKAGE, + ROOT_TSCONFIG, + ROOT_PACKAGE, + DIST_ENTRY, + DIST_EXTENSION_SRC, + BUILD_STAMP, + ], + }); + + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: "", + }), + ); + + expect(requirement).toEqual({ + shouldBuild: true, + reason: "missing_bundled_plugin_dist_entry", + }); + }); + }); + + it("rebuilds when a clean stamped bundled plugin dist directory is missing", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts"]}}\n', + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_SRC, + EXTENSION_MANIFEST, + EXTENSION_PACKAGE, + ROOT_TSCONFIG, + ROOT_PACKAGE, + DIST_ENTRY, + BUILD_STAMP, + ], + }); + + const requirement = resolveBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: "", + }), + ); + + expect(requirement).toEqual({ + shouldBuild: true, + reason: "missing_bundled_plugin_dist_entry", + }); + }); + }); + it("reports clean runtime postbuild artifacts when the runtime stamp matches HEAD", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { @@ -1476,17 +1635,117 @@ describe("run-node script", () => { }); }); + it("reports missing runtime postbuild outputs even when stamps match HEAD", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_SRC]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.ts"]}}\n', + [DIST_EXTENSION_SRC]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.js"]}}\n', + [DIST_EXTENSION_RUNTIME_SRC]: "export default {};\n", + [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [DIST_RUNTIME_EXTENSION_PACKAGE]: '{"openclaw":{"extensions":["./src/index.js"]}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_SRC, + EXTENSION_MANIFEST, + EXTENSION_PACKAGE, + DIST_ENTRY, + DIST_EXTENSION_SRC, + DIST_EXTENSION_MANIFEST, + DIST_EXTENSION_PACKAGE, + DIST_EXTENSION_RUNTIME_SRC, + DIST_RUNTIME_EXTENSION_MANIFEST, + DIST_RUNTIME_EXTENSION_PACKAGE, + BUILD_STAMP, + RUNTIME_POSTBUILD_STAMP, + ], + }); + await fs.rm(resolvePath(tmp, DIST_EXTENSION_PACKAGE)); + + const requirement = resolveRuntimePostBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: "", + }), + ); + + expect(requirement).toEqual({ + shouldSync: true, + reason: "missing_runtime_postbuild_output", + }); + }); + }); + + it("reports missing runtime skill outputs even when stamps match HEAD", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_INDEX]: "export default {};\n", + [EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [EXTENSION_SKILL]: "# Demo\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", + [DIST_EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [DIST_EXTENSION_SKILL]: "# Demo\n", + [DIST_RUNTIME_EXTENSION_INDEX]: "export default {};\n", + [DIST_RUNTIME_EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [DIST_RUNTIME_EXTENSION_SKILL]: "# Demo\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_INDEX, + EXTENSION_MANIFEST, + EXTENSION_SKILL, + DIST_ENTRY, + DIST_EXTENSION_INDEX, + DIST_EXTENSION_MANIFEST, + DIST_EXTENSION_SKILL, + DIST_RUNTIME_EXTENSION_INDEX, + DIST_RUNTIME_EXTENSION_MANIFEST, + DIST_RUNTIME_EXTENSION_SKILL, + BUILD_STAMP, + RUNTIME_POSTBUILD_STAMP, + ], + }); + await fs.rm(resolvePath(tmp, DIST_RUNTIME_EXTENSION_SKILL)); + + const requirement = resolveRuntimePostBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: "", + }), + ); + + expect(requirement).toEqual({ + shouldSync: true, + reason: "missing_runtime_postbuild_output", + }); + }); + }); + it("reports dirty runtime postbuild inputs separately from rebuild inputs", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { files: { [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_INDEX]: "export default {};\n", [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + [DIST_EXTENSION_INDEX]: "export default {};\n", }, buildPaths: [ ROOT_SRC, + EXTENSION_INDEX, EXTENSION_MANIFEST, + DIST_EXTENSION_INDEX, ROOT_TSCONFIG, ROOT_PACKAGE, DIST_ENTRY, @@ -1535,21 +1794,58 @@ describe("run-node script", () => { }); }); + it("reports bundled skill edits as runtime postbuild inputs", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","skills":["./skills/SKILL.md"]}\n', + [EXTENSION_SKILL]: "# Demo\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_MANIFEST, + EXTENSION_SKILL, + ROOT_TSCONFIG, + ROOT_PACKAGE, + DIST_ENTRY, + BUILD_STAMP, + RUNTIME_POSTBUILD_STAMP, + ], + }); + + const deps = createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: ` M ${EXTENSION_SKILL}\n`, + }); + + expect(resolveRuntimePostBuildRequirement(deps)).toEqual({ + shouldSync: true, + reason: "dirty_runtime_postbuild_inputs", + }); + }); + }); + it("repairs missing bundled plugin metadata without rerunning tsdown", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { files: { [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_INDEX]: "export default {};\n", [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', [ROOT_TSDOWN]: "export default {};\n", + [DIST_EXTENSION_INDEX]: "export default {};\n", }, buildPaths: [ ROOT_SRC, + EXTENSION_INDEX, EXTENSION_MANIFEST, ROOT_TSCONFIG, ROOT_PACKAGE, ROOT_TSDOWN, DIST_ENTRY, + DIST_EXTENSION_INDEX, BUILD_STAMP, ], });