mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its CI must catch regressions without running unrelated work on every pull request. > - The existing task planner selects package lanes, but each selected lane still runs all its test files. > - Vitest can find related imports, but that graph misses tests that read files or launch processes. > - This pull request combines the real Vitest graph with conservative dependency checks before CI allocates test runners. > - The result runs fewer test files and can allocate fewer jobs while retaining tests with unknown dependencies. ## Linked Issues or Issue Description Refs #18214. The repository and PR search found no duplicate file-selection implementation. **What existing behavior does this improve?** The general and serialized Vitest lanes in pull request CI. **Current behavior** An affected package schedules its complete test lane and its fixed shard count. **Proposed behavior** For source-only diffs, select related tests plus conservative filesystem, process, network and opaque consumers. Repartition those files within their existing lane owners. Keep the complete original plan when selection cannot be trusted. **Reason and benefit** Avoid unrelated test execution and allocate fewer runners when the selected workload fits fewer existing job budgets. Report file counts and job counts separately. **Breaking changes** No product or API changes. The set of PR test files and jobs changes for eligible diffs. Full CI and the kill switch retain the original coverage. ## What Changed - Add a separate selection job on the gate-approved test fleet. The metadata gate does not load PR code. - Inspect real Vitest/Vite dependency edges. Retain opaque consumers and unchanged downstream projects. Respect only complete local mock factories. Reject unknown setup dependencies and source mutations during dependency setup. - Declare `oxc-parser` directly for AST inspection. This exact version is already a transitive dependency in the checked-in lockfile; the direct declaration avoids relying on pnpm's internal dependency layout. - Produce a merge-SHA-bound manifest with exact lane ownership and duration-aware partitions. Preserve serialized execution, chat shards and native Runner ownership. - Execute exact Vitest test specifications. Positional substring filters cannot accidentally run a similarly named file from another shard. - Use the original matrix if selection fails or times out. Fail selected jobs if their published artifact is missing or invalid. - Save the uploaded artifact ID in the executable plan. Failed-job retries reuse that artifact even when GitHub increments the attempt number. - Hash selected assignments into Turbo cache inputs. A subset cannot satisfy a full run or another subset. - Add graph, workflow, real-runner and real-Turbo qualification. The regression fixture proves a filesystem contract fails even when raw Vitest selection misses it. - Document selection limits, audit artifacts and `CI_VITEST_SELECTION=off`. ## Verification - All 138 affected routing, sharding, workflow, manifest, real Vitest and real Turbo checks passed under Node 24 on the final implementation. Additional release workflow checks passed. Cases cover opaque setup, remocking, generated inputs, Node's builtin loader, artifact retries, and exact execution of similarly named files. - Repository-wide `pnpm -r typecheck` and `pnpm build` passed before the clean rebase onto current master. The full remote typecheck and build are required on the current head. - `actionlint` passed for both changed workflows. `git diff --check` passed. - The Linux qualification run passed and selected 699 of 911 workspace test files for an Inbox change. UI graph construction took 16.1 seconds. This still uses two jobs. Synthetic partition coverage proves a 15-of-120 selection uses two of twelve jobs without dropping or duplicating files. - Both selected workspace shards passed locally. After tightening execution to exact specifications, the real colliding UI test ran as exactly one file with two passing assertions; the real server auth middleware test ran as exactly one file with 28 passing assertions. - The server probe selected 1,158 of 1,260 general files and 161 of 163 serialized files for an auth middleware change. Those conservative selections retain 12 general and nine serialized jobs. The server's temporary-home setup exception is pinned to reviewed content hashes; changed setup fails closed. - Full `pnpm test:run` has not been run locally. The full PR test matrix is required because this change modifies global CI inputs. Final-head CI is pending at runner allocation, and both qualification jobs are queued. Other recent repository workflows are also queued. Superseded runs of this PR were cancelled; this PR remains in draft until the required checks execute. - Greptile reviewed final commit `31b484fc5b412da245cffb52a8ac8977228c5651` at 5/5 with no unresolved findings. The earlier qualification run passed; it does not replace checks on the final commit. ## Risks - Dependency analysis adds setup and graph time. Fewer files do not guarantee a shorter critical path. CI summaries expose the actual file and job counts. - Static dependency graphs cannot describe all runtime inputs. Unknown inputs retain tests or use the original plan. The pure-package allowlist requires conservative review. - The trusted workflow runs from master. This PR exercises the new selector in the qualification workflow; ordinary PR selection starts after merge. Set `CI_VITEST_SELECTION=off`, use `full-ci`, or select full/shadow mode to disable narrowing. - The lockfile remains managed by the existing repository actions and is not changed here. ## Model Used OpenAI Codex, based on GPT-6, with repository tools and code execution. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
175 lines
12 KiB
JavaScript
175 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { chmodSync, copyFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
|
|
import { executionPlan, sourceFiles } from "./execution-plan.mjs";
|
|
|
|
const repositoryRoot = new URL("../../", import.meta.url);
|
|
const turbo = process.env.PAPERCLIP_CI_TEST_TURBO ?? new URL("node_modules/.bin/turbo", repositoryRoot).pathname;
|
|
|
|
test("real Turbo keeps full runs and different selected test sets in separate cache entries", () => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-selection-cache-"));
|
|
const write = (file, value) => writeFileSync(path.join(directory, file), value);
|
|
const json = (file, value) => write(file, JSON.stringify(value));
|
|
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim();
|
|
try {
|
|
git("init", "-q");
|
|
for (const folder of ["scripts/ci", "server/src", "packages/fixture", "bin", ".ci-test-selection"]) mkdirSync(path.join(directory, folder), { recursive: true });
|
|
for (const file of ["run-task.mjs", "execution-plan.mjs", "test-selection-manifest.mjs"]) {
|
|
copyFileSync(new URL(`scripts/ci/${file}`, repositoryRoot), path.join(directory, "scripts/ci", file));
|
|
}
|
|
write(".gitignore", ".turbo/\n.ci-test-selection/\nresult.txt\ncalls.txt\n");
|
|
json("package.json", { name: "selection-cache-proof", private: true, packageManager: "npm@10.0.0", workspaces: ["packages/*"], scripts: { "test:run:general": "node check.cjs" } });
|
|
json("packages/fixture/package.json", { name: "fixture", private: true });
|
|
json("package-lock.json", { name: "selection-cache-proof", lockfileVersion: 3, packages: { "": { name: "selection-cache-proof" } } });
|
|
json("turbo.json", { globalDependencies: [], globalEnv: ["CI_TASK_CACHE_SCOPE", "PAPERCLIP_*"], tasks: {
|
|
"//#ci:server": { inputs: ["check.cjs", "server/**"], outputs: ["result.txt"], cache: true },
|
|
} });
|
|
write("server/src/a.test.ts", "// a"); write("server/src/b.test.ts", "// b");
|
|
write("check.cjs", `const fs=require('node:fs');fs.appendFileSync('calls.txt','call\\n');const file=process.env.PAPERCLIP_CI_TEST_SELECTION;fs.writeFileSync('result.txt',file?JSON.parse(fs.readFileSync(file)).lanes['general-server-without-chat'].shards[0]['@paperclipai/server'].join(','):'full');`);
|
|
write("bin/pnpm", `#!${process.execPath}\nconst {spawnSync}=require('node:child_process');const args=process.argv.slice(2);if(args[0]==='--version')console.log('9.15.4');else{const r=spawnSync(${JSON.stringify(turbo)},args.slice(2),{stdio:'inherit'});process.exit(r.status??1);}`);
|
|
chmodSync(path.join(directory, "bin/pnpm"), 0o755);
|
|
git("add", ".");
|
|
git("-c", "user.name=CI", "-c", "user.email=ci@example.invalid", "commit", "-qm", "fixture");
|
|
const head = git("rev-parse", "HEAD");
|
|
const calls = () => readFileSync(path.join(directory, "calls.txt"), "utf8").trim().split("\n").length;
|
|
const run = (testFile) => {
|
|
const selection = path.join(directory, ".ci-test-selection/manifest.json");
|
|
json(".ci-test-selection/manifest.json", { version: 1, head, lanes: {
|
|
"general-server-without-chat": { shards: [{ "@paperclipai/server": [testFile ?? "server/src/a.test.ts"] }] },
|
|
} });
|
|
const result = spawnSync(process.execPath, [path.join(directory, "scripts/ci/run-task.mjs")], {
|
|
cwd: directory, encoding: "utf8", env: { ...process.env, PATH: `${directory}/bin:${process.env.PATH}`,
|
|
CI_TASK: "ci:server", CI_TASK_ARGS: "[]", CI_FORCE_TASKS: "false", CI_TASK_DRY_RUN: "false",
|
|
PAPERCLIP_CI_TEST_SELECTION: testFile ? selection : "", PAPERCLIP_CI_SELECTION_HASH: "",
|
|
TURBO_TOKEN: "", TURBO_TEAM: "", TURBO_TELEMETRY_DISABLED: "1" },
|
|
});
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
assert.equal(readFileSync(path.join(directory, "result.txt"), "utf8"), testFile ?? "full");
|
|
};
|
|
run(); run(); assert.equal(calls(), 1);
|
|
run("server/src/a.test.ts"); run("server/src/a.test.ts"); assert.equal(calls(), 2);
|
|
run("server/src/b.test.ts"); assert.equal(calls(), 3);
|
|
run(); assert.equal(calls(), 3, "the original full result is reusable without accepting a subset result");
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("every cached lane maps to an existing package script with complete inputs", () => {
|
|
const config = JSON.parse(readFileSync(new URL("turbo.json", repositoryRoot), "utf8"));
|
|
for (const [name, definition] of Object.entries(config.tasks).filter(([, task]) => task.cache)) {
|
|
const plan = executionPlan(config, name.slice(3), ["--shard-index", "0"]);
|
|
const rootTask = plan.target.startsWith("//#");
|
|
const manifest = JSON.parse(readFileSync(new URL(rootTask ? "package.json"
|
|
: "packages/paperclip-runner/package.json", repositoryRoot), "utf8"));
|
|
const script = plan.target.split("#")[1];
|
|
assert.equal(typeof manifest.scripts[script], "string", plan.target);
|
|
assert.ok(plan.config.tasks[plan.target].inputs.length > 0);
|
|
for (const input of plan.config.tasks[plan.target].inputs) {
|
|
const file = rootTask ? input : input.slice("$TURBO_ROOT$/".length);
|
|
assert.ok(definition.inputs.some((glob) => path.matchesGlob(file, glob)), input);
|
|
assert.doesNotMatch(file, /\/(?:node_modules|target|dist)\//);
|
|
}
|
|
assert.ok(plan.args.includes("--shard-index"));
|
|
}
|
|
});
|
|
|
|
test("real Turbo executes existing suite scripts and invalidates source, arguments, environment and failed tasks", () => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-turbo-"));
|
|
const write = (name, content) => writeFileSync(path.join(directory, name), content);
|
|
const json = (name, value) => write(name, JSON.stringify(value));
|
|
try {
|
|
execFileSync("git", ["init", "-q"], { cwd: directory });
|
|
write(".gitignore", ".turbo/\ngenerated/\nresult.txt\ncalls.txt\n");
|
|
mkdirSync(path.join(directory, "packages/fixture"), { recursive: true });
|
|
json("packages/fixture/package.json", { name: "fixture", private: true });
|
|
json("package.json", { name: "cache-proof", private: true, packageManager: "npm@10.0.0", workspaces: ["packages/*"],
|
|
scripts: { "test:run:general": "node check.cjs" } });
|
|
json("package-lock.json", { name: "cache-proof", lockfileVersion: 3, packages: { "": { name: "cache-proof" } } });
|
|
const definition = { globalEnv: ["CI_TASK_CACHE_SCOPE"], tasks: {
|
|
"//#ci:server": { inputs: ["*.txt", "check.cjs"], outputs: ["result.txt"], env: ["FLAVOR"], cache: true },
|
|
} };
|
|
const execution = () => executionPlan(definition, "ci:server", ["--shard-index", "0", "--shard-count", "12"], sourceFiles(directory));
|
|
write("input[one].txt", "first");
|
|
write("check.cjs", `const fs = require('node:fs'); fs.appendFileSync('calls.txt','call\\n'); const input=fs.readFileSync('input[one].txt','utf8'); if(input==='fail') process.exit(1); if(!process.argv.includes('general-server-without-chat')) process.exit(2); fs.writeFileSync('result.txt',input+process.env.FLAVOR);`);
|
|
const run = (extra = {}, args = execution().args) => {
|
|
const plan = execution();
|
|
json("turbo.json", plan.config);
|
|
return spawnSync(turbo, ["run", plan.target, "--cache=local:rw", "--cache-dir=.turbo/cache", "--", ...args], {
|
|
cwd: directory, encoding: "utf8", env: { ...process.env, TURBO_TELEMETRY_DISABLED: "1", FLAVOR: "a", CI_TASK_CACHE_SCOPE: "node-24", ...extra },
|
|
});
|
|
};
|
|
const calls = () => readFileSync(path.join(directory, "calls.txt"), "utf8").trim().split("\n").length;
|
|
const pass = (extra) => { const r = run(extra); assert.equal(r.status, 0, r.stdout + r.stderr); return r; };
|
|
pass(); assert.equal(calls(), 1);
|
|
rmSync(path.join(directory, "result.txt"));
|
|
pass(); assert.equal(calls(), 1, "cached task must not execute");
|
|
assert.equal(readFileSync(path.join(directory, "result.txt"), "utf8"), "firsta");
|
|
write("unrelated.md", "another push");
|
|
pass(); assert.equal(calls(), 1, "unrelated input must retain the result");
|
|
write("input[one].txt", "second");
|
|
pass(); assert.equal(calls(), 2, "source must invalidate checks");
|
|
pass({ FLAVOR: "b" }); assert.equal(calls(), 3);
|
|
pass({ FLAVOR: "b", CI_TASK_CACHE_SCOPE: "node-25" }); assert.equal(calls(), 4);
|
|
assert.equal(run({}, [...execution().args, "--another-argument"]).status, 0);
|
|
assert.equal(calls(), 5, "different shard arguments must not share a task result");
|
|
write("input[one].txt", "fail");
|
|
assert.notEqual(run().status, 0); assert.notEqual(run().status, 0);
|
|
assert.equal(calls(), 7, "a failed task must execute on every attempt");
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("the task launcher bypasses cache reads and writes when execution is forced", () => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-turbo-forced-"));
|
|
const write = (name, value) => writeFileSync(path.join(directory, name), value);
|
|
const json = (name, value) => write(name, JSON.stringify(value));
|
|
try {
|
|
execFileSync("git", ["init", "-q"], { cwd: directory });
|
|
mkdirSync(path.join(directory, "scripts/ci"), { recursive: true });
|
|
mkdirSync(path.join(directory, "bin"));
|
|
mkdirSync(path.join(directory, "packages/fixture"), { recursive: true });
|
|
for (const name of ["run-task.mjs", "execution-plan.mjs", "test-selection-manifest.mjs"]) {
|
|
copyFileSync(new URL(`scripts/ci/${name}`, repositoryRoot), path.join(directory, "scripts/ci", name));
|
|
}
|
|
write(".gitignore", ".turbo/\ncalls.txt\nresult.txt\nbin/\n");
|
|
json("packages/fixture/package.json", { name: "fixture", private: true });
|
|
json("package.json", { name: "forced-cache-proof", private: true, packageManager: "npm@10.0.0",
|
|
workspaces: ["packages/*"], scripts: { "test:run:general": "node check.cjs" } });
|
|
json("package-lock.json", { name: "forced-cache-proof", lockfileVersion: 3, packages: { "": { name: "forced-cache-proof" } } });
|
|
json("turbo.json", { globalDependencies: [], globalEnv: ["CI_TASK_CACHE_SCOPE"], tasks: {
|
|
"//#ci:server": { inputs: ["input.txt", "check.cjs"], outputs: ["result.txt"], cache: true },
|
|
} });
|
|
write("input.txt", "result");
|
|
write("check.cjs", "const fs=require('node:fs');fs.appendFileSync('calls.txt','call\\n');fs.writeFileSync('result.txt',fs.readFileSync('input.txt'));");
|
|
write("bin/pnpm", `#!${process.execPath}\nconst {spawnSync}=require('node:child_process');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('9.15.4');}else{if(args[0]!=='exec'||args[1]!=='turbo')process.exit(2);const result=spawnSync(${JSON.stringify(turbo)},args.slice(2),{stdio:'inherit'});process.exit(result.status??1);}`);
|
|
chmodSync(path.join(directory, "bin/pnpm"), 0o755);
|
|
const run = force => {
|
|
const result = spawnSync(process.execPath, [path.join(directory, "scripts/ci/run-task.mjs")], {
|
|
cwd: directory, encoding: "utf8", env: { ...process.env, PATH: `${path.join(directory, "bin")}${path.delimiter}${process.env.PATH}`,
|
|
CI_TASK: "ci:server", CI_TASK_ARGS: "[]", CI_TASK_DRY_RUN: "false", CI_FORCE_TASKS: String(force),
|
|
TURBO_TOKEN: "", TURBO_TEAM: "", TURBO_TELEMETRY_DISABLED: "1" },
|
|
});
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
};
|
|
const calls = () => readFileSync(path.join(directory, "calls.txt"), "utf8").trim().split("\n").length;
|
|
const cacheFiles = () => existsSync(path.join(directory, ".turbo/cache"))
|
|
? readdirSync(path.join(directory, ".turbo/cache"), { recursive: true }).sort() : [];
|
|
run(true); run(true);
|
|
assert.equal(calls(), 2, "forced runs must execute even when a previous result exists");
|
|
assert.deepEqual(cacheFiles(), [], "forced runs must not populate the task cache");
|
|
run(false); run(false);
|
|
assert.equal(calls(), 3, "ordinary runs still reuse local results");
|
|
const retainedCache = cacheFiles();
|
|
assert.ok(retainedCache.length > 0);
|
|
run(true);
|
|
assert.equal(calls(), 4, "forced runs must bypass an existing cache hit");
|
|
assert.deepEqual(cacheFiles(), retainedCache);
|
|
symlinkSync("input.txt", path.join(directory, "unclassified-link"));
|
|
run(false);
|
|
assert.equal(calls(), 5, "unclassified non-regular input must force execution");
|
|
assert.deepEqual(cacheFiles(), retainedCache);
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|