diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 8a0761ea115..6202c499007 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -1,10 +1,93 @@ import { spawn } from "node:child_process"; +import fs from "node:fs"; import { createRequire } from "node:module"; -import { resolve } from "node:path"; +import path, { resolve } from "node:path"; const require = createRequire(import.meta.url); const repoRoot = resolve(import.meta.dirname, ".."); const tscBin = require.resolve("typescript/bin/tsc"); +const TYPE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".d.ts", ".js", ".mjs", ".json"]); + +const ROOT_DTS_INPUTS = [ + "tsconfig.json", + "tsconfig.plugin-sdk.dts.json", + "src", + "packages/memory-host-sdk/src", +]; +const PACKAGE_DTS_INPUTS = [ + "tsconfig.json", + "packages/plugin-sdk/tsconfig.json", + "src/plugin-sdk", + "src/video-generation/dashscope-compatible.ts", + "src/video-generation/types.ts", + "src/types", +]; +const ENTRY_SHIMS_INPUTS = [ + "scripts/write-plugin-sdk-entry-dts.ts", + "scripts/lib/plugin-sdk-entrypoints.json", + "scripts/lib/plugin-sdk-entries.mjs", +]; + +function isRelevantTypeInput(filePath) { + const basename = path.basename(filePath); + if (basename.endsWith(".test.ts")) { + return false; + } + return TYPE_INPUT_EXTENSIONS.has(path.extname(filePath)); +} + +function collectNewestMtime(paths, params = {}) { + const rootDir = params.rootDir ?? repoRoot; + const includeFile = params.includeFile ?? (() => true); + let newestMtimeMs = 0; + + function visit(entryPath) { + if (!fs.existsSync(entryPath)) { + return; + } + const stats = fs.statSync(entryPath); + if (stats.isDirectory()) { + for (const child of fs.readdirSync(entryPath)) { + visit(path.join(entryPath, child)); + } + return; + } + if (!includeFile(entryPath)) { + return; + } + newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs); + } + + for (const relativePath of paths) { + visit(resolve(rootDir, relativePath)); + } + + return newestMtimeMs; +} + +function collectOldestMtime(paths, params = {}) { + const rootDir = params.rootDir ?? repoRoot; + let oldestMtimeMs = Number.POSITIVE_INFINITY; + + for (const relativePath of paths) { + const absolutePath = resolve(rootDir, relativePath); + if (!fs.existsSync(absolutePath)) { + return null; + } + oldestMtimeMs = Math.min(oldestMtimeMs, fs.statSync(absolutePath).mtimeMs); + } + + return Number.isFinite(oldestMtimeMs) ? oldestMtimeMs : null; +} + +export function isArtifactSetFresh(params) { + const newestInputMtimeMs = collectNewestMtime(params.inputPaths, { + rootDir: params.rootDir, + includeFile: params.includeFile, + }); + const oldestOutputMtimeMs = collectOldestMtime(params.outputPaths, { rootDir: params.rootDir }); + return oldestOutputMtimeMs !== null && oldestOutputMtimeMs >= newestInputMtimeMs; +} export function createPrefixedOutputWriter(label, target) { let buffered = ""; @@ -118,23 +201,58 @@ export async function runNodeStepsInParallel(steps) { export async function main() { try { - await runNodeStepsInParallel([ - { + const rootDtsFresh = isArtifactSetFresh({ + inputPaths: ROOT_DTS_INPUTS, + outputPaths: ["dist/plugin-sdk/.tsbuildinfo"], + includeFile: isRelevantTypeInput, + }); + const packageDtsFresh = isArtifactSetFresh({ + inputPaths: PACKAGE_DTS_INPUTS, + outputPaths: ["packages/plugin-sdk/dist/.tsbuildinfo"], + includeFile: isRelevantTypeInput, + }); + const entryShimsFresh = isArtifactSetFresh({ + inputPaths: [ + ...ENTRY_SHIMS_INPUTS, + "dist/plugin-sdk/.tsbuildinfo", + "packages/plugin-sdk/dist/.tsbuildinfo", + ], + outputPaths: ["dist/plugin-sdk/.boundary-entry-shims.stamp"], + }); + + const pendingSteps = []; + if (!rootDtsFresh) { + pendingSteps.push({ label: "plugin-sdk boundary dts", args: [tscBin, "-p", "tsconfig.plugin-sdk.dts.json"], timeoutMs: 300_000, - }, - { + }); + } else { + process.stdout.write("[plugin-sdk boundary dts] fresh; skipping\n"); + } + if (!packageDtsFresh) { + pendingSteps.push({ label: "plugin-sdk package boundary dts", args: [tscBin, "-p", "packages/plugin-sdk/tsconfig.json"], timeoutMs: 300_000, - }, - ]); - await runNodeStep( - "plugin-sdk boundary root shims", - ["--import", "tsx", resolve(repoRoot, "scripts/write-plugin-sdk-entry-dts.ts")], - 120_000, - ); + }); + } else { + process.stdout.write("[plugin-sdk package boundary dts] fresh; skipping\n"); + } + + if (pendingSteps.length > 0) { + await runNodeStepsInParallel(pendingSteps); + } + + if (!entryShimsFresh || pendingSteps.length > 0) { + await runNodeStep( + "plugin-sdk boundary root shims", + ["--import", "tsx", resolve(repoRoot, "scripts/write-plugin-sdk-entry-dts.ts")], + 120_000, + ); + } else { + process.stdout.write("[plugin-sdk boundary root shims] fresh; skipping\n"); + } } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index bb2850675a2..b225ea73f19 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -89,3 +89,7 @@ for (const entry of pluginSdkEntrypoints) { fs.mkdirSync(path.dirname(runtimeOut), { recursive: true }); fs.writeFileSync(runtimeOut, runtimeShim, "utf8"); } + +const stampPath = path.join(process.cwd(), "dist/plugin-sdk/.boundary-entry-shims.stamp"); +fs.mkdirSync(path.dirname(stampPath), { recursive: true }); +fs.writeFileSync(stampPath, `${new Date().toISOString()}\n`, "utf8"); diff --git a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts index 09dbd6ab984..4ffdfe6bc6b 100644 --- a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts +++ b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts @@ -1,9 +1,22 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { createPrefixedOutputWriter, + isArtifactSetFresh, runNodeStepsInParallel, } from "../../scripts/prepare-extension-package-boundary-artifacts.mjs"; +const tempRoots = new Set(); + +afterEach(() => { + for (const rootDir of tempRoots) { + fs.rmSync(rootDir, { force: true, recursive: true }); + } + tempRoots.clear(); +}); + describe("prepare-extension-package-boundary-artifacts", () => { it("prefixes each completed line and flushes the trailing partial line", () => { let output = ""; @@ -40,4 +53,36 @@ describe("prepare-extension-package-boundary-artifacts", () => { expect(Date.now() - startedAt).toBeLessThan(2_000); }); + + it("treats artifacts as fresh only when outputs are newer than inputs", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-prep-")); + tempRoots.add(rootDir); + const inputPath = path.join(rootDir, "src", "demo.ts"); + const outputPath = path.join(rootDir, "dist", "demo.tsbuildinfo"); + fs.mkdirSync(path.dirname(inputPath), { recursive: true }); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(inputPath, "export const demo = 1;\n", "utf8"); + fs.writeFileSync(outputPath, "ok\n", "utf8"); + + fs.utimesSync(inputPath, new Date(1_000), new Date(1_000)); + fs.utimesSync(outputPath, new Date(2_000), new Date(2_000)); + + expect( + isArtifactSetFresh({ + rootDir, + inputPaths: ["src"], + outputPaths: ["dist/demo.tsbuildinfo"], + }), + ).toBe(true); + + fs.utimesSync(inputPath, new Date(3_000), new Date(3_000)); + + expect( + isArtifactSetFresh({ + rootDir, + inputPaths: ["src"], + outputPaths: ["dist/demo.tsbuildinfo"], + }), + ).toBe(false); + }); });