Files
PaperClipAI/scripts/ci/select-tests.mjs
T
DottaandPaperclip e40de0c9e8 perf(ci): refresh test weights and narrow graph dependencies (#18464)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Contributors need fast, reliable CI feedback.
> - Diff-aware CI selects test files and partitions them into runner
jobs.
> - Stale cost estimates put several slow suites on the same runner.
Broad imports also couple small tests to large modules.
> - This PR refreshes test costs, runs independent graph discovery in
parallel, and narrows three dependency boundaries.
> - Selection safeguards and CODEOWNER-only AWS routing stay in force.

## Linked Issues or Issue Description

Refs #18398. This follows the audit of [PR #18427's verification
run](https://github.com/paperclipai/paperclip/actions/runs/41169978784).

The audit found 403 selected general-server files without timing
weights. Three suites measured at 232s, 122s and 116s shared one shard
because each received a roughly 2s fallback weight. Local graph
discovery across four projects took 43.7s serially and 21.7s with two
workers. A reverse-order repeat took 37.7s serially and 22.6s with two
workers. Both comparisons produced identical selection results. This is
a local measurement, not a claim about production CI speed.

## What Changed

- Refresh general-server costs for 1,285 of 1,309 current files and
serialized costs for all 163 files from two successful protected-master
Cloud readiness runs. Retain prior costs for unmeasured files, including
dedicated chat and native-runner suites.
- Record passing server file costs, including collection, setup and
environment preparation. A weekly hosted workflow validates trusted
source runs and opens a timing-only PR. It tests shard coverage, pauses
while a refresh PR is open, and never merges automatically.
- Discover at most two project graphs concurrently in isolated,
heap-limited Node processes. Cache repeated inventory reads within one
selection. Worker errors and timeouts retain the full task plan.
- Fetch only the merge commit and parents for the selector. A real
shallow-clone regression proves the complete diff still includes more
than 300 files, renames and deletions.
- Share the identical log viewer and run details between both
AgentDetail pages. The viewer test imports the shared implementation and
runs each of its 13 distinct cases once.
- Extract the pure API error class while preserving the API client's
public exports. Import delivery work signals through their existing
narrow DB export.

## Verification

- PASS: `pnpm -r typecheck` and `pnpm build`.
- PASS: 284 CI selection, timing, shallow-diff, sharding and routing
tests.
- PASS: 74 further cache, routing and graph-proof tests.
- PASS: 94 targeted UI assertions across the viewer, run invocation, API
errors/client and progress helpers. All 76 assertions in the 12 server
suites made independently selectable also pass.
- PASS: `pnpm check:token-gates`, module-boundary checks, YAML parsing
and `git diff --check`.
- PASS: [Full PR CI and required `ci / verify`
aggregate](https://github.com/paperclipai/paperclip/actions/runs/41206683345),
including all 12 general-server shards, all nine serialized shards,
workspace groups, chat suites, Runner lanes and build checks.
- PASS: [GitHub hosted real-workspace selector and cache/routing
qualification](https://github.com/paperclipai/paperclip/actions/runs/41206683205).
- PASS: Both Greptile and Paperclip reviews are 5/5 with no actionable
findings or inline comments on `7dc65c51b8`. The PR has no merge
conflicts.
- PASS: The production refresh parser accepted all 107 structured
file-cost records from a real successful server CI job, including
setup/loading cost.
- Local `pnpm test:run` completed all six chat shards successfully. The
duplicate monolithic local run was stopped after the complete remote
suite passed; it was not completed locally.

The focused log-viewer command improved from 18.84s (26 duplicate
variant cases) to 8.49s (all 13 distinct behavior cases). The dependency
audit confirms 12 server files and the API-error test no longer need
conservative always-run retention. Repartitioning the audited PR with
the new costs changes the slowest shard from 810s to 591s of summed
historical test execution (1,124 of 1,142 files measured). This is a
workload simulation, not measured new CI wall time.

## Risks

- Two graph processes use more peak memory. Each has a 2 GiB heap limit
and a three-minute timeout. Any failure preserves the original task
plan. Real workspace qualification runs in GitHub CI.
- Historical logs contain test/hook costs only. New reporter records add
loading/setup costs after merge. The manifests identify measurement
coverage and source runs. These weights change placement, never
permission to skip tests.
- Runner load adds timing noise. The refresh uses medians and requires
at least 90% measurement coverage in each lane.
- GitHub may require approval to run checks on a PR created with
`GITHUB_TOKEN`. Refreshes remain reviewable and do not merge themselves.
- The shared viewer is a mechanical extraction. Both pages retain their
existing exports, and the behavior assertions remain covered. AWS runner
authorization is unchanged.

## Model Used

OpenAI Codex, GPT-6, with repository inspection, reasoning, code
execution and GitHub tools. The exact deployment ID and context-window
size were 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 references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused checks and six
chat shards; complete suite passed in CI as detailed above)
- [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
- [x] 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>
2026-10-31 11:05:12 -05:00

131 lines
7.0 KiB
JavaScript

import { execFileSync } from "node:child_process";
import { appendFileSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import assert from "node:assert/strict";
import { refineTestPlan, selectionDurations } from "./test-selection-plan.mjs";
import { productInputs } from "../../.github/scripts/ci-inputs.mjs";
import { mergeDiff } from "../../.github/scripts/ci-diff.mjs";
import { selectProjects } from "./select-projects.mjs";
export function selectionEligibility(plan) {
if (plan?.version !== 1 || plan.mode !== "enabled" || plan.forceReason) return "Full/shadow/forced task plan";
if (!Array.isArray(plan.changed) || !plan.changed.length) return "Incomplete comparison";
const changed = productInputs(plan.changed);
if (!changed.length) return "Documentation-only diff";
for (const file of changed) {
// Business modules can contain "config"/"setup" in their names. The graph
// separately tracks actual setup and configuration dependencies.
if (!/^(?:ui\/src|server\/src|cli\/src|packages\/.+\/src)\/.+\.[cm]?[jt]sx?$/.test(file)
|| /(?:^|\/)(?:vite|vitest)\.(?:config|setup)\.[cm]?[jt]sx?$|\.d\.[cm]?ts$/i.test(file)
|| file.split("/").includes("..")) return `Broad input: ${file}`;
try {
if (!lstatSync(file).isFile()) return `Non-regular source: ${file}`;
} catch { return `Deleted or missing source: ${file}`; }
}
return null;
}
function runnerInventory(group) {
const args = group === "serialized" ? ["--mode", "serialized", "--shard-index", "0", "--shard-count", "1"]
: ["--mode", "general", "--group", group,
...(group === "general-server-without-chat" ? ["--shard-index", "0", "--shard-count", "1"] : [])];
const env = { ...process.env, GITHUB_WORKFLOW: "PR" };
delete env.PAPERCLIP_CI_TEST_SELECTION;
return JSON.parse(execFileSync(process.execPath, ["scripts/run-vitest-stable.mjs", ...args, "--dry-run"],
{ env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }));
}
export async function createTestSelection(plan, head) {
const reason = selectionEligibility(plan);
if (reason) throw new Error(reason);
const groups = new Set();
for (const job of ["general_tests", "verify_serialized_server"]) {
if (plan.jobs[job].run) for (const entry of plan.jobs[job].matrix.include) groups.add(entry.group ?? "serialized");
}
groups.delete("general-chat"); // Keep its line sharding and DB ownership intact.
const owners = {}, projects = new Set(), inventories = new Map();
const inventoryFor = group => {
if (!inventories.has(group)) inventories.set(group, runnerInventory(group));
return inventories.get(group);
};
for (const group of groups) {
const inventory = inventoryFor(group);
if (group === "serialized" || group === "general-server-without-chat") {
owners[group] = { "@paperclipai/server": group === "serialized"
? inventory.selectedSerializedSuites : inventory.selectedGeneralServerSuites };
projects.add("@paperclipai/server");
} else {
owners[group] = Object.fromEntries(inventory.workspaceProjects.map((project) => [project, null]));
for (const project of inventory.workspaceProjects) projects.add(project);
}
}
const changed = productInputs(plan.changed);
const graphs = await selectProjects({ projects: [...projects], changed });
for (const graph of graphs) {
const { project } = graph;
console.log(`${project}: ${graph.selected.length}/${graph.inventory.length} files, ${graph.alwaysRun.length} retained conservatively, ${graph.seconds.toFixed(1)}s`);
for (const owner of Object.values(owners)) if (owner[project] === null) owner[project] = graph.inventory;
}
const covered = new Set(graphs.flatMap((graph) => graph.coveredChanges));
for (const file of changed) assert.ok(covered.has(file), `Change outside the resolved test graphs: ${file}`);
const server = graphs.find((graph) => graph.project === "@paperclipai/server");
if (server) {
const general = inventoryFor("general-server-without-chat");
const native = inventoryFor("general-server-native-runner");
assert.deepEqual([...general.selectedGeneralServerSuites, ...general.selectedSerializedSuites,
...native.selectedGeneralServerSuites, "server/src/__tests__/chat-channels.integration.test.ts"].sort(),
server.inventory, "Server inventory must be completely covered by existing lane ownership");
}
return { ...refineTestPlan({ plan, head, owners, graphs, durations: selectionDurations() }), graphs };
}
async function main() {
const plan = JSON.parse(process.env.CI_PLAN || "null");
const output = (key, value) => {
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
};
if (process.argv.includes("--eligible")) {
const reason = selectionEligibility(plan);
console.log(reason ?? "Source diff is eligible for graph selection");
output("enabled", !reason);
return;
}
const directory = ".ci-test-selection";
mkdirSync(directory, { recursive: true });
const git = (...args) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).trim();
let result;
try {
const head = git("rev-parse", "HEAD");
assert.equal(head, process.env.GITHUB_SHA, "Selection must describe the tested merge commit");
assert.ok(plan.diff, "Missing trusted merge diff");
const local = mergeDiff(head, plan.diff.prHead);
assert.deepEqual(local.diff, plan.diff, "Selection must use the trusted merge parents");
assert.deepEqual(local.changed, [...new Set(plan.changed)].sort(), "Local diff disagrees with the trusted routing diff");
assert.equal(git("status", "--porcelain", "--untracked-files=normal"), "", "Source inputs changed during dependency setup");
process.env.NODE_ENV = "test";
process.env.VITEST = "true";
result = await createTestSelection(plan, head);
writeFileSync(`${directory}/manifest.json`, JSON.stringify(result.manifest));
writeFileSync(`${directory}/audit.json`, JSON.stringify(result.graphs, null, 2));
output("selected", "true");
} catch (error) {
console.warn(`Using the complete task plan: ${error.message}`);
result = { plan: { ...plan, testSelectionFallback: error.message } };
output("selected", "false");
}
writeFileSync(`${directory}/plan.json`, JSON.stringify(result.plan));
if (process.env.GITHUB_STEP_SUMMARY) {
const stats = result.plan.testSelection?.stats;
appendFileSync(process.env.GITHUB_STEP_SUMMARY, stats
? ["### Vitest test selection", "", "| Lane | Test files | Jobs |", "| --- | ---: | ---: |",
...stats.map((s) => `| ${s.group} | ${s.selected}/${s.total} | ${s.jobs}/${s.beforeJobs} |`), "",
"Filesystem/process/opaque consumers are retained. Chat and native Runner lanes keep their existing ownership.", ""].join("\n")
: `### Vitest test selection\n\nFull task plan: ${result.plan.testSelectionFallback}\n`);
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => { console.error(error); process.exitCode = 1; });
}