diff --git a/scripts/run-node.d.mts b/scripts/run-node.d.mts index 34e48d847db..206f378907b 100644 --- a/scripts/run-node.d.mts +++ b/scripts/run-node.d.mts @@ -13,6 +13,15 @@ export function resolveBuildRequirement(deps: { configFiles: string[]; }): { shouldBuild: boolean; reason: string }; +export function resolveRuntimePostBuildRequirement(deps: { + cwd: string; + env: NodeJS.ProcessEnv; + fs: unknown; + spawnSync: unknown; + buildStampPath: string; + runtimePostBuildStampPath: string; +}): { shouldSync: boolean; reason: string }; + export function acquireRunNodeBuildLock(deps: { cwd: string; args: readonly string[]; diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 286c582e23a..e9c142634f7 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -17,10 +17,32 @@ const compilerArgs = [buildScript, "--no-clean"]; const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR]; const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; +const runtimePostBuildStampFile = ".runtime-postbuildstamp"; +const runtimePostBuildWatchedPaths = [ + "scripts/copy-bundled-plugin-metadata.mjs", + "scripts/copy-plugin-sdk-root-alias.mjs", + "scripts/lib", + "scripts/npm-runner.mjs", + "scripts/runtime-postbuild-shared.mjs", + "scripts/runtime-postbuild.mjs", + "scripts/stage-bundled-plugin-runtime-deps.mjs", + "scripts/stage-bundled-plugin-runtime.mjs", + "scripts/windows-cmd-helpers.mjs", + "scripts/write-official-channel-catalog.mjs", + "src/plugin-sdk/root-alias.cjs", + BUNDLED_PLUGIN_ROOT_DIR, +]; const ignoredRunNodeRepoPaths = new Set([ "src/canvas-host/a2ui/.bundle.hash", "src/canvas-host/a2ui/a2ui.bundle.js", ]); +const runtimePostBuildScriptPaths = new Set( + runtimePostBuildWatchedPaths.filter((entry) => entry.startsWith("scripts/")), +); +const runtimePostBuildStaticAssetPaths = new Set([ + "extensions/acpx/src/runtime-internals/mcp-proxy.mjs", + "extensions/diffs/assets/viewer-runtime.js", +]); const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); @@ -130,11 +152,11 @@ const findLatestMtime = (dirPath, shouldSkip, deps) => { return latest; }; -const readGitStatus = (deps) => { +const readGitStatus = (deps, paths = runNodeWatchedPaths) => { try { const result = deps.spawnSync( "git", - ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], + ["status", "--porcelain", "--untracked-files=normal", "--", ...paths], { cwd: deps.cwd, encoding: "utf8", @@ -165,6 +187,38 @@ const hasDirtySourceTree = (deps) => { return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath)); }; +const isRuntimePostBuildRelevantPath = (repoPath) => { + const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); + if (normalizedPath === "src/plugin-sdk/root-alias.cjs") { + return true; + } + if (runtimePostBuildStaticAssetPaths.has(normalizedPath)) { + return true; + } + if ( + normalizedPath.startsWith("scripts/") && + (runtimePostBuildScriptPaths.has(normalizedPath) || normalizedPath.startsWith("scripts/lib/")) + ) { + return true; + } + if (!normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { + return false; + } + const pluginRelativePath = normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length); + if (pluginRelativePath.startsWith("skills/")) { + return true; + } + return extensionRestartMetadataFiles.has(path.posix.basename(pluginRelativePath)); +}; + +const hasDirtyRuntimePostBuildInputs = (deps) => { + const output = readGitStatus(deps, runtimePostBuildWatchedPaths); + if (output === null) { + return null; + } + return parseGitStatusPaths(output).some((repoPath) => isRuntimePostBuildRelevantPath(repoPath)); +}; + const readBuildStamp = (deps) => { const mtime = statMtime(deps.buildStampPath, deps.fs); if (mtime == null) { @@ -183,6 +237,24 @@ const readBuildStamp = (deps) => { } }; +const readRuntimePostBuildStamp = (deps) => { + const mtime = statMtime(deps.runtimePostBuildStampPath, deps.fs); + if (mtime == null) { + return { mtime: null, head: null }; + } + try { + const raw = deps.fs.readFileSync(deps.runtimePostBuildStampPath, "utf8").trim(); + if (!raw.startsWith("{")) { + return { mtime, head: null }; + } + const parsed = JSON.parse(raw); + const head = typeof parsed?.head === "string" && parsed.head.trim() ? parsed.head.trim() : null; + return { mtime, head }; + } catch { + return { mtime, head: null }; + } +}; + const hasSourceMtimeChanged = (stampMtime, deps) => { let latestSourceMtime = null; for (const sourceRoot of deps.sourceRoots) { @@ -198,6 +270,43 @@ const hasSourceMtimeChanged = (stampMtime, deps) => { return latestSourceMtime != null && latestSourceMtime > stampMtime; }; +const findLatestRuntimePostBuildInputMtime = (absolutePath, relativePath, deps) => { + const normalizedRelativePath = normalizePath(relativePath); + const statsMtime = statMtime(absolutePath, deps.fs); + if (statsMtime == null) { + return null; + } + let stat; + try { + stat = deps.fs.statSync(absolutePath); + } catch { + return null; + } + if (!stat.isDirectory()) { + return isRuntimePostBuildRelevantPath(normalizedRelativePath) ? statsMtime : null; + } + return findLatestMtime( + absolutePath, + (candidate) => { + const candidateRelativePath = path.relative(deps.cwd, candidate); + return !isRuntimePostBuildRelevantPath(candidateRelativePath); + }, + deps, + ); +}; + +const hasRuntimePostBuildInputMtimeChanged = (stampMtime, deps) => { + let latestInputMtime = null; + for (const relativePath of runtimePostBuildWatchedPaths) { + const absolutePath = path.join(deps.cwd, relativePath); + const inputMtime = findLatestRuntimePostBuildInputMtime(absolutePath, relativePath, deps); + if (inputMtime != null && (latestInputMtime == null || inputMtime > latestInputMtime)) { + latestInputMtime = inputMtime; + } + } + return latestInputMtime != null && latestInputMtime > stampMtime; +}; + export const resolveBuildRequirement = (deps) => { if (deps.env.OPENCLAW_FORCE_BUILD === "1") { return { shouldBuild: true, reason: "force_build" }; @@ -248,6 +357,48 @@ export const resolveBuildRequirement = (deps) => { return { shouldBuild: false, reason: "clean" }; }; +export const resolveRuntimePostBuildRequirement = (deps) => { + if (deps.env.OPENCLAW_FORCE_RUNTIME_POSTBUILD === "1") { + return { shouldSync: true, reason: "force_runtime_postbuild" }; + } + + const stamp = readRuntimePostBuildStamp(deps); + if (stamp.mtime == null) { + return { shouldSync: true, reason: "missing_runtime_postbuild_stamp" }; + } + + const buildStamp = readBuildStamp(deps); + if (buildStamp.mtime == null) { + return { shouldSync: true, reason: "missing_build_stamp" }; + } + if (buildStamp.mtime > stamp.mtime) { + return { shouldSync: true, reason: "build_stamp_newer" }; + } + + const currentHead = resolveGitHead(deps); + if (currentHead && !stamp.head) { + return { shouldSync: true, reason: "runtime_postbuild_stamp_missing_head" }; + } + if (currentHead && stamp.head && currentHead !== stamp.head) { + return { shouldSync: true, reason: "git_head_changed" }; + } + if (currentHead) { + const dirty = hasDirtyRuntimePostBuildInputs(deps); + if (dirty === true) { + return { shouldSync: true, reason: "dirty_runtime_postbuild_inputs" }; + } + if (dirty === false) { + return { shouldSync: false, reason: "clean" }; + } + } + + if (hasRuntimePostBuildInputMtimeChanged(stamp.mtime, deps)) { + return { shouldSync: true, reason: "runtime_postbuild_input_mtime_newer" }; + } + + return { shouldSync: false, reason: "clean" }; +}; + const BUILD_REASON_LABELS = { force_build: "forced by OPENCLAW_FORCE_BUILD", missing_build_stamp: "build stamp missing", @@ -261,7 +412,20 @@ const BUILD_REASON_LABELS = { clean: "clean", }; +const RUNTIME_POSTBUILD_REASON_LABELS = { + force_runtime_postbuild: "forced by OPENCLAW_FORCE_RUNTIME_POSTBUILD", + missing_runtime_postbuild_stamp: "runtime postbuild stamp missing", + missing_build_stamp: "build stamp missing", + build_stamp_newer: "build stamp newer than runtime postbuild stamp", + runtime_postbuild_stamp_missing_head: "runtime postbuild stamp missing git head", + git_head_changed: "git head changed", + dirty_runtime_postbuild_inputs: "dirty runtime postbuild inputs", + runtime_postbuild_input_mtime_newer: "runtime postbuild input mtime newer than stamp", + clean: "clean", +}; + const formatBuildReason = (reason) => BUILD_REASON_LABELS[reason] ?? reason; +const formatRuntimePostBuildReason = (reason) => RUNTIME_POSTBUILD_REASON_LABELS[reason] ?? reason; const SIGNAL_EXIT_CODES = { SIGINT: 130, @@ -565,6 +729,38 @@ const syncRuntimeArtifacts = async (deps) => { return true; }; +const writeRuntimePostBuildStamp = (deps) => { + try { + deps.fs.mkdirSync(path.dirname(deps.runtimePostBuildStampPath), { recursive: true }); + const head = resolveGitHead(deps); + deps.fs.writeFileSync( + deps.runtimePostBuildStampPath, + `${JSON.stringify( + { + syncedAt: Date.now(), + ...(head ? { head } : {}), + }, + null, + 2, + )}\n`, + "utf8", + ); + } catch (error) { + logRunner( + `Failed to write runtime postbuild stamp: ${error?.message ?? "unknown error"}`, + deps, + ); + } +}; + +const syncRuntimeArtifactsAndStamp = async (deps) => { + const synced = await syncRuntimeArtifacts(deps); + if (synced) { + writeRuntimePostBuildStamp(deps); + } + return synced; +}; + const writeBuildStamp = (deps) => { try { writeDistBuildStamp({ @@ -598,6 +794,7 @@ export async function runNodeMain(params = {}) { deps.distRoot = path.join(deps.cwd, "dist"); deps.distEntry = path.join(deps.distRoot, "/entry.js"); deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); + deps.runtimePostBuildStampPath = path.join(deps.distRoot, runtimePostBuildStampFile); deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({ name: sourceRoot, path: path.join(deps.cwd, sourceRoot), @@ -615,12 +812,22 @@ export async function runNodeMain(params = {}) { const buildRequirement = resolveBuildRequirement(deps); if (!buildRequirement.shouldBuild) { if (!shouldSkipCleanWatchRuntimeSync(deps)) { - const synced = await withRunNodeBuildLock( - deps, - async () => await syncRuntimeArtifacts(deps), - ); - if (!synced) { - return await closeRunNodeOutputTee(deps, 1); + const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps); + if (runtimePostBuildRequirement.shouldSync) { + const synced = await withRunNodeBuildLock(deps, async () => { + const lockedRuntimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps); + if (!lockedRuntimePostBuildRequirement.shouldSync) { + return true; + } + logRunner( + `Syncing runtime artifacts (${lockedRuntimePostBuildRequirement.reason} - ${formatRuntimePostBuildReason(lockedRuntimePostBuildRequirement.reason)}).`, + deps, + ); + return await syncRuntimeArtifactsAndStamp(deps); + }); + if (!synced) { + return await closeRunNodeOutputTee(deps, 1); + } } } exitCode = await runOpenClaw(deps); @@ -630,7 +837,15 @@ export async function runNodeMain(params = {}) { const buildExitCode = await withRunNodeBuildLock(deps, async () => { const lockedBuildRequirement = resolveBuildRequirement(deps); if (!lockedBuildRequirement.shouldBuild) { - return (await syncRuntimeArtifacts(deps)) ? 0 : 1; + const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps); + if (!runtimePostBuildRequirement.shouldSync) { + return 0; + } + logRunner( + `Syncing runtime artifacts (${runtimePostBuildRequirement.reason} - ${formatRuntimePostBuildReason(runtimePostBuildRequirement.reason)}).`, + deps, + ); + return (await syncRuntimeArtifactsAndStamp(deps)) ? 0 : 1; } logRunner( @@ -658,6 +873,7 @@ export async function runNodeMain(params = {}) { return 1; } writeBuildStamp(deps); + writeRuntimePostBuildStamp(deps); return 0; }); if (buildExitCode !== 0) { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 60e03daeeaf..d559a407498 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest"; import { acquireRunNodeBuildLock, resolveBuildRequirement, + resolveRuntimePostBuildRequirement, runNodeMain, } from "../../scripts/run-node.mjs"; import { @@ -23,6 +24,7 @@ const GENERATED_A2UI_BUNDLE = "src/canvas-host/a2ui/a2ui.bundle.js"; const GENERATED_A2UI_BUNDLE_HASH = "src/canvas-host/a2ui/.bundle.hash"; const DIST_ENTRY = "dist/entry.js"; const BUILD_STAMP = "dist/.buildstamp"; +const RUNTIME_POSTBUILD_STAMP = "dist/.runtime-postbuildstamp"; 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_SRC = bundledPluginFile("demo", "src/index.ts"); @@ -189,6 +191,7 @@ function createBuildRequirementDeps( distRoot: path.join(tmp, "dist"), distEntry: path.join(tmp, DIST_ENTRY), buildStampPath: path.join(tmp, BUILD_STAMP), + runtimePostBuildStampPath: path.join(tmp, RUNTIME_POSTBUILD_STAMP), sourceRoots: [path.join(tmp, "src"), path.join(tmp, bundledPluginRoot("demo"))].map( (sourceRoot) => ({ name: path.relative(tmp, sourceRoot).replaceAll("\\", "/"), @@ -623,6 +626,72 @@ describe("run-node script", () => { }); }); + it("skips runtime postbuild restaging when the runtime stamp is current", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP, RUNTIME_POSTBUILD_STAMP], + }); + + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", + }); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + expect(runRuntimePostBuild).not.toHaveBeenCalled(); + }); + }); + + it("restages runtime artifacts when runtime metadata is dirty", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [EXTENSION_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_MANIFEST, + ROOT_TSCONFIG, + ROOT_PACKAGE, + DIST_ENTRY, + BUILD_STAMP, + RUNTIME_POSTBUILD_STAMP, + ], + }); + + const runRuntimePostBuild = vi.fn(); + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: ` M ${EXTENSION_MANIFEST}\n`, + }); + const exitCode = await runStatusCommand({ + tmp, + spawn, + spawnSync, + runRuntimePostBuild, + }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([statusCommandSpawn()]); + expect(runRuntimePostBuild).toHaveBeenCalledOnce(); + }); + }); + it("serializes runtime postbuild restaging across concurrent clean launchers", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { @@ -669,7 +738,7 @@ describe("run-node script", () => { ]), ).resolves.toEqual([0, 0]); - expect(runRuntimePostBuild).toHaveBeenCalledTimes(2); + expect(runRuntimePostBuild).toHaveBeenCalledTimes(1); expect(maxActivePostbuilds).toBe(1); expect(fsSync.existsSync(path.join(tmp, ".artifacts", "run-node-build.lock"))).toBe(false); }); @@ -941,6 +1010,66 @@ describe("run-node script", () => { }); }); + it("reports clean runtime postbuild artifacts when the runtime stamp matches HEAD", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP, RUNTIME_POSTBUILD_STAMP], + }); + + const requirement = resolveRuntimePostBuildRequirement( + createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: "", + }), + ); + + expect(requirement).toEqual({ + shouldSync: false, + reason: "clean", + }); + }); + }); + + 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_MANIFEST]: '{"id":"demo","configSchema":{"type":"object"}}\n', + [RUNTIME_POSTBUILD_STAMP]: '{"head":"abc123"}\n', + }, + buildPaths: [ + ROOT_SRC, + EXTENSION_MANIFEST, + ROOT_TSCONFIG, + ROOT_PACKAGE, + DIST_ENTRY, + BUILD_STAMP, + RUNTIME_POSTBUILD_STAMP, + ], + }); + + const deps = createBuildRequirementDeps(tmp, { + gitHead: "abc123\n", + gitStatus: ` M ${EXTENSION_MANIFEST}\n`, + }); + + expect(resolveBuildRequirement(deps)).toEqual({ + shouldBuild: false, + reason: "clean", + }); + expect(resolveRuntimePostBuildRequirement(deps)).toEqual({ + shouldSync: true, + reason: "dirty_runtime_postbuild_inputs", + }); + }); + }); + it("ignores dirty generated A2UI bundle artifacts when dist is current", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, {