From 0575b85473af5ece55e2bd6a90e26bdead988e89 Mon Sep 17 00:00:00 2001 From: devinfoley <139239+devinfoley@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:04:43 +0000 Subject: [PATCH] perf(build): reuse a current Runner build in the server's prepare:runner-vendor step The server build's prepare:runner-vendor step ran the Runner's full build every time, so inside `pnpm -r build` the Runner was built twice. The second build took 12-16s on the CI Build job's critical path (six master runs, 2026-10-11) and the same again in every other `pnpm build`. The Runner's build now ends by recording a digest of its inputs, and the server's step reuses the build when the digest matches the stamp and every vendored output exists. Any other state runs the same `pnpm --filter @paperclipai/paperclip-runner build` as before, so a standalone server build (the Dockerfile's path) still builds the Runner itself. PAPERCLIP_RUNNER_VENDOR_REBUILD=1 forces the rebuild. Co-Authored-By: Claude Fable 5.1 --- doc/DEVELOPING.md | 28 ++ packages/paperclip-runner/package.json | 4 +- .../paperclip-runner/scripts/build-inputs.mjs | 230 +++++++++++++++ .../scripts/build-inputs.test.mjs | 197 +++++++++++++ .../scripts/record-build-inputs.mjs | 47 +++ server/package.json | 2 +- server/scripts/prepare-runner-vendor.mjs | 139 +++++++++ server/scripts/prepare-runner-vendor.test.mjs | 267 ++++++++++++++++++ .../server-package-build-script.test.ts | 26 +- 9 files changed, 934 insertions(+), 6 deletions(-) create mode 100644 packages/paperclip-runner/scripts/build-inputs.mjs create mode 100644 packages/paperclip-runner/scripts/build-inputs.test.mjs create mode 100644 packages/paperclip-runner/scripts/record-build-inputs.mjs create mode 100644 server/scripts/prepare-runner-vendor.mjs create mode 100644 server/scripts/prepare-runner-vendor.test.mjs diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 6fc8fa4f267..08610686258 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -560,6 +560,34 @@ falls back to the fresh path automatically, so the cache can make a suite slower but never fail it or skip it: a migration that cannot apply still fails the suite on the fresh path. +#### Reused Runner build in the server build + +The server build vendors `packages/paperclip-runner/dist`, so its +`prepare:runner-vendor` step has to make sure that directory is a current +build. It used to run the Runner's full `build` every time. Now the Runner's +`build` ends by recording a digest of its inputs (every file under +`packages/paperclip-runner` except `dist`, `dist-browser`, `runner/target`, +`node_modules` and dot-directories; every file under +`packages/paperclip-eval-kernel` except `dist` and `node_modules`; and the root +`package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml` and +`tsconfig.base.json`) in +`packages/paperclip-runner/node_modules/.cache/paperclip-runner-build-inputs.json`. +`node server/scripts/prepare-runner-vendor.mjs` recomputes that digest and +reuses the build when it matches the stamp and `dist/index.js`, +`dist/live/index.js`, `dist/testing.js` and `dist/bin/paperclip-runnerd` all +exist. Anything else (no stamp, a changed input, a missing output, a stamp from +another platform) runs `pnpm --filter @paperclipai/paperclip-runner build` +exactly as before, so a standalone `pnpm --filter @paperclipai/server build` +on a fresh checkout still builds the Runner itself. Inside `pnpm build` the +Runner has just been built by the same command, and the step skips a 12-16s +rebuild. The step prints one `paperclip.runner-vendor.timing.v1` line saying +which it did. + +Set `PAPERCLIP_RUNNER_VENDOR_REBUILD=1` to rebuild regardless of the stamp. A +stamp is only written after a Runner build that passed every step, and the +prepare step removes any existing stamp before it starts a rebuild, so a build +that stops partway never leaves a stamp that vouches for it. + ### Task search evaluation The task search relevance rubric and regression corpus are documented in diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index f962fe6c20b..83eca54c23f 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -63,7 +63,7 @@ }, "sideEffects": false, "scripts": { - "build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && node scripts/generate-semantic-contracts.mjs --check && node scripts/check-runner-workflow-traceability.mjs && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check", + "build": "pnpm run check:protocol-manifest && pnpm run build:typescript && pnpm run check:capability-contract && pnpm run check:capability-inventory && pnpm run check:protocol-coverage && node scripts/generate-semantic-contracts.mjs --check && node scripts/check-runner-workflow-traceability.mjs && pnpm run build:binary && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check && node scripts/record-build-inputs.mjs", "build:typescript": "pnpm run check:acpx-profiles && pnpm run ensure:eval-build-deps && pnpm run check:protocol-types && node ./node_modules/typescript/bin/tsc --version && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.surfaces.json && node scripts/build-verified-provider-entrypoints.mjs", "build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins", "build:binary": "cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/stage-runner-binary.mjs", @@ -81,7 +81,7 @@ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", "test:typescript": "pnpm run test:typescript:prep && vitest run", - "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs", "test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", diff --git a/packages/paperclip-runner/scripts/build-inputs.mjs b/packages/paperclip-runner/scripts/build-inputs.mjs new file mode 100644 index 00000000000..4db47677cfe --- /dev/null +++ b/packages/paperclip-runner/scripts/build-inputs.mjs @@ -0,0 +1,230 @@ +// Records which inputs produced the Runner package's current build output, so +// a consumer that would otherwise rebuild the Runner can reuse the build when +// nothing it depends on has changed. +// +// server's `prepare:runner-vendor` runs the Runner's full `build`, because a +// standalone `pnpm --filter @paperclipai/server build` (the Dockerfile's path) +// must produce a current vendored Runner on its own. Inside `pnpm -r build` +// the same command has just built the Runner a few seconds earlier, and the +// rebuild cost 12-16s on the CI Build job's critical path (six master runs, +// 2026-10-11), the same again in every other `pnpm build`. +// +// The Runner's `build` ends by recording a digest of every build input into a +// stamp under node_modules/.cache (scripts/record-build-inputs.mjs). The +// server's prepare step (server/scripts/prepare-runner-vendor.mjs) recomputes +// the digest and reuses the build only when the stamp matches and the outputs +// exist. Every other state rebuilds: a missing stamp, a changed input, a +// deleted output, a different platform. The slow direction is always the safe +// one, and a stamp is only ever written after a build that passed every check. +// +// Inputs: every file under packages/paperclip-runner except its build outputs +// (dist, dist-browser, runner/target), node_modules and dot-directories (tool +// caches); every file under packages/paperclip-eval-kernel except dist and +// node_modules, because build:typescript builds that package first; and the +// root files the Runner's tsconfig and dependency graph read. The toolchain +// pins live inside the package (rust-toolchain.toml, runner/Cargo.lock), and +// the TypeScript version comes from pnpm-lock.yaml, so both are covered. + +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const BUILD_INPUTS_SCHEMA = "paperclip.runner-build-inputs.v1"; + +const RUNNER_PACKAGE_RELATIVE = "packages/paperclip-runner"; +const EVAL_KERNEL_PACKAGE_RELATIVE = "packages/paperclip-eval-kernel"; +const ROOT_INPUT_FILES = [ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.base.json", +]; +const SKIPPED_DIRECTORY_NAMES = new Set(["node_modules", "dist", "dist-browser"]); +const RUNNER_SKIPPED_DIRECTORIES = new Set([`${RUNNER_PACKAGE_RELATIVE}/runner/target`]); + +export function runnerPackageRootFromScripts() { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +} + +export function repoRootFromRunnerPackage(packageRoot) { + return path.resolve(packageRoot, "../.."); +} + +export function buildInputsStampPath(packageRoot) { + return path.join(packageRoot, "node_modules", ".cache", "paperclip-runner-build-inputs.json"); +} + +/** The outputs a consumer of the vendored Runner reads; all must exist for a build to count as current. */ +export function expectedBuildOutputs(packageRoot, platform = process.platform) { + const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + return ["dist/index.js", "dist/live/index.js", "dist/testing.js", `dist/bin/${executable}`] + .map((relative) => path.join(packageRoot, ...relative.split("/"))); +} + +function walk(repoRoot, relativeDirectory, skippedDirectories, out) { + const absolute = path.join(repoRoot, ...relativeDirectory.split("/")); + if (!existsSync(absolute)) return; + const entries = readdirSync(absolute, { withFileTypes: true }) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of entries) { + const relative = `${relativeDirectory}/${entry.name}`; + if (entry.isDirectory()) { + if (entry.name.startsWith(".")) continue; + if (SKIPPED_DIRECTORY_NAMES.has(entry.name)) continue; + if (skippedDirectories.has(relative)) continue; + walk(repoRoot, relative, skippedDirectories, out); + continue; + } + // Files and symlinks; a symlinked directory is hashed as its link target + // rather than followed, so a link into node_modules cannot pull a + // dependency tree into the input set. + out.push(relative); + } +} + +/** Repo-relative POSIX paths of every build input, sorted. */ +export function collectBuildInputFiles(repoRoot) { + const files = []; + walk(repoRoot, RUNNER_PACKAGE_RELATIVE, RUNNER_SKIPPED_DIRECTORIES, files); + walk(repoRoot, EVAL_KERNEL_PACKAGE_RELATIVE, new Set(), files); + for (const name of ROOT_INPUT_FILES) { + if (existsSync(path.join(repoRoot, name))) files.push(name); + } + return files.sort(); +} + +export function computeBuildInputsDigest(repoRoot) { + const files = collectBuildInputFiles(repoRoot); + const hash = createHash("sha256"); + let byteCount = 0; + for (const relative of files) { + const absolute = path.join(repoRoot, ...relative.split("/")); + hash.update(relative); + hash.update("\0"); + if (lstatSync(absolute).isSymbolicLink()) { + hash.update("link\0"); + hash.update(readlinkSync(absolute)); + } else { + const content = readFileSync(absolute); + byteCount += content.length; + hash.update(`file ${content.length}\0`); + hash.update(content); + } + hash.update("\0"); + } + return { + schema: BUILD_INPUTS_SCHEMA, + digest: hash.digest("hex"), + fileCount: files.length, + byteCount, + }; +} + +export function readBuildInputsStamp(stampPath) { + try { + const parsed = JSON.parse(readFileSync(stampPath, "utf8")); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +export function writeBuildInputsStamp(stampPath, record) { + mkdirSync(path.dirname(stampPath), { recursive: true }); + const staged = `${stampPath}.${process.pid}.tmp`; + writeFileSync(staged, `${JSON.stringify(record, null, 2)}\n`); + renameSync(staged, stampPath); +} + +export function removeBuildInputsStamp(stampPath) { + rmSync(stampPath, { force: true }); +} + +/** + * Records the current inputs as the ones that produced the current outputs. + * Call this only after a build that completed every step and check. + */ +export function recordRunnerBuild({ + repoRoot, + packageRoot, + platform = process.platform, + arch = process.arch, + now = () => new Date(), +}) { + const inputs = computeBuildInputsDigest(repoRoot); + const record = { ...inputs, platform, arch, recordedAt: now().toISOString() }; + const stampPath = buildInputsStampPath(packageRoot); + writeBuildInputsStamp(stampPath, record); + return { ...record, stampPath }; +} + +/** + * Decides whether the Runner's outputs are current for the present inputs. + * `code` is a stable slug for timing lines; `detail` is for humans. + */ +export function assessRunnerBuild({ + repoRoot, + packageRoot, + platform = process.platform, + arch = process.arch, +}) { + const stampPath = buildInputsStampPath(packageRoot); + const stamp = readBuildInputsStamp(stampPath); + if (!stamp) { + return { current: false, code: "no_stamp", detail: `no build stamp at ${stampPath}`, stampPath }; + } + if (stamp.schema !== BUILD_INPUTS_SCHEMA) { + return { + current: false, + code: "schema_mismatch", + detail: `stamp schema ${String(stamp.schema)} is not ${BUILD_INPUTS_SCHEMA}`, + stampPath, + }; + } + if (stamp.platform !== platform || stamp.arch !== arch) { + return { + current: false, + code: "platform_mismatch", + detail: `stamp recorded on ${stamp.platform}/${stamp.arch}, this is ${platform}/${arch}`, + stampPath, + }; + } + const missing = expectedBuildOutputs(packageRoot, platform).filter((file) => !existsSync(file)); + if (missing.length > 0) { + return { + current: false, + code: "missing_output", + detail: `missing build output ${path.relative(packageRoot, missing[0])}`, + stampPath, + }; + } + const inputs = computeBuildInputsDigest(repoRoot); + if (inputs.digest !== stamp.digest) { + return { + current: false, + code: "inputs_changed", + detail: `inputs changed since the recorded build (${inputs.fileCount} files checked)`, + stampPath, + inputs, + }; + } + return { + current: true, + code: "inputs_unchanged", + detail: `inputs unchanged since the build recorded at ${stamp.recordedAt} (${inputs.fileCount} files checked)`, + stampPath, + inputs, + stamp, + }; +} diff --git a/packages/paperclip-runner/scripts/build-inputs.test.mjs b/packages/paperclip-runner/scripts/build-inputs.test.mjs new file mode 100644 index 00000000000..7e75c66f6e8 --- /dev/null +++ b/packages/paperclip-runner/scripts/build-inputs.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { + BUILD_INPUTS_SCHEMA, + assessRunnerBuild, + buildInputsStampPath, + collectBuildInputFiles, + computeBuildInputsDigest, + expectedBuildOutputs, + readBuildInputsStamp, + recordRunnerBuild, +} from "./build-inputs.mjs"; + +const execFileAsync = promisify(execFile); +const recordScript = fileURLToPath(new URL("./record-build-inputs.mjs", import.meta.url)); + +async function writeTree(root, files) { + for (const [relative, content] of Object.entries(files)) { + const absolute = path.join(root, ...relative.split("/")); + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, content); + } +} + +/** A throwaway repo with one Runner source file, one Rust source, built outputs, and the usual noise. */ +async function makeRepo() { + const repoRoot = await mkdtemp(path.join(tmpdir(), "runner-build-inputs-")); + await writeTree(repoRoot, { + "package.json": "{}\n", + "pnpm-lock.yaml": "lockfileVersion: 9\n", + "pnpm-workspace.yaml": "packages:\n - packages/*\n", + "tsconfig.base.json": "{}\n", + "packages/paperclip-runner/package.json": '{"name":"@paperclipai/paperclip-runner"}\n', + "packages/paperclip-runner/src/index.ts": "export const one = 1;\n", + "packages/paperclip-runner/runner/src/main.rs": "fn main() {}\n", + "packages/paperclip-runner/runner/target/release/paperclip-runnerd": "binary\n", + "packages/paperclip-runner/dist/index.js": "export const one = 1;\n", + "packages/paperclip-runner/dist/live/index.js": "export {};\n", + "packages/paperclip-runner/dist/testing.js": "export {};\n", + "packages/paperclip-runner/dist/bin/paperclip-runnerd": "binary\n", + "packages/paperclip-runner/dist-browser/index.js": "browser\n", + "packages/paperclip-runner/node_modules/dep/index.js": "dep\n", + "packages/paperclip-runner/.vite/cache.json": "{}\n", + "packages/paperclip-eval-kernel/src/index.ts": "export const kernel = 1;\n", + "packages/paperclip-eval-kernel/dist/index.js": "export const kernel = 1;\n", + }); + const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); + return { repoRoot, packageRoot }; +} + +async function withRepo(action) { + const repo = await makeRepo(); + try { + await action(repo); + } finally { + await rm(repo.repoRoot, { recursive: true, force: true }); + } +} + +test("the input set covers sources, the eval kernel and root config, and skips outputs and caches", async () => { + await withRepo(async ({ repoRoot }) => { + const files = collectBuildInputFiles(repoRoot); + assert.deepEqual(files, [ + "package.json", + "packages/paperclip-eval-kernel/src/index.ts", + "packages/paperclip-runner/package.json", + "packages/paperclip-runner/runner/src/main.rs", + "packages/paperclip-runner/src/index.ts", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.base.json", + ]); + }); +}); + +test("the digest is stable, ignores outputs and caches, and tracks every kind of input", async () => { + await withRepo(async ({ repoRoot }) => { + const before = computeBuildInputsDigest(repoRoot); + assert.equal(before.schema, BUILD_INPUTS_SCHEMA); + assert.equal(before.fileCount, 8); + assert.equal(computeBuildInputsDigest(repoRoot).digest, before.digest, "two reads disagree"); + + // Outputs, dependencies, caches and the Rust target directory may change freely. + await writeTree(repoRoot, { + "packages/paperclip-runner/dist/index.js": "export const one = 2;\n", + "packages/paperclip-runner/dist-browser/index.js": "changed\n", + "packages/paperclip-runner/node_modules/dep/index.js": "changed\n", + "packages/paperclip-runner/.vite/cache.json": '{"changed":true}\n', + "packages/paperclip-runner/runner/target/release/paperclip-runnerd": "rebuilt\n", + "packages/paperclip-eval-kernel/dist/index.js": "changed\n", + }); + assert.equal(computeBuildInputsDigest(repoRoot).digest, before.digest, "an output changed the digest"); + + // Every input kind changes it: TypeScript, Rust, the eval kernel, root config, a new file, a rename. + const seen = new Set([before.digest]); + const expectChange = async (label, files) => { + await writeTree(repoRoot, files); + const next = computeBuildInputsDigest(repoRoot).digest; + assert.ok(!seen.has(next), `${label} did not change the digest`); + seen.add(next); + }; + await expectChange("a Runner source", { "packages/paperclip-runner/src/index.ts": "export const one = 2;\n" }); + await expectChange("a Rust source", { "packages/paperclip-runner/runner/src/main.rs": "fn main() { }\n" }); + await expectChange("an eval-kernel source", { "packages/paperclip-eval-kernel/src/index.ts": "export const kernel = 2;\n" }); + await expectChange("the lockfile", { "pnpm-lock.yaml": "lockfileVersion: 9\nchanged: true\n" }); + await expectChange("the base tsconfig", { "tsconfig.base.json": '{"compilerOptions":{}}\n' }); + await expectChange("a new file", { "packages/paperclip-runner/protocol/new.json": "{}\n" }); + await rm(path.join(repoRoot, "packages/paperclip-runner/protocol/new.json")); + await expectChange("a renamed file", { "packages/paperclip-runner/protocol/renamed.json": "{}\n" }); + }); +}); + +test("a symlink is hashed as its target path, not followed", async () => { + await withRepo(async ({ repoRoot, packageRoot }) => { + const before = computeBuildInputsDigest(repoRoot); + await symlink("../node_modules/dep", path.join(packageRoot, "src", "linked"), "dir"); + const linked = computeBuildInputsDigest(repoRoot); + assert.notEqual(linked.digest, before.digest, "the link itself is an input"); + assert.equal(linked.fileCount, before.fileCount + 1, "the link target's files were followed"); + await writeFile(path.join(packageRoot, "node_modules/dep/index.js"), "changed\n"); + assert.equal(computeBuildInputsDigest(repoRoot).digest, linked.digest, "a change behind the link changed the digest"); + }); +}); + +test("a build is current only with a matching stamp and every consumer-facing output present", async () => { + await withRepo(async ({ repoRoot, packageRoot }) => { + const platform = "linux"; + const arch = "x64"; + const assess = () => assessRunnerBuild({ repoRoot, packageRoot, platform, arch }); + + assert.equal(assess().current, false); + assert.equal(assess().code, "no_stamp"); + + const record = recordRunnerBuild({ repoRoot, packageRoot, platform, arch, now: () => new Date(0) }); + assert.equal(record.stampPath, buildInputsStampPath(packageRoot)); + assert.deepEqual(readBuildInputsStamp(record.stampPath), { + schema: BUILD_INPUTS_SCHEMA, + digest: record.digest, + fileCount: 8, + byteCount: record.byteCount, + platform, + arch, + recordedAt: "1970-01-01T00:00:00.000Z", + }); + const current = assess(); + assert.equal(current.current, true, current.detail); + assert.equal(current.code, "inputs_unchanged"); + + assert.equal(assessRunnerBuild({ repoRoot, packageRoot, platform: "win32", arch }).code, "platform_mismatch"); + assert.equal(assessRunnerBuild({ repoRoot, packageRoot, platform, arch: "arm64" }).code, "platform_mismatch"); + + for (const output of expectedBuildOutputs(packageRoot, platform)) { + const content = await readFile(output); + await rm(output); + const missing = assess(); + assert.equal(missing.current, false, `${output} missing still counted as current`); + assert.equal(missing.code, "missing_output"); + assert.match(missing.detail, new RegExp(path.basename(output))); + await writeFile(output, content); + } + assert.equal(assess().current, true); + + await writeFile(path.join(packageRoot, "src", "index.ts"), "export const one = 3;\n"); + const changed = assess(); + assert.equal(changed.current, false); + assert.equal(changed.code, "inputs_changed"); + + await writeFile(record.stampPath, JSON.stringify({ ...readBuildInputsStamp(record.stampPath), schema: "other" })); + assert.equal(assess().code, "schema_mismatch"); + await writeFile(record.stampPath, "not json"); + assert.equal(assess().code, "no_stamp"); + }); +}); + +test("record-build-inputs.mjs writes the stamp for the package it is pointed at", async () => { + await withRepo(async ({ repoRoot, packageRoot }) => { + const { stdout } = await execFileAsync(process.execPath, [recordScript, "--package-root", packageRoot]); + const expected = computeBuildInputsDigest(repoRoot); + assert.match(stdout, new RegExp(`^${BUILD_INPUTS_SCHEMA} recorded: digest=${expected.digest.slice(0, 12)} files=8 `)); + const stamp = readBuildInputsStamp(buildInputsStampPath(packageRoot)); + assert.equal(stamp.digest, expected.digest); + assert.equal(stamp.platform, process.platform); + assert.equal(stamp.arch, process.arch); + assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).current, true); + + await assert.rejects( + execFileAsync(process.execPath, [recordScript, "--bogus"]), + /unknown argument --bogus/, + ); + }); +}); diff --git a/packages/paperclip-runner/scripts/record-build-inputs.mjs b/packages/paperclip-runner/scripts/record-build-inputs.mjs new file mode 100644 index 00000000000..c3aefb2dd74 --- /dev/null +++ b/packages/paperclip-runner/scripts/record-build-inputs.mjs @@ -0,0 +1,47 @@ +// Final step of the Runner package's `build`: records the digest of every +// build input next to the outputs it produced, so server's +// prepare:runner-vendor can reuse this build instead of repeating it when +// nothing has changed. See build-inputs.mjs for what counts as an input. +// +// Usage: node scripts/record-build-inputs.mjs [--package-root ] +// The package root defaults to this package; the flag exists for tests that +// build a throwaway package layout. + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + recordRunnerBuild, + repoRootFromRunnerPackage, + runnerPackageRootFromScripts, +} from "./build-inputs.mjs"; + +export function parseArguments(argv) { + let packageRoot = null; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--package-root") { + packageRoot = argv[index + 1]; + if (!packageRoot) throw new Error("--package-root needs a directory"); + index += 1; + continue; + } + throw new Error(`unknown argument ${argument}`); + } + return { packageRoot }; +} + +export function main(argv = process.argv.slice(2), log = console.log) { + const { packageRoot: requested } = parseArguments(argv); + const packageRoot = requested ? path.resolve(requested) : runnerPackageRootFromScripts(); + const repoRoot = repoRootFromRunnerPackage(packageRoot); + const record = recordRunnerBuild({ repoRoot, packageRoot }); + log( + `${record.schema} recorded: digest=${record.digest.slice(0, 12)} files=${record.fileCount} ` + + `bytes=${record.byteCount} stamp=${path.relative(packageRoot, record.stampPath)}`, + ); + return record; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/server/package.json b/server/package.json index ae6ce5ab0b9..da6992bdb62 100644 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", - "prepare:runner-vendor": "pnpm --filter @paperclipai/paperclip-runner build", + "prepare:runner-vendor": "node scripts/prepare-runner-vendor.mjs", "typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { diff --git a/server/scripts/prepare-runner-vendor.mjs b/server/scripts/prepare-runner-vendor.mjs new file mode 100644 index 00000000000..4039aa0a93f --- /dev/null +++ b/server/scripts/prepare-runner-vendor.mjs @@ -0,0 +1,139 @@ +// Makes sure packages/paperclip-runner/dist is a current build before the +// server build vendors it, rebuilding the Runner only when that is needed. +// +// The server build (and typecheck) used to run the Runner's full `build` +// unconditionally here. That is what lets a standalone +// `pnpm --filter @paperclipai/server build` produce a current vendored Runner +// on its own, which the Dockerfile relies on. But inside `pnpm -r build` the +// Runner has just been built by the same command, and repeating it cost +// 12-16s on the CI Build job's critical path (six master runs, 2026-10-11) +// and the same again in every other `pnpm build`. +// +// The Runner's `build` now records a digest of its inputs after it succeeds +// (packages/paperclip-runner/scripts/build-inputs.mjs). This step recomputes +// the digest and reuses the build when the stamp matches and every output a +// consumer reads exists; anything else runs the full Runner build exactly as +// before. The standalone guarantee therefore holds: a fresh checkout, a +// changed input, or a deleted output always rebuilds. Set +// PAPERCLIP_RUNNER_VENDOR_REBUILD=1 to rebuild regardless of the stamp. +// +// Usage: node scripts/prepare-runner-vendor.mjs [--repo-root ] +// The repo root defaults to this checkout; the flag exists for tests that +// build a throwaway workspace layout. + +import { spawnSync } from "node:child_process"; +import { performance } from "node:perf_hooks"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assessRunnerBuild, + removeBuildInputsStamp, +} from "../../packages/paperclip-runner/scripts/build-inputs.mjs"; + +/** The command the server build has always run here; the rebuild path is unchanged. */ +export const RUNNER_VENDOR_BUILD_COMMAND = "pnpm --filter @paperclipai/paperclip-runner build"; +export const FORCE_REBUILD_VARIABLE = "PAPERCLIP_RUNNER_VENDOR_REBUILD"; +export const TIMING_SCHEMA = "paperclip.runner-vendor.timing.v1"; + +export function decideRunnerVendorAction({ env, assessment }) { + if (env[FORCE_REBUILD_VARIABLE] === "1") { + return { action: "rebuild", code: "forced", detail: `${FORCE_REBUILD_VARIABLE}=1` }; + } + if (assessment.current) { + return { action: "reuse", code: assessment.code, detail: assessment.detail }; + } + return { action: "rebuild", code: assessment.code, detail: assessment.detail }; +} + +/** + * The pnpm that is running this script, the same rule as the CLI's + * resolvePnpmInstallInvocation: pnpm sets npm_execpath to its own entry, which + * is a JavaScript file for a normal install (run through this Node) or a native + * executable for a standalone pnpm (run directly). Outside a pnpm script, + * `pnpm` on PATH is the only option. The pnpm entry is not always on PATH in a + * script (corepack, for one), so this cannot just spawn `pnpm`. + */ +export function resolvePnpmInvocation(env = process.env, nodeExecPath = process.execPath) { + const npmExecPath = env.npm_execpath?.trim(); + if (npmExecPath && npmExecPath.toLowerCase().includes("pnpm")) { + if (/\.(cjs|mjs|js)$/i.test(npmExecPath)) { + return { command: nodeExecPath, argsPrefix: [npmExecPath] }; + } + return { command: npmExecPath, argsPrefix: [] }; + } + return { command: "pnpm", argsPrefix: [] }; +} + +export function runBuildCommand(command, { cwd, env = process.env }) { + const [tool, ...args] = command.split(" "); + if (tool !== "pnpm") throw new Error(`unexpected build command ${command}`); + const pnpm = resolvePnpmInvocation(env); + const result = spawnSync(pnpm.command, [...pnpm.argsPrefix, ...args], { + cwd, + env, + stdio: "inherit", + // A bare `pnpm` on Windows is pnpm.cmd, which needs the shell to resolve. + shell: pnpm.command === "pnpm" && process.platform === "win32", + }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +/** + * Returns the exit status for the step. `run` is injectable so tests can + * observe the command and the stamp state at the moment it runs. + */ +export function prepareRunnerVendor({ + repoRoot, + env = process.env, + run = runBuildCommand, + log = console.log, + now = () => performance.now(), +}) { + const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); + const checkStart = now(); + const assessment = assessRunnerBuild({ repoRoot, packageRoot }); + const decision = decideRunnerVendorAction({ env, assessment }); + const checkMs = Math.round(now() - checkStart); + + if (decision.action === "reuse") { + log(`prepare:runner-vendor: reusing the current Runner build (${decision.detail})`); + log(`${TIMING_SCHEMA},action:reuse,reason:${decision.code},check_ms:${checkMs},build_ms:0`); + return 0; + } + + log(`prepare:runner-vendor: rebuilding the Runner (${decision.detail})`); + // A build that stops partway must not leave a stamp that vouches for it; the + // Runner's own build writes a fresh one as its final step. + removeBuildInputsStamp(assessment.stampPath); + const buildStart = now(); + const status = run(RUNNER_VENDOR_BUILD_COMMAND, { cwd: path.join(repoRoot, "server"), env }); + const buildMs = Math.round(now() - buildStart); + log( + `${TIMING_SCHEMA},action:rebuild,reason:${decision.code},check_ms:${checkMs},build_ms:${buildMs},status:${status}`, + ); + return status; +} + +export function parseArguments(argv) { + let repoRoot = null; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--repo-root") { + repoRoot = argv[index + 1]; + if (!repoRoot) throw new Error("--repo-root needs a directory"); + index += 1; + continue; + } + throw new Error(`unknown argument ${argument}`); + } + return { repoRoot }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { repoRoot: requested } = parseArguments(process.argv.slice(2)); + const repoRoot = requested + ? path.resolve(requested) + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + process.exitCode = prepareRunnerVendor({ repoRoot }); +} diff --git a/server/scripts/prepare-runner-vendor.test.mjs b/server/scripts/prepare-runner-vendor.test.mjs new file mode 100644 index 00000000000..736d99b71d1 --- /dev/null +++ b/server/scripts/prepare-runner-vendor.test.mjs @@ -0,0 +1,267 @@ +import { execFile } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + assessRunnerBuild, + buildInputsStampPath, + readBuildInputsStamp, + recordRunnerBuild, +} from "../../packages/paperclip-runner/scripts/build-inputs.mjs"; +import { + FORCE_REBUILD_VARIABLE, + RUNNER_VENDOR_BUILD_COMMAND, + TIMING_SCHEMA, + decideRunnerVendorAction, + prepareRunnerVendor, + resolvePnpmInvocation, +} from "./prepare-runner-vendor.mjs"; + +const execFileAsync = promisify(execFile); +const prepareScript = fileURLToPath(new URL("./prepare-runner-vendor.mjs", import.meta.url)); +const recordScript = fileURLToPath( + new URL("../../packages/paperclip-runner/scripts/record-build-inputs.mjs", import.meta.url), +); +const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + +const roots = []; + +function writeTree(root, files) { + for (const [relative, content] of Object.entries(files)) { + const absolute = path.join(root, ...relative.split("/")); + mkdirSync(path.dirname(absolute), { recursive: true }); + writeFileSync(absolute, content); + } +} + +/** A throwaway workspace whose Runner is already built, with no stamp yet. */ +function makeRepo() { + const repoRoot = mkdtempSync(path.join(tmpdir(), "prepare-runner-vendor-")); + roots.push(repoRoot); + writeTree(repoRoot, { + "package.json": "{}\n", + "pnpm-lock.yaml": "lockfileVersion: 9\n", + "pnpm-workspace.yaml": "packages:\n - packages/*\n - server\n", + "tsconfig.base.json": "{}\n", + "server/package.json": '{"name":"@paperclipai/server"}\n', + "packages/paperclip-runner/package.json": '{"name":"@paperclipai/paperclip-runner"}\n', + "packages/paperclip-runner/src/index.ts": "export const one = 1;\n", + "packages/paperclip-runner/dist/index.js": "export const one = 1;\n", + "packages/paperclip-runner/dist/live/index.js": "export {};\n", + "packages/paperclip-runner/dist/testing.js": "export {};\n", + [`packages/paperclip-runner/dist/bin/${executable}`]: "binary\n", + "packages/paperclip-eval-kernel/src/index.ts": "export const kernel = 1;\n", + }); + const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); + return { repoRoot, packageRoot }; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("decideRunnerVendorAction", () => { + const current = { current: true, code: "inputs_unchanged", detail: "unchanged" }; + const stale = { current: false, code: "inputs_changed", detail: "changed" }; + + it("reuses a current build and rebuilds a stale one, carrying the assessment's reason", () => { + expect(decideRunnerVendorAction({ env: {}, assessment: current })).toEqual({ + action: "reuse", + code: "inputs_unchanged", + detail: "unchanged", + }); + expect(decideRunnerVendorAction({ env: {}, assessment: stale })).toEqual({ + action: "rebuild", + code: "inputs_changed", + detail: "changed", + }); + }); + + it("rebuilds a current build when the operator forces it", () => { + expect(decideRunnerVendorAction({ env: { [FORCE_REBUILD_VARIABLE]: "1" }, assessment: current })).toEqual({ + action: "rebuild", + code: "forced", + detail: `${FORCE_REBUILD_VARIABLE}=1`, + }); + // Only the documented value forces; an unrelated value does not. + expect(decideRunnerVendorAction({ env: { [FORCE_REBUILD_VARIABLE]: "0" }, assessment: current }).action).toBe( + "reuse", + ); + }); +}); + +describe("resolvePnpmInvocation", () => { + it("runs the pnpm that is running the script, through Node for a JavaScript entry", () => { + expect(resolvePnpmInvocation({ npm_execpath: "/corepack/pnpm/9.15.4/bin/pnpm.cjs" }, "/usr/bin/node")).toEqual({ + command: "/usr/bin/node", + argsPrefix: ["/corepack/pnpm/9.15.4/bin/pnpm.cjs"], + }); + expect(resolvePnpmInvocation({ npm_execpath: "/opt/pnpm/pnpm" }, "/usr/bin/node")).toEqual({ + command: "/opt/pnpm/pnpm", + argsPrefix: [], + }); + expect(resolvePnpmInvocation({ npm_execpath: "C:\\pnpm\\pnpm.exe" }, "node.exe")).toEqual({ + command: "C:\\pnpm\\pnpm.exe", + argsPrefix: [], + }); + }); + + it("falls back to pnpm on PATH outside a pnpm script or under another package manager", () => { + expect(resolvePnpmInvocation({}, "/usr/bin/node")).toEqual({ command: "pnpm", argsPrefix: [] }); + expect(resolvePnpmInvocation({ npm_execpath: "" }, "/usr/bin/node")).toEqual({ command: "pnpm", argsPrefix: [] }); + expect(resolvePnpmInvocation({ npm_execpath: "/usr/lib/node_modules/npm/bin/npm-cli.js" }, "/usr/bin/node")).toEqual({ + command: "pnpm", + argsPrefix: [], + }); + }); +}); + +describe("prepareRunnerVendor", () => { + it("still runs the Runner build the server build has always run", () => { + // The Dockerfile's standalone `pnpm --filter @paperclipai/server build` + // depends on this exact command producing a current vendored Runner. + expect(RUNNER_VENDOR_BUILD_COMMAND).toBe("pnpm --filter @paperclipai/paperclip-runner build"); + }); + + it("skips the rebuild when the recorded build is current and reports the check time", () => { + const { repoRoot, packageRoot } = makeRepo(); + recordRunnerBuild({ repoRoot, packageRoot }); + const runs = []; + const lines = []; + + const status = prepareRunnerVendor({ + repoRoot, + env: {}, + run: (...args) => { + runs.push(args); + return 0; + }, + log: (line) => lines.push(line), + }); + + expect(status).toBe(0); + expect(runs).toEqual([]); + expect(lines[0]).toMatch(/^prepare:runner-vendor: reusing the current Runner build \(inputs unchanged/); + expect(lines[1]).toMatch( + new RegExp(`^${TIMING_SCHEMA},action:reuse,reason:inputs_unchanged,check_ms:\\d+,build_ms:0$`), + ); + expect(readBuildInputsStamp(buildInputsStampPath(packageRoot))).not.toBeNull(); + }); + + it("rebuilds when the inputs changed, removing the stale stamp before the build starts", () => { + const { repoRoot, packageRoot } = makeRepo(); + recordRunnerBuild({ repoRoot, packageRoot }); + writeFileSync(path.join(packageRoot, "src", "index.ts"), "export const one = 2;\n"); + const stampPath = buildInputsStampPath(packageRoot); + const observed = []; + const lines = []; + const env = { npm_execpath: "/corepack/pnpm.cjs" }; + + const status = prepareRunnerVendor({ + repoRoot, + env, + run: (command, options) => { + observed.push({ command, ...options, stampPresent: readBuildInputsStamp(stampPath) !== null }); + return 0; + }, + log: (line) => lines.push(line), + }); + + expect(status).toBe(0); + expect(observed).toEqual([ + { command: RUNNER_VENDOR_BUILD_COMMAND, cwd: path.join(repoRoot, "server"), env, stampPresent: false }, + ]); + expect(lines[0]).toMatch(/^prepare:runner-vendor: rebuilding the Runner \(inputs changed/); + expect(lines[1]).toMatch( + new RegExp(`^${TIMING_SCHEMA},action:rebuild,reason:inputs_changed,check_ms:\\d+,build_ms:\\d+,status:0$`), + ); + }); + + it("returns the build's failure status and leaves no stamp behind", () => { + const { repoRoot, packageRoot } = makeRepo(); + const lines = []; + + const status = prepareRunnerVendor({ + repoRoot, + env: {}, + run: () => 3, + log: (line) => lines.push(line), + }); + + expect(status).toBe(3); + expect(lines[0]).toMatch(/^prepare:runner-vendor: rebuilding the Runner \(no build stamp/); + expect(lines[1]).toMatch(/,action:rebuild,reason:no_stamp,.*,status:3$/); + expect(readBuildInputsStamp(buildInputsStampPath(packageRoot))).toBeNull(); + }); + + it("rebuilds a current build when PAPERCLIP_RUNNER_VENDOR_REBUILD=1", () => { + const { repoRoot, packageRoot } = makeRepo(); + recordRunnerBuild({ repoRoot, packageRoot }); + let runs = 0; + const lines = []; + + const status = prepareRunnerVendor({ + repoRoot, + env: { [FORCE_REBUILD_VARIABLE]: "1" }, + run: () => { + runs += 1; + return 0; + }, + log: (line) => lines.push(line), + }); + + expect(status).toBe(0); + expect(runs).toBe(1); + expect(lines[1]).toMatch(/,action:rebuild,reason:forced,/); + }); + + it.skipIf(process.platform === "win32")( + "end to end: the first server build rebuilds the Runner, the second reuses it, a change rebuilds again", + async () => { + const { repoRoot, packageRoot } = makeRepo(); + // A stand-in pnpm that behaves like the Runner build: it records how it + // was called, then records the build inputs the way the real build does + // as its final step. + const binDir = path.join(repoRoot, "bin"); + const argvLog = path.join(repoRoot, "pnpm-argv.log"); + mkdirSync(binDir); + const pnpm = path.join(binDir, "pnpm"); + writeFileSync( + pnpm, + `${[ + "#!/bin/sh", + "set -e", + `printf '%s\\n' "$*" >> "${argvLog}"`, + `"${process.execPath}" "${recordScript}" --package-root "${packageRoot}" > /dev/null`, + ].join("\n")}\n`, + ); + chmodSync(pnpm, 0o755); + const env = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` }; + delete env[FORCE_REBUILD_VARIABLE]; + // The suite itself may run under pnpm; the script must then take the + // PATH fallback so the stand-in, not the real pnpm, receives the build. + delete env.npm_execpath; + const run = () => execFileAsync(process.execPath, [prepareScript, "--repo-root", repoRoot], { env }); + const calls = () => readFileSync(argvLog, "utf8").split("\n").filter(Boolean); + + const first = await run(); + expect(calls()).toEqual(["--filter @paperclipai/paperclip-runner build"]); + expect(first.stdout).toMatch(/action:rebuild,reason:no_stamp,/); + expect(assessRunnerBuild({ repoRoot, packageRoot }).current).toBe(true); + + const second = await run(); + expect(calls()).toHaveLength(1); + expect(second.stdout).toMatch(/action:reuse,reason:inputs_unchanged,/); + + writeFileSync(path.join(packageRoot, "src", "index.ts"), "export const one = 2;\n"); + const third = await run(); + expect(calls()).toHaveLength(2); + expect(third.stdout).toMatch(/action:rebuild,reason:inputs_changed,/); + }, + ); +}); diff --git a/server/src/__tests__/server-package-build-script.test.ts b/server/src/__tests__/server-package-build-script.test.ts index 0c972569472..bebaca1a417 100644 --- a/server/src/__tests__/server-package-build-script.test.ts +++ b/server/src/__tests__/server-package-build-script.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { RUNNER_VENDOR_BUILD_COMMAND } from "../../scripts/prepare-runner-vendor.mjs"; const packageJsonPath = fileURLToPath( new URL("../../package.json", import.meta.url), @@ -57,14 +58,33 @@ describe("server package build script", () => { expect(packageJson.devDependencies?.["@paperclipai/paperclip-runner"]).toBe( "workspace:*", ); + // prepare-runner-vendor.mjs reuses a Runner build whose recorded inputs + // are unchanged (the `pnpm -r build` case) and otherwise runs the Runner's + // full build, which is what a standalone server build (the Dockerfile's + // path) relies on. Pin both the indirection and the command behind it. expect(packageJson.scripts?.["prepare:runner-vendor"]).toBe( + "node scripts/prepare-runner-vendor.mjs", + ); + expect(RUNNER_VENDOR_BUILD_COMMAND).toBe( "pnpm --filter @paperclipai/paperclip-runner build", ); + expect(packageJson.scripts?.build.startsWith("pnpm run prepare:runner-vendor && ")).toBe(true); expect(packageJson.scripts?.build).toContain( "cp -Rf ../packages/paperclip-runner/dist/. dist/vendor/paperclip-runner/", ); }); + it("records the Runner's build inputs as the last step of its build", () => { + // The stamp prepare-runner-vendor.mjs trusts must only exist for a build + // that completed every step and check, so it has to be written last. + const runner = JSON.parse(readFileSync(new URL( + "../../../packages/paperclip-runner/package.json", import.meta.url, + ), "utf8")) as { scripts: Record }; + const steps = runner.scripts.build.split(" && "); + expect(steps.at(-1)).toBe("node scripts/record-build-inputs.mjs"); + expect(steps.filter((step) => step.includes("record-build-inputs"))).toHaveLength(1); + }); + it("replaces an existing read-only image manifest with the build's copy step", () => { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); const step = packageJson.scripts.build.split(" && ").find((command: string) => @@ -104,10 +124,10 @@ describe("server package build script", () => { }); it("compiles the vendored Runner's TypeScript surface once per build", () => { - // prepare:runner-vendor runs the Runner's full build, and `pnpm -r build` - // has already run it once by the time the server builds. Each extra + // prepare:runner-vendor runs the Runner's full build whenever its recorded + // inputs are stale or missing (every standalone server build). Each extra // build:typescript inside that chain (two tsc projects plus the eval-kernel - // rebuild) costs about 7s on the CI Build job's critical path, twice. + // rebuild) costs about 7s on the CI Build job's critical path. // Checks that read dist/ must call their script directly, the way // generate-replay-goldens.mjs already does, not through a `check:*` // wrapper that rebuilds first for standalone use.