// Canonical declaration groups share one private compiler and publication lifetime. import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { prepareTsdownBuildExecution, resolveStagedSdkDeclarationConcurrency, TSDOWN_DECLARATION_EXTENSIONS, TSDOWN_UNIFIED_CACHE_ENV, } from "../tsdown-build.mts"; import { finalizeBuildStepCache, portableRelativePath, restoreBuildStepCacheOutputs, resolveBuildStepCacheState, resolveTsdownCompilerFiles, type BuildCacheStep, } from "./build-artifact-cache.mts"; import { CompilerInputSnapshot } from "./compiler-input-snapshot.mts"; import { publishStagedDeclarations } from "./declaration-stage.mts"; import { withDistArtifactOwnership } from "./dist-artifact-ownership.mts"; import { hasUnjoinedWork } from "./managed-child-process.mts"; import { resolveTsdownDeclarationGeneratorInputs } from "./tsdown-declaration-generator-inputs.mts"; import { createDeclarationStage, readDeclarationInputs, requestDeclarationInputs, } from "./tsdown-declaration-inputs.mts"; export async function writeTsdownDeclarations( groups: readonly string[], label: string, previousOutputs: (root: string) => string[], generatorEntry: string, ) { const root = fs.realpathSync.native(process.cwd()); const stages: string[] = []; const createStage = () => { const stage = createDeclarationStage(root); stages.push(stage); return stage; }; const failures: unknown[] = []; try { // The private child retains declared cwd ownership; snapshot/output paths are physical. await withDistArtifactOwnership(process.cwd(), async () => { const { default: configs }: { default: typeof import("../../tsdown.config.ts").default } = await import(pathToFileURL(path.join(root, "tsdown.config.ts")).href); const staging = createStage(); const output = path.join(staging, "dist"); const environment = TSDOWN_UNIFIED_CACHE_ENV.map((name) => JSON.stringify([name, process.env[name] ?? ""]), ); const generatorInputs = resolveTsdownDeclarationGeneratorInputs(root, generatorEntry); const snapshot = () => new CompilerInputSnapshot(root, { toolchainFiles: resolveTsdownCompilerFiles(), generatorInputs, isGeneratorInput: (file) => /(?:^|\/)(?:package|openclaw\.plugin)\.json$/u.test(file), }); // All groups share the same before/after reads of configuration, topology, // tools and overlapping sources. Only compiler membership differs. const before = snapshot(); const liveDist = path.join(root, "dist"); const prepared = groups.map((name) => { const config = configs.find((candidate: { name?: string }) => candidate.name === name); if ( !config?.dts || typeof config.dts !== "object" || !Array.isArray(config.dts.entry) || !config.entry || typeof config.entry !== "object" || Array.isArray(config.entry) ) { throw new Error(`Missing canonical declaration group ${name}`); } // Runtime worker entries are absolute; declaration partitions are checkout-relative. const entries = Object.entries(config.entry).map( ([entry, inputs]) => [entry, [inputs].flat().map((input) => path.resolve(root, input))] as const, ); const required = config.dts.entry.map((source) => { const resolved = path.resolve(root, source); const selected = entries.find(([, inputs]) => inputs.includes(resolved)); if (!selected) { throw new Error(`Missing canonical declaration entry for ${source}`); } return `${selected[0]}.d.ts`; }); const identity = [ ...environment, JSON.stringify({ name, entry: Object.fromEntries( entries.map(([entry, sources]) => [ entry, sources.map((source) => portableRelativePath(root, source)), ]), ), declarations: config.dts.entry, sourcemap: config.sourcemap, }), ]; const stage = createStage(); const groupOutput = path.join(stage, "dist"); requestDeclarationInputs(groupOutput, name, config.dts.entry); const plan = prepareTsdownBuildExecution( { args: ["--config", "tsdown.config.ts", "--filter", name, "--out-dir", groupOutput], }, { // Every compiler owns a fresh stage; live runtime outputs stay intact. cleanup() {}, reportShortfall(shortfall) { console.error(shortfall.message); }, }, ); if (!plan) { throw new Error("Insufficient memory for declaration build"); } const receipt = `compiler-inputs/${name}.json`; const step: BuildCacheStep = { label: `${label}-${name}`, cache: { env: TSDOWN_UNIFIED_CACHE_ENV, inputs: generatorInputs, // An empty canonical partition still owns its successful compiler // receipt. Ordinary cache records never admit an empty inventory. outputs: [{ path: "dist", extensions: TSDOWN_DECLARATION_EXTENSIONS }, receipt], requiredOutputs: [...required.map((entry) => `dist/${entry}`), receipt], restore: "always", }, }; const params = { rootDir: root, artifactRoot: stage, env: { ...process.env, OPENCLAW_BUILD_PRIVATE_QA: process.env.OPENCLAW_BUILD_PRIVATE_QA === "1" ? "1" : "0", }, inputSignature: (inputs: string[]) => before.signature("tsconfig.json", identity, inputs, liveDist), }; const state = process.env.OPENCLAW_BUILD_CACHE === "0" ? undefined : resolveBuildStepCacheState(step, params); if (!state) { params.inputSignature([]); } // Record the lookup before compilation replaces the signature and cache record. console.error( `[${label}] ${name}: cache ${state?.fresh ? "hit" : "miss"} (${state?.reason ?? "disabled"})`, ); return { name, output: groupOutput, required, identity, plan, step, params, state }; }); const required = prepared.flatMap((group) => group.required); if (!required.length) { throw new Error("Canonical declaration selection is empty"); } const startedAt = Date.now(); for (const group of prepared) { if (group.state?.fresh && !restoreBuildStepCacheOutputs(group.state, group.params)) { throw new Error("Declaration cache changed before restoration; rerun the build"); } } // Empty partitions still produce receipts, but only two nonempty SDK misses // may overlap. All other plans retain dependency-ordered serial execution. const misses = prepared.filter((group) => !group.state?.fresh); const concurrency = misses.every( (group) => group.required.length > 0 && group.plan.invocations.length === 1, ) ? resolveStagedSdkDeclarationConcurrency( misses.map((group) => ({ name: group.name, maxOldSpaceMb: group.plan.maxOldSpaceMb })), ) : 1; const plan = { ...prepared[0]!.plan, invocations: misses.flatMap((group) => group.plan.invocations), }; await publishStagedDeclarations( plan, prepared.map((group) => ({ output: group.output, required: group.required })), output, liveDist, required, previousOutputs(root).map((file) => portableRelativePath(liveDist, file)), () => { const after = snapshot(); for (const group of prepared) { const sealed = after.seal( "tsconfig.json", group.identity, readDeclarationInputs(group.output, group.name), before, startedAt, liveDist, ); if (group.state?.fresh && sealed.signature !== group.state.signature) { throw new Error(`Cached declaration membership changed: ${group.name}`); } if (group.state) { group.state.signature = sealed.signature; group.state.consumedInputs = sealed.inputs; } } }, concurrency, ); for (const group of prepared) { if (group.state && !group.state.fresh) { finalizeBuildStepCache(group.step, group.state, group.params); } } if (prepared.every((group) => group.state?.fresh)) { console.log(`[${label}] restored complete cached generation`); } }); } catch (error) { failures.push(error); } // An unjoined child may still be writing a stage; retain its inputs and error // under the checkout's existing fail-closed ownership until explicit recovery. for (const stage of failures.some(hasUnjoinedWork) ? [] : stages) { try { fs.rmSync(stage, { recursive: true, force: true }); } catch (error) { failures.push(error); } } // The private entry observes this after module evaluation. Keep unjoined build // metadata even if removing the private staging tree also failed. if (failures.length) { throw failures.length === 1 ? failures[0] : new AggregateError(failures, "Declaration build and staging cleanup failed"); } }