mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
perf(dev): add agent:preflight with a shared content-keyed build cache Agent worktrees each built the Runner from nothing: a cold cargo debug and release build, the Runner TypeScript build and its checks, about 8 minutes per fresh worktree on a loaded machine. `pnpm agent:preflight` replaces the manual install + Runner build sequence. It skips steps whose inputs are unchanged and reuses other worktrees' results from ~/.cache/paperclip/agent-build: - Runner and eval-kernel dist, keyed by the Runner build-input digest and the toolchain - Runner debug binaries, keyed by every file the Rust build reads - a cargo seed with third-party crates only; the Runner crate is scrubbed because cargo records its out-of-package includes by absolute path Entries are written once with an atomic rename and restored as APFS clones. Also exclude provider-assets from the Runner build inputs. The build writes that directory itself, so the first build in every fresh checkout saw its inputs change and never recorded a build stamp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
543 lines
27 KiB
JavaScript
543 lines
27 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { existsSync, readdirSync } from "node:fs";
|
|
import { mkdtemp, mkdir, readdir, readFile, rm, stat, symlink, truncate, utimes, 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,
|
|
PENDING_RECORD_MAX_AGE_MS,
|
|
assessRunnerBuild,
|
|
beginRunnerBuild,
|
|
buildInputsPendingDirectory,
|
|
buildInputsPendingPath,
|
|
buildInputsStampPath,
|
|
collectBuildInputFiles,
|
|
collectBuildOutputFiles,
|
|
computeBuildInputsDigest,
|
|
currentBuildId,
|
|
expectedBuildOutputs,
|
|
readBuildInputsStamp,
|
|
recordRunnerBuild,
|
|
} from "./build-inputs.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const recordScript = fileURLToPath(new URL("./record-build-inputs.mjs", import.meta.url));
|
|
/** A child of this process names its build after this process, as `--begin` names it after the shell. */
|
|
const childBuildId = String(process.pid);
|
|
|
|
async function waitFor(condition, label, timeoutMs = 10_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (!condition()) {
|
|
if (Date.now() > deadline) throw new Error(`timed out waiting for ${label}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The whole build chain the way pnpm runs it: one shell, `--begin` first,
|
|
* `--finish` last, with a stand-in for the build steps that waits for a
|
|
* marker file so two chains can be held open at the same time.
|
|
*/
|
|
function runChainInShell(shell, packageRoot, marker) {
|
|
const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
const wait =
|
|
`${quote(process.execPath)} -e ${quote(
|
|
"const fs=require('node:fs');const [marker]=process.argv.slice(1);const deadline=Date.now()+10000;" +
|
|
"(function poll(){if(fs.existsSync(marker))return;if(Date.now()>deadline)process.exit(3);setTimeout(poll,20)})()",
|
|
)} ${quote(marker)}`;
|
|
const phase = (flag) => `${quote(process.execPath)} ${quote(recordScript)} ${flag} --package-root ${quote(packageRoot)}`;
|
|
return execFileAsync(shell, ["-c", `${phase("--begin")} && ${wait} && ${phase("--finish")}`]);
|
|
}
|
|
|
|
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, two root
|
|
* patches, built outputs (the four every consumer reads plus one helper that
|
|
* is only opened by path), 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",
|
|
"patches/acpx@0.13.1.patch": "--- a\n+++ b\n",
|
|
"patches/pi-acp@0.0.33.patch": "--- c\n+++ d\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/cli/acpx-runtime-sidecar.cjs": "module.exports = {};\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 };
|
|
}
|
|
|
|
const INPUT_FILE_COUNT = 10;
|
|
const OUTPUT_FILE_COUNT = 5;
|
|
|
|
async function withRepo(action) {
|
|
const repo = await makeRepo();
|
|
try {
|
|
await action(repo);
|
|
} finally {
|
|
await rm(repo.repoRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** A complete, undisturbed build: begin, (nothing changes), finish. */
|
|
function recordCurrentBuild({ repoRoot, packageRoot, platform, arch, now }) {
|
|
beginRunnerBuild({ repoRoot, packageRoot, platform, arch, now });
|
|
return recordRunnerBuild({ repoRoot, packageRoot, platform, arch, now });
|
|
}
|
|
|
|
test("the input set covers sources, the eval kernel, root patches 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",
|
|
"patches/acpx@0.13.1.patch",
|
|
"patches/pi-acp@0.0.33.patch",
|
|
"pnpm-lock.yaml",
|
|
"pnpm-workspace.yaml",
|
|
"tsconfig.base.json",
|
|
]);
|
|
assert.equal(files.length, INPUT_FILE_COUNT);
|
|
});
|
|
});
|
|
|
|
test("the output manifest lists every file under dist with its size, and nothing outside it", async () => {
|
|
await withRepo(async ({ packageRoot }) => {
|
|
const outputs = collectBuildOutputFiles(packageRoot);
|
|
assert.deepEqual(outputs, [
|
|
{ path: "dist/bin/paperclip-runnerd", size: 7 },
|
|
{ path: "dist/cli/acpx-runtime-sidecar.cjs", size: 21 },
|
|
{ path: "dist/index.js", size: 22 },
|
|
{ path: "dist/live/index.js", size: 11 },
|
|
{ path: "dist/testing.js", size: 11 },
|
|
]);
|
|
assert.equal(outputs.length, OUTPUT_FILE_COUNT);
|
|
});
|
|
});
|
|
|
|
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, INPUT_FILE_COUNT);
|
|
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/cli/acpx-runtime-sidecar.cjs": "changed\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-runner/provider-assets/copilot/copilot": "installed by the build\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, a root
|
|
// patch, 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 pi-acp patch the build copies into dist", { "patches/pi-acp@0.0.33.patch": "--- c\n+++ d\n+e\n" });
|
|
await expectChange("the acpx patch the profile check hashes", { "patches/acpx@0.13.1.patch": "--- a\n+++ b\n+c\n" });
|
|
await expectChange("a new patch", { "patches/brace-expansion@5.0.9.patch": "--- x\n+++ y\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 recorded output intact", 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 = recordCurrentBuild({ repoRoot, packageRoot, platform, arch, now: () => new Date(0) });
|
|
assert.equal(record.recorded, true);
|
|
assert.equal(record.stampPath, buildInputsStampPath(packageRoot));
|
|
const stamp = readBuildInputsStamp(record.stampPath);
|
|
assert.equal(stamp.schema, BUILD_INPUTS_SCHEMA);
|
|
assert.equal(stamp.digest, record.digest);
|
|
assert.equal(stamp.fileCount, INPUT_FILE_COUNT);
|
|
assert.equal(stamp.byteCount, record.byteCount);
|
|
assert.equal(stamp.platform, platform);
|
|
assert.equal(stamp.arch, arch);
|
|
assert.equal(stamp.startedAt, "1970-01-01T00:00:00.000Z");
|
|
assert.equal(stamp.recordedAt, "1970-01-01T00:00:00.000Z");
|
|
assert.equal(stamp.outputCount, OUTPUT_FILE_COUNT);
|
|
assert.deepEqual(stamp.outputs, collectBuildOutputFiles(packageRoot));
|
|
const current = assess();
|
|
assert.equal(current.current, true, current.detail);
|
|
assert.equal(current.code, "inputs_unchanged");
|
|
assert.match(current.detail, /10 files and 5 outputs checked/);
|
|
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot, platform: "win32", arch }).code, "platform_mismatch");
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot, platform, arch: "arm64" }).code, "platform_mismatch");
|
|
|
|
// The four outputs every consumer reads are checked by name.
|
|
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);
|
|
|
|
// So is every other file the build left under dist: a helper that is only
|
|
// opened by path must be there, at the size the build wrote.
|
|
const helper = path.join(packageRoot, "dist", "cli", "acpx-runtime-sidecar.cjs");
|
|
const helperContent = await readFile(helper);
|
|
await rm(helper);
|
|
const gone = assess();
|
|
assert.equal(gone.current, false, "a missing helper still counted as current");
|
|
assert.equal(gone.code, "outputs_changed");
|
|
assert.match(gone.detail, /^build output dist\/cli\/acpx-runtime-sidecar\.cjs is missing since the build recorded at /);
|
|
await writeFile(helper, helperContent);
|
|
assert.equal(assess().current, true);
|
|
|
|
await truncate(helper, 3);
|
|
const truncated = assess();
|
|
assert.equal(truncated.current, false, "a truncated helper still counted as current");
|
|
assert.equal(truncated.code, "outputs_changed");
|
|
assert.match(truncated.detail, /^build output dist\/cli\/acpx-runtime-sidecar\.cjs is changed size since /);
|
|
await writeFile(helper, helperContent);
|
|
assert.equal(assess().current, true);
|
|
|
|
// A file the build did not produce may appear without invalidating it.
|
|
await writeFile(path.join(packageRoot, "dist", "extra.txt"), "later\n");
|
|
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, JSON.stringify({ ...readBuildInputsStamp(record.stampPath), schema: BUILD_INPUTS_SCHEMA, outputs: undefined }));
|
|
assert.equal(assess().code, "schema_mismatch", "a stamp without an output manifest was trusted");
|
|
await writeFile(record.stampPath, "not json");
|
|
assert.equal(assess().code, "no_stamp");
|
|
});
|
|
});
|
|
|
|
test("an input edited while the build runs leaves no stamp, so the next consumer rebuilds", async () => {
|
|
await withRepo(async ({ repoRoot, packageRoot }) => {
|
|
const stampPath = buildInputsStampPath(packageRoot);
|
|
const pendingPath = buildInputsPendingPath(packageRoot, currentBuildId());
|
|
const assess = () => assessRunnerBuild({ repoRoot, packageRoot });
|
|
|
|
// A previous good build, then a new build begins: the old stamp is gone at
|
|
// once, because the outputs are about to be replaced.
|
|
recordCurrentBuild({ repoRoot, packageRoot });
|
|
assert.equal(assess().current, true);
|
|
const pending = beginRunnerBuild({ repoRoot, packageRoot, now: () => new Date(1000) });
|
|
assert.equal(pending.pendingPath, pendingPath);
|
|
assert.equal(pending.buildId, String(process.ppid), "a build is named after the shell that runs its chain");
|
|
assert.equal(readBuildInputsStamp(stampPath), null, "the old stamp survived the start of a new build");
|
|
assert.equal(readBuildInputsStamp(pendingPath).digest, pending.digest);
|
|
assert.equal(readBuildInputsStamp(pendingPath).buildId, pending.buildId);
|
|
assert.equal(assess().code, "no_stamp");
|
|
|
|
// The build "compiles" the old source, then an edit lands before it ends.
|
|
await writeFile(path.join(packageRoot, "src", "index.ts"), "export const one = 2;\n");
|
|
const result = recordRunnerBuild({ repoRoot, packageRoot });
|
|
assert.equal(result.recorded, false);
|
|
assert.equal(result.code, "inputs_changed_during_build");
|
|
assert.match(result.detail, /^inputs changed while the build ran \(digest [0-9a-f]{12} at start, [0-9a-f]{12} now\); the next consumer rebuilds$/);
|
|
assert.equal(result.startedAt, "1970-01-01T00:00:01.000Z");
|
|
assert.equal(readBuildInputsStamp(stampPath), null, "a stamp was written for a build whose inputs moved");
|
|
assert.equal(readBuildInputsStamp(pendingPath), null, "the pending record was left behind");
|
|
assert.equal(assess().code, "no_stamp");
|
|
|
|
// An undisturbed build records normally, and only the pending record's
|
|
// start time is kept.
|
|
const record = recordCurrentBuild({ repoRoot, packageRoot });
|
|
assert.equal(record.recorded, true);
|
|
assert.equal(readBuildInputsStamp(pendingPath), null);
|
|
assert.equal(assess().current, true);
|
|
});
|
|
});
|
|
|
|
test("a build that did not begin, or that left a required output missing, cannot be recorded", async () => {
|
|
await withRepo(async ({ repoRoot, packageRoot }) => {
|
|
const stampPath = buildInputsStampPath(packageRoot);
|
|
|
|
recordCurrentBuild({ repoRoot, packageRoot });
|
|
assert.throws(
|
|
() => recordRunnerBuild({ repoRoot, packageRoot }),
|
|
new RegExp(
|
|
`no pending build inputs for build ${process.ppid} or ${process.pid} under .*paperclip-runner-build-inputs\\.pending: ` +
|
|
"the Runner build must start with `node scripts/record-build-inputs\\.mjs --begin`",
|
|
),
|
|
);
|
|
assert.equal(readBuildInputsStamp(stampPath), null, "the previous stamp outlived a build that did not begin");
|
|
|
|
// Another build's record is not this build's: a chain that did not begin
|
|
// cannot finish on the strength of one that is still running.
|
|
beginRunnerBuild({ repoRoot, packageRoot, buildId: "other-shell" });
|
|
assert.throws(() => recordRunnerBuild({ repoRoot, packageRoot }), /no pending build inputs for build /);
|
|
assert.equal(
|
|
readBuildInputsStamp(buildInputsPendingPath(packageRoot, "other-shell")).digest,
|
|
computeBuildInputsDigest(repoRoot).digest,
|
|
"a build that did not begin consumed another build's record",
|
|
);
|
|
await rm(buildInputsPendingPath(packageRoot, "other-shell"));
|
|
|
|
beginRunnerBuild({ repoRoot, packageRoot });
|
|
await rm(path.join(packageRoot, "dist", "testing.js"));
|
|
assert.throws(
|
|
() => recordRunnerBuild({ repoRoot, packageRoot }),
|
|
/the Runner build finished without producing dist\/testing\.js/,
|
|
);
|
|
assert.equal(readBuildInputsStamp(stampPath), null);
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).code, "no_stamp");
|
|
});
|
|
});
|
|
|
|
test("record-build-inputs.mjs brackets the build for the package it is pointed at", async () => {
|
|
await withRepo(async ({ repoRoot, packageRoot }) => {
|
|
const run = (...args) => execFileAsync(process.execPath, [recordScript, ...args, "--package-root", packageRoot]);
|
|
const expected = computeBuildInputsDigest(repoRoot);
|
|
const short = expected.digest.slice(0, 12);
|
|
|
|
// --finish before any --begin is a wiring error, not a silent stamp.
|
|
await assert.rejects(run("--finish"), /no pending build inputs/);
|
|
assert.equal(readBuildInputsStamp(buildInputsStampPath(packageRoot)), null);
|
|
|
|
const begin = await run("--begin");
|
|
assert.match(begin.stdout, new RegExp(`^${BUILD_INPUTS_SCHEMA} begin: digest=${short} files=${INPUT_FILE_COUNT} bytes=\\d+\n$`));
|
|
assert.equal(readBuildInputsStamp(buildInputsPendingPath(packageRoot, childBuildId)).digest, expected.digest);
|
|
|
|
const finish = await run("--finish");
|
|
assert.match(
|
|
finish.stdout,
|
|
new RegExp(`^${BUILD_INPUTS_SCHEMA} recorded: digest=${short} files=${INPUT_FILE_COUNT} bytes=\\d+ outputs=${OUTPUT_FILE_COUNT} stamp=node_modules/.cache/paperclip-runner-build-inputs.json\n$`),
|
|
);
|
|
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(stamp.outputCount, OUTPUT_FILE_COUNT);
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).current, true);
|
|
assert.equal((await stat(buildInputsStampPath(packageRoot))).isFile(), true);
|
|
|
|
// An edit between the two phases is reported and leaves nothing behind.
|
|
await run("--begin");
|
|
await writeFile(path.join(packageRoot, "src", "index.ts"), "export const one = 2;\n");
|
|
const moved = await run("--finish");
|
|
assert.match(moved.stdout, /^paperclip\.runner-build-inputs not recorded: inputs changed while the build ran /);
|
|
assert.equal(readBuildInputsStamp(buildInputsStampPath(packageRoot)), null);
|
|
|
|
await assert.rejects(run(), /one of --begin or --finish is required/);
|
|
await assert.rejects(run("--begin", "--finish"), /--finish cannot be combined with --begin/);
|
|
await assert.rejects(
|
|
execFileAsync(process.execPath, [recordScript, "--begin", "--bogus"]),
|
|
/unknown argument --bogus/,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("two builds running at once in one checkout each keep their own pending record", async () => {
|
|
await withRepo(async ({ repoRoot, packageRoot }) => {
|
|
const stampPath = buildInputsStampPath(packageRoot);
|
|
const pendingDirectory = buildInputsPendingDirectory(packageRoot);
|
|
const first = { repoRoot, packageRoot, buildId: "shell-1" };
|
|
const second = { repoRoot, packageRoot, buildId: "shell-2" };
|
|
const digestNow = () => computeBuildInputsDigest(repoRoot).digest;
|
|
|
|
// Nothing changes: both builds record, in either finishing order, and the
|
|
// second to finish does not fail for want of a record.
|
|
beginRunnerBuild(first);
|
|
beginRunnerBuild(second);
|
|
assert.deepEqual((await readdir(pendingDirectory)).sort(), ["shell-1.json", "shell-2.json"]);
|
|
assert.equal(recordRunnerBuild(first).recorded, true);
|
|
assert.deepEqual(await readdir(pendingDirectory), ["shell-2.json"], "the first finish consumed the wrong record");
|
|
assert.equal(readBuildInputsStamp(stampPath).digest, digestNow());
|
|
assert.equal(recordRunnerBuild(second).recorded, true);
|
|
assert.deepEqual(await readdir(pendingDirectory), []);
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).current, true);
|
|
|
|
beginRunnerBuild(first);
|
|
beginRunnerBuild(second);
|
|
assert.equal(recordRunnerBuild(second).recorded, true);
|
|
assert.equal(recordRunnerBuild(first).recorded, true);
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).current, true);
|
|
|
|
// An edit lands between the two starts. The build that began before it
|
|
// must not leave a stamp whichever of the two finishes last: a stamp means
|
|
// every build that wrote dist read the inputs it names.
|
|
const source = path.join(packageRoot, "src", "index.ts");
|
|
beginRunnerBuild(first);
|
|
await writeFile(source, "export const one = 2;\n");
|
|
beginRunnerBuild(second);
|
|
const staleFirst = recordRunnerBuild(first);
|
|
assert.equal(staleFirst.recorded, false);
|
|
assert.equal(staleFirst.code, "inputs_changed_during_build");
|
|
assert.equal(readBuildInputsStamp(stampPath), null);
|
|
assert.equal(recordRunnerBuild(second).recorded, true, "the build that read the edited inputs was not recorded");
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).current, true);
|
|
|
|
beginRunnerBuild(first);
|
|
await writeFile(source, "export const one = 3;\n");
|
|
beginRunnerBuild(second);
|
|
assert.equal(recordRunnerBuild(second).recorded, true);
|
|
assert.equal(readBuildInputsStamp(stampPath).digest, digestNow());
|
|
const staleLast = recordRunnerBuild(first);
|
|
assert.equal(staleLast.recorded, false);
|
|
assert.equal(staleLast.code, "inputs_changed_during_build");
|
|
assert.equal(readBuildInputsStamp(stampPath), null, "a build that began before the edit left the other build's stamp standing");
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).code, "no_stamp");
|
|
assert.deepEqual(await readdir(pendingDirectory), []);
|
|
});
|
|
});
|
|
|
|
test("a build's start drops the pending records of builds that never finished, and no others", async () => {
|
|
await withRepo(async ({ repoRoot, packageRoot }) => {
|
|
const pendingDirectory = buildInputsPendingDirectory(packageRoot);
|
|
const now = new Date("2026-10-11T22:00:00.000Z");
|
|
const age = async (buildId, ageMs) => {
|
|
const moment = new Date(now.getTime() - ageMs);
|
|
await utimes(buildInputsPendingPath(packageRoot, buildId), moment, moment);
|
|
};
|
|
|
|
// A build that failed a step yesterday, one that failed a minute ago, and
|
|
// one still running, all left records; a new build starts.
|
|
beginRunnerBuild({ repoRoot, packageRoot, buildId: "crashed-yesterday", now: () => now });
|
|
await age("crashed-yesterday", PENDING_RECORD_MAX_AGE_MS + 60_000);
|
|
beginRunnerBuild({ repoRoot, packageRoot, buildId: "crashed-just-now", now: () => now });
|
|
await age("crashed-just-now", 60_000);
|
|
beginRunnerBuild({ repoRoot, packageRoot, buildId: "still-running", now: () => now });
|
|
await writeFile(path.join(pendingDirectory, "notes.txt"), "not a record\n");
|
|
await age("still-running", 0);
|
|
|
|
beginRunnerBuild({ repoRoot, packageRoot, buildId: "new-build", now: () => now });
|
|
assert.deepEqual(
|
|
(await readdir(pendingDirectory)).sort(),
|
|
["crashed-just-now.json", "new-build.json", "notes.txt", "still-running.json"],
|
|
"pruning removed the wrong records",
|
|
);
|
|
|
|
// The running build and the new one still finish on their own records.
|
|
assert.equal(recordRunnerBuild({ repoRoot, packageRoot, buildId: "still-running" }).recorded, true);
|
|
assert.equal(recordRunnerBuild({ repoRoot, packageRoot, buildId: "new-build" }).recorded, true);
|
|
// A record that fell out of the directory is simply absent.
|
|
await rm(pendingDirectory, { recursive: true, force: true });
|
|
beginRunnerBuild({ repoRoot, packageRoot, buildId: "after-wipe" });
|
|
assert.deepEqual(await readdir(pendingDirectory), ["after-wipe.json"]);
|
|
});
|
|
});
|
|
|
|
test("two chains run by separate shells at the same time both record", { skip: process.platform === "win32" }, async () => {
|
|
await withRepo(async ({ repoRoot, packageRoot }) => {
|
|
const pendingDirectory = buildInputsPendingDirectory(packageRoot);
|
|
const marker = path.join(repoRoot, "release-the-chains");
|
|
const records = () => (existsSync(pendingDirectory) ? readdirSync(pendingDirectory).filter(name => name.endsWith(".json")).sort() : []);
|
|
|
|
const staged = path.join(pendingDirectory, "999999.json.999999.tmp");
|
|
const chains = [runChainInShell("sh", packageRoot, marker)];
|
|
let results;
|
|
try {
|
|
await waitFor(() => records().length === 1, "the first chain's record");
|
|
await writeFile(staged, "{");
|
|
chains.push(runChainInShell("sh", packageRoot, marker));
|
|
await waitFor(() => records().length === 2, "the second chain's record");
|
|
const [firstId, secondId] = records().map((name) => name.replace(/\.json$/, ""));
|
|
assert.notEqual(firstId, secondId);
|
|
for (const id of [firstId, secondId]) {
|
|
assert.match(id, /^\d+$/, "a chain's record is named after its shell's pid");
|
|
assert.equal(readBuildInputsStamp(buildInputsPendingPath(packageRoot, id)).buildId, id);
|
|
}
|
|
} finally {
|
|
await rm(staged, { force: true });
|
|
await writeFile(marker, "go\n");
|
|
results = await Promise.allSettled(chains);
|
|
}
|
|
|
|
for (const result of results) {
|
|
assert.equal(result.status, "fulfilled", result.reason?.message);
|
|
assert.match(result.value.stdout, new RegExp(`${BUILD_INPUTS_SCHEMA} begin: `));
|
|
assert.match(result.value.stdout, new RegExp(`${BUILD_INPUTS_SCHEMA} recorded: `), "a chain finished without recording");
|
|
}
|
|
assert.deepEqual(await readdir(pendingDirectory), []);
|
|
assert.equal(assessRunnerBuild({ repoRoot, packageRoot }).current, true);
|
|
|
|
// The same chain under bash, which may exec its last command: `--finish`
|
|
// is then the shell itself and still finds the record `--begin` wrote.
|
|
let bash;
|
|
try {
|
|
bash = (await execFileAsync("bash", ["-c", "echo ok"])).stdout.trim() === "ok";
|
|
} catch {
|
|
bash = false;
|
|
}
|
|
if (bash) {
|
|
await rm(marker);
|
|
const chain = runChainInShell("bash", packageRoot, marker);
|
|
await waitFor(() => records().length === 1, "the bash chain's record");
|
|
await writeFile(marker, "go\n");
|
|
assert.match((await chain).stdout, new RegExp(`${BUILD_INPUTS_SCHEMA} recorded: `));
|
|
assert.deepEqual(records(), []);
|
|
}
|
|
});
|
|
});
|