diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index f962fe6c20b..b0fd931a8d0 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": "node scripts/build-runner-package.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-runner-package.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-runner-package.mjs b/packages/paperclip-runner/scripts/build-runner-package.mjs new file mode 100644 index 00000000000..6a9dffcfc7d --- /dev/null +++ b/packages/paperclip-runner/scripts/build-runner-package.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +// Build the Runner package with the Rust binary compiling while the +// TypeScript surface builds and its checks run. +// +// `pnpm run build` used to be one `&&` chain: seven TypeScript steps (about +// 17s on the CI Build job), then `cargo build` (about 36s with the dependency +// cache restored), then two checks that read the staged binary. Nothing in +// the first seven steps reads the binary or runner/target, and cargo reads +// nothing those steps write: its only non-Rust inputs are the committed +// fixtures and schemas under protocol/, which the steps check but never +// regenerate. So cargo starts first, the TypeScript steps run while it +// compiles, the binary is staged once both are done, and the last two steps +// keep their place after staging. +// +// This build sits on the critical path of `pnpm -r build`: every adapter, +// plugin package and the server wait for it. +// +// PAPERCLIP_RUNNER_BUILD_SERIAL=1 runs cargo after the TypeScript steps +// instead, for a machine that cannot afford both at once. + +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const CARGO_BUILD_ARGS = [ + "build", + "--release", + "--manifest-path", + "runner/Cargo.toml", + "--locked", + "-p", + "paperclip-runner-core", + "--bin", + "paperclip-runnerd", +]; + +// Steps that run while cargo compiles, in this order. A `run` entry names a +// script in package.json. A `node` entry runs a script directly because the +// matching `check:*` wrapper rebuilds the TypeScript surface first for +// standalone use, and this build compiles it exactly once. +export const STEPS_DURING_CARGO = Object.freeze([ + { run: "check:protocol-manifest" }, + { run: "build:typescript" }, + { run: "check:capability-contract" }, + { run: "check:capability-inventory" }, + { run: "check:protocol-coverage" }, + { node: ["scripts/generate-semantic-contracts.mjs", "--check"] }, + { node: ["scripts/check-runner-workflow-traceability.mjs"] }, +]); + +// Runs after cargo has exited successfully and before anything reads the binary. +export const STAGE_BINARY_STEP = Object.freeze({ node: ["scripts/stage-runner-binary.mjs"] }); + +// Steps that read the staged binary, in this order. +export const STEPS_AFTER_BINARY = Object.freeze([ + { node: ["scripts/generate-replay-goldens.mjs", "--check"] }, + { node: ["scripts/generate-semantic-action-catalog.mjs", "--check"] }, +]); + +export const TIMING_LINE_PREFIX = "paperclip.runner-build.timing.v1"; + +const STOP_GRACE_MS = 5_000; + +export function describeStep(step) { + return step.run ? `pnpm run ${step.run}` : `node ${step.node.join(" ")}`; +} + +export class RunnerBuildError extends Error { + constructor(name, result) { + const reason = result.error + ? `could not start (${result.error.message})` + : result.signal + ? `was stopped by ${result.signal}` + : `exited with code ${result.code}`; + super(`${name} ${reason}`); + this.name = "RunnerBuildError"; + this.step = name; + this.result = result; + } +} + +function pnpmCommand(env) { + // pnpm sets npm_execpath to its own entry point while it runs this script, + // so a nested `pnpm run` uses the same pnpm. Outside pnpm, use the one on + // PATH; Windows needs a shell to resolve the .cmd shim. + const execPath = env.npm_execpath; + if (execPath && /pnpm/.test(path.basename(execPath))) { + return { command: process.execPath, args: [execPath] }; + } + return process.platform === "win32" + ? { command: "pnpm", args: [], shell: true } + : { command: "pnpm", args: [] }; +} + +export function defaultCommandFor(step, env = process.env) { + if (step.node) { + return { command: process.execPath, args: [...step.node] }; + } + const pnpm = pnpmCommand(env); + return { command: pnpm.command, args: [...pnpm.args, "run", step.run], shell: pnpm.shell }; +} + +export function defaultCargoCommand() { + return { command: "cargo", args: [...CARGO_BUILD_ARGS] }; +} + +function launch({ command, args, shell }, { cwd, env }) { + const child = spawn(command, args, { cwd, env, stdio: "inherit", shell: shell === true }); + let settled = null; + const exit = new Promise((resolve) => { + const settle = (result) => { + if (!settled) { + settled = result; + resolve(result); + } + }; + child.once("error", (error) => settle({ error })); + child.once("exit", (code, signal) => settle({ code, signal })); + }); + return { child, exit, result: () => settled }; +} + +const succeeded = (result) => !result.error && result.code === 0 && !result.signal; + +async function stop(running) { + if (running.result()) return running.result(); + try { + running.child.kill("SIGTERM"); + } catch { + // Already gone. + } + const timer = new Promise((resolve) => setTimeout(resolve, STOP_GRACE_MS, "timeout")); + if ((await Promise.race([running.exit, timer])) === "timeout" && !running.result()) { + try { + running.child.kill("SIGKILL"); + } catch { + // Already gone. + } + } + return running.exit; +} + +/** + * Run the Runner build. `commandFor` and `cargoCommand` exist so tests can + * stand in for pnpm and cargo with local Node processes. + */ +export async function runRunnerBuild({ + cwd = packageRoot, + env = process.env, + serial = env.PAPERCLIP_RUNNER_BUILD_SERIAL === "1", + commandFor = (step) => defaultCommandFor(step, env), + cargoCommand = defaultCargoCommand, + log = (line) => process.stderr.write(`${line}\n`), +} = {}) { + const startedAt = Date.now(); + const timing = { cargoStartedAt: null, cargoEndedAt: null, stepsEndedAt: null }; + let cargo = null; + + const startCargo = () => { + const spec = cargoCommand(); + log(`[runner build] ${spec.command} ${spec.args.join(" ")}`); + timing.cargoStartedAt = Date.now(); + cargo = launch(spec, { cwd, env }); + void cargo.exit.then(() => { + timing.cargoEndedAt = Date.now(); + }); + }; + + const runStep = async (step) => { + const name = describeStep(step); + log(`[runner build] ${name}`); + const result = await launch(commandFor(step), { cwd, env }).exit; + if (!succeeded(result)) throw new RunnerBuildError(name, result); + }; + + const cargoFailure = () => { + const result = cargo?.result(); + return result && !succeeded(result) ? new RunnerBuildError("cargo build", result) : null; + }; + + if (!serial) startCargo(); + try { + for (const step of STEPS_DURING_CARGO) { + // A cargo failure that has already surfaced ends the build before the + // next step, instead of after the whole TypeScript chain. + const failure = cargoFailure(); + if (failure) throw failure; + await runStep(step); + } + } catch (error) { + if (cargo) await stop(cargo); + throw error; + } + timing.stepsEndedAt = Date.now(); + + if (serial) startCargo(); + const cargoResult = await cargo.exit; + if (!succeeded(cargoResult)) throw new RunnerBuildError("cargo build", cargoResult); + + await runStep(STAGE_BINARY_STEP); + for (const step of STEPS_AFTER_BINARY) await runStep(step); + + const endedAt = Date.now(); + const summary = { + mode: serial ? "serial" : "overlap", + cargo_ms: timing.cargoEndedAt - timing.cargoStartedAt, + steps_ms: timing.stepsEndedAt - startedAt, + // How long the build waited on cargo alone after the TypeScript steps. + cargo_wait_ms: Math.max(0, timing.cargoEndedAt - timing.stepsEndedAt), + total_ms: endedAt - startedAt, + }; + log( + `${TIMING_LINE_PREFIX},${Object.entries(summary) + .map(([key, value]) => `${key}:${value}`) + .join(",")}`, + ); + return summary; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runRunnerBuild().catch((error) => { + process.stderr.write(`[runner build] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/paperclip-runner/scripts/build-runner-package.test.mjs b/packages/paperclip-runner/scripts/build-runner-package.test.mjs new file mode 100644 index 00000000000..fa80d00f7bf --- /dev/null +++ b/packages/paperclip-runner/scripts/build-runner-package.test.mjs @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + CARGO_BUILD_ARGS, + RunnerBuildError, + STAGE_BINARY_STEP, + STEPS_AFTER_BINARY, + STEPS_DURING_CARGO, + TIMING_LINE_PREFIX, + defaultCargoCommand, + defaultCommandFor, + describeStep, + runRunnerBuild, +} from "./build-runner-package.mjs"; + +// Every stand-in appends " start" and " end" to a shared event +// log, so a test can assert what ran before what. Its arguments: +// log, name, exit code, marker to write at start, file to wait for before +// ending, marker to write at the end. The cargo stand-in also records its +// pid so a test can check that the build stopped it. +const STAND_IN = ` +const fs = require("node:fs"); +const [log, name, exitCode, startMarker, waitFor, endMarker] = process.argv.slice(1); +const append = (line) => fs.appendFileSync(log, line + "\\n"); +if (name === "cargo") fs.writeFileSync(log + ".cargo.pid", String(process.pid)); +append(name + " start"); +if (startMarker) fs.writeFileSync(startMarker, ""); +const deadline = Date.now() + 15000; +while (waitFor && !fs.existsSync(waitFor)) { + if (Date.now() > deadline) { append(name + " timeout"); process.exit(3); } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); +} +if (endMarker) fs.writeFileSync(endMarker, ""); +append(name + " end"); +process.exit(Number(exitCode)); +`; + +const FIRST_STEP = describeStep(STEPS_DURING_CARGO[0]); +const RELEASE_STEP = describeStep(STEPS_DURING_CARGO[STEPS_DURING_CARGO.length - 1]); + +function harness({ exitCodes = {}, cargoExit = 0, cargoWaitsForRelease = true } = {}) { + const root = mkdtempSync(path.join(tmpdir(), "runner-build-")); + const log = path.join(root, "events.log"); + const cargoStarted = path.join(root, "cargo.started"); + const release = path.join(root, "release"); + const standIn = (name, exitCode, startMarker = "", waitFor = "", endMarker = "") => ({ + command: process.execPath, + args: ["-e", STAND_IN, log, name, String(exitCode), startMarker, waitFor, endMarker], + }); + return { + root, + log, + events: () => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []), + cargoPid: () => Number(readFileSync(`${log}.cargo.pid`, "utf8")), + options: { + cwd: root, + env: { ...process.env, PAPERCLIP_RUNNER_BUILD_SERIAL: "" }, + log: () => {}, + cargoCommand: () => standIn("cargo", cargoExit, cargoStarted, cargoWaitsForRelease ? release : ""), + commandFor: (step) => { + const name = describeStep(step); + // The first step waits for cargo's start marker so the event order + // between two processes started back to back is not left to chance. + // The last step during cargo releases the cargo stand-in. + return standIn( + name, + exitCodes[name] ?? 0, + "", + name === FIRST_STEP && cargoWaitsForRelease ? cargoStarted : "", + name === RELEASE_STEP ? release : "", + ); + }, + }, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +const alive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === "EPERM"; + } +}; + +async function waitUntilGone(pid) { + const deadline = Date.now() + 10_000; + while (alive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return !alive(pid); +} + +test("cargo starts before the first TypeScript step and is joined before the binary is staged", async () => { + const h = harness(); + try { + const summary = await runRunnerBuild(h.options); + const events = h.events(); + const at = (line) => { + const index = events.indexOf(line); + assert.notEqual(index, -1, `missing event ${line} in ${events.join(" | ")}`); + return index; + }; + assert.ok(at("cargo start") < at(`${FIRST_STEP} start`), "cargo started after the first step"); + for (let i = 1; i < STEPS_DURING_CARGO.length; i += 1) { + assert.ok( + at(`${describeStep(STEPS_DURING_CARGO[i - 1])} end`) < at(`${describeStep(STEPS_DURING_CARGO[i])} start`), + "TypeScript steps ran out of order", + ); + } + // cargo was still running while the steps ran: it only ends once the last step releases it. + assert.ok(at(`${RELEASE_STEP} start`) < at("cargo end"), "cargo ended before the steps finished"); + assert.ok(at("cargo end") < at(`${describeStep(STAGE_BINARY_STEP)} start`), "binary staged before cargo ended"); + const afterNames = STEPS_AFTER_BINARY.map(describeStep); + assert.ok(at(`${describeStep(STAGE_BINARY_STEP)} end`) < at(`${afterNames[0]} start`)); + assert.ok(at(`${afterNames[0]} end`) < at(`${afterNames[1]} start`)); + assert.equal(summary.mode, "overlap"); + for (const key of ["cargo_ms", "steps_ms", "cargo_wait_ms", "total_ms"]) { + assert.equal(typeof summary[key], "number", key); + assert.ok(summary[key] >= 0, key); + } + } finally { + h.cleanup(); + } +}); + +test("a failing TypeScript step stops cargo and names the step", async () => { + const failing = describeStep(STEPS_DURING_CARGO[1]); + const h = harness({ exitCodes: { [failing]: 7 } }); + try { + await assert.rejects(runRunnerBuild(h.options), (error) => { + assert.ok(error instanceof RunnerBuildError); + assert.equal(error.step, failing); + assert.match(error.message, /exited with code 7/); + return true; + }); + assert.ok(await waitUntilGone(h.cargoPid()), "cargo stand-in outlived the failed build"); + const events = h.events(); + assert.ok(!events.includes("cargo end"), "cargo ran to completion after the failure"); + assert.ok(!events.some((line) => line.startsWith(describeStep(STEPS_DURING_CARGO[2]))), "a later step ran after the failure"); + assert.ok(!events.some((line) => line.startsWith(describeStep(STAGE_BINARY_STEP))), "the binary was staged after the failure"); + } finally { + h.cleanup(); + } +}); + +test("a failing cargo build fails the build before the binary is staged", async () => { + const h = harness({ cargoExit: 2, cargoWaitsForRelease: false }); + try { + await assert.rejects(runRunnerBuild(h.options), (error) => { + assert.ok(error instanceof RunnerBuildError); + assert.equal(error.step, "cargo build"); + assert.match(error.message, /exited with code 2/); + return true; + }); + const events = h.events(); + assert.ok(events.includes("cargo end")); + assert.ok(!events.some((line) => line.startsWith(describeStep(STAGE_BINARY_STEP))), "the binary was staged after cargo failed"); + assert.ok(!events.some((line) => line.startsWith(describeStep(STEPS_AFTER_BINARY[0]))), "a post-binary step ran after cargo failed"); + } finally { + h.cleanup(); + } +}); + +test("a cargo failure that surfaces early ends the TypeScript chain at the next step", async () => { + const h = harness({ cargoExit: 2, cargoWaitsForRelease: false }); + // The cargo stand-in fails at once and leaves a marker; the first step + // holds until that marker exists, so cargo's failure is known before the + // second step would start. + const cargoDone = path.join(h.root, "cargo.done"); + const innerCargo = h.options.cargoCommand; + h.options.cargoCommand = () => { + const spec = innerCargo(); + spec.args[spec.args.length - 1] = cargoDone; + return spec; + }; + const innerStep = h.options.commandFor; + h.options.commandFor = (step) => { + const spec = innerStep(step); + if (describeStep(step) === FIRST_STEP) spec.args[spec.args.length - 2] = cargoDone; + return spec; + }; + try { + await assert.rejects(runRunnerBuild(h.options), (error) => { + assert.equal(error.step, "cargo build"); + return true; + }); + const events = h.events(); + assert.ok(events.includes(`${FIRST_STEP} end`), `first step should have run to its end: ${events.join(" | ")}`); + assert.ok(!events.some((line) => line.startsWith(describeStep(STEPS_DURING_CARGO[1]))), "the second step ran after cargo had failed"); + } finally { + h.cleanup(); + } +}); + +test("PAPERCLIP_RUNNER_BUILD_SERIAL=1 runs cargo after the TypeScript steps", async () => { + const h = harness({ cargoWaitsForRelease: false }); + h.options.env = { ...process.env, PAPERCLIP_RUNNER_BUILD_SERIAL: "1" }; + try { + const summary = await runRunnerBuild(h.options); + const events = h.events(); + assert.ok(events.indexOf(`${RELEASE_STEP} end`) < events.indexOf("cargo start")); + assert.ok(events.indexOf("cargo end") < events.indexOf(`${describeStep(STAGE_BINARY_STEP)} start`)); + assert.equal(summary.mode, "serial"); + } finally { + h.cleanup(); + } +}); + +test("the plan compiles the TypeScript surface once and keeps the binary readers after staging", () => { + const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + assert.equal(packageJson.scripts.build, "node scripts/build-runner-package.mjs"); + const wrapped = STEPS_DURING_CARGO.filter((step) => step.run).map((step) => step.run); + for (const name of wrapped) assert.ok(packageJson.scripts[name], `missing script ${name}`); + const compiles = (name) => name === "build:typescript" || packageJson.scripts[name].includes("build:typescript"); + assert.deepEqual(wrapped.filter(compiles), ["build:typescript"]); + for (const step of [...STEPS_DURING_CARGO, STAGE_BINARY_STEP, ...STEPS_AFTER_BINARY]) { + assert.ok(!step.run || step.run !== "build:binary", "the plan must not run build:binary; the build runs cargo itself"); + if (step.node) assert.ok(existsSync(new URL(`../${step.node[0]}`, import.meta.url)), `missing ${step.node[0]}`); + } + // build:binary stays the standalone way to compile and stage the binary, + // and the build runs the same cargo invocation. + const standalone = packageJson.scripts["build:binary"]; + assert.equal(standalone, `cargo ${CARGO_BUILD_ARGS.join(" ")} && node ${STAGE_BINARY_STEP.node.join(" ")}`); + assert.deepEqual(defaultCargoCommand(), { command: "cargo", args: CARGO_BUILD_ARGS }); + assert.deepEqual(STEPS_AFTER_BINARY.map(describeStep), [ + "node scripts/generate-replay-goldens.mjs --check", + "node scripts/generate-semantic-action-catalog.mjs --check", + ]); + assert.equal(typeof TIMING_LINE_PREFIX, "string"); +}); + +test("nested pnpm runs use the pnpm that started the build", () => { + const viaPnpm = defaultCommandFor({ run: "build:typescript" }, { npm_execpath: "/opt/pnpm/bin/pnpm.cjs" }); + assert.deepEqual(viaPnpm, { command: process.execPath, args: ["/opt/pnpm/bin/pnpm.cjs", "run", "build:typescript"], shell: undefined }); + const viaPath = defaultCommandFor({ run: "build:typescript" }, {}); + assert.equal(viaPath.command, "pnpm"); + assert.deepEqual(viaPath.args, ["run", "build:typescript"]); + const direct = defaultCommandFor({ node: ["scripts/stage-runner-binary.mjs"] }, {}); + assert.deepEqual(direct, { command: process.execPath, args: ["scripts/stage-runner-binary.mjs"] }); +}); diff --git a/server/src/__tests__/server-package-build-script.test.ts b/server/src/__tests__/server-package-build-script.test.ts index 0c972569472..cc4c2df7b80 100644 --- a/server/src/__tests__/server-package-build-script.test.ts +++ b/server/src/__tests__/server-package-build-script.test.ts @@ -103,21 +103,33 @@ describe("server package build script", () => { ); }); - it("compiles the vendored Runner's TypeScript surface once per build", () => { + it("compiles the vendored Runner's TypeScript surface once per build", async () => { // 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 - // build:typescript inside that chain (two tsc projects plus the eval-kernel + // build:typescript inside that build (two tsc projects plus the eval-kernel // rebuild) costs about 7s on the CI Build job's critical path, twice. // 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. - 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(" && "); - const wrapped = steps - .filter((step) => step.startsWith("pnpm run ")) - .map((step) => step.slice("pnpm run ".length)); + // + // The Runner's build is scripts/build-runner-package.mjs, which runs the + // TypeScript steps while cargo compiles and exports the step plan, so the + // plan is read from the module rather than parsed out of a shell chain. + const runnerRoot = new URL("../../../packages/paperclip-runner/", import.meta.url); + const runner = JSON.parse(readFileSync( + new URL("package.json", runnerRoot), "utf8", + )) as { scripts: Record }; + expect(runner.scripts.build).toBe("node scripts/build-runner-package.mjs"); + + const plan = await import(new URL("scripts/build-runner-package.mjs", runnerRoot).href) as { + STEPS_DURING_CARGO: ReadonlyArray<{ run?: string; node?: string[] }>; + STAGE_BINARY_STEP: { node: string[] }; + STEPS_AFTER_BINARY: ReadonlyArray<{ run?: string; node?: string[] }>; + describeStep: (step: { run?: string; node?: string[] }) => string; + }; + const wrapped = plan.STEPS_DURING_CARGO + .filter((step) => step.run) + .map((step) => step.run as string); for (const name of wrapped) { expect(runner.scripts[name], `build step ${name}`).toBeDefined(); } @@ -125,12 +137,22 @@ describe("server package build script", () => { step === "build:typescript" || (runner.scripts[step] ?? "").includes("build:typescript"); expect(wrapped.filter(compiles)).toEqual(["build:typescript"]); + const steps = plan.STEPS_DURING_CARGO.map(plan.describeStep); expect(steps).toContain("node scripts/generate-semantic-contracts.mjs --check"); expect(steps).toContain("node scripts/check-runner-workflow-traceability.mjs"); expect(steps.indexOf("node scripts/generate-semantic-contracts.mjs --check")) .toBeGreaterThan(steps.indexOf("pnpm run build:typescript")); expect(steps.indexOf("node scripts/check-runner-workflow-traceability.mjs")) .toBeGreaterThan(steps.indexOf("pnpm run build:typescript")); + // The binary the server vendors is staged by the build itself, after + // cargo, and the steps that read it stay behind the staging step. + expect(plan.describeStep(plan.STAGE_BINARY_STEP)).toBe("node scripts/stage-runner-binary.mjs"); + expect(plan.STEPS_AFTER_BINARY.map(plan.describeStep)).toEqual([ + "node scripts/generate-replay-goldens.mjs --check", + "node scripts/generate-semantic-action-catalog.mjs --check", + ]); + expect([...plan.STEPS_DURING_CARGO, ...plan.STEPS_AFTER_BINARY].map(plan.describeStep)) + .not.toContain("pnpm run build:binary"); }); it("ships the pinned OpenCode dependency resolved by the vendored Runner", () => {