mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## 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>
144 lines
7.9 KiB
JavaScript
144 lines
7.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile, execFileSync } from "node:child_process";
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify, stripVTControlCharacters } from "node:util";
|
|
import { timingPrefix } from "./test-timing-reporter.mjs";
|
|
|
|
const repository = "paperclipai/paperclip";
|
|
const api = `/repos/${repository}`;
|
|
const execute = promisify(execFile);
|
|
const github = async (endpoint, json = true) => {
|
|
const { stdout } = await execute("gh", ["api", endpoint], {
|
|
encoding: "utf8", maxBuffer: json ? 4 * 1024 * 1024 : 128 * 1024 * 1024, timeout: 120_000,
|
|
});
|
|
return json ? JSON.parse(stdout) : stdout;
|
|
};
|
|
|
|
export function validateRun(run) {
|
|
assert.equal(run.repository?.full_name, repository);
|
|
assert.equal(run.repository?.id, 1170821064);
|
|
assert.equal(run.head_repository?.id, 1170821064);
|
|
assert.equal(run.head_branch, "master");
|
|
assert.equal(run.event, "push");
|
|
assert.equal(run.path, ".github/workflows/cloud-readiness.yml");
|
|
assert.equal(run.status, "completed");
|
|
assert.equal(run.conclusion, "success");
|
|
assert.match(run.head_sha, /^[a-f0-9]{40}$/);
|
|
assert.ok(Number.isSafeInteger(run.id) && run.id > 0);
|
|
assert.ok(Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0);
|
|
}
|
|
|
|
export function parseTimings(log, inventory) {
|
|
const fallback = new Map(), measured = new Map();
|
|
for (const raw of log.split("\n")) {
|
|
const line = stripVTControlCharacters(raw);
|
|
const offset = line.indexOf(timingPrefix);
|
|
if (offset >= 0) {
|
|
const record = JSON.parse(line.slice(offset + timingPrefix.length));
|
|
assert.equal(record.version, 1);
|
|
assert.equal(typeof record.file, "string");
|
|
assert.match(record.file, /^server\/(src|scripts)\/.+\.test\.[cm]?[jt]s$/);
|
|
assert.ok(!record.file.split("/").some(part => part === ".." || part === "." || part === ""));
|
|
assert.ok(!record.file.includes("\\"));
|
|
assert.ok(Number.isFinite(record.durationMs) && record.durationMs >= 0 && record.durationMs <= 3_600_000);
|
|
// A trusted source run can predate a deletion or a lane reassignment.
|
|
if (!inventory.has(record.file)) continue;
|
|
measured.set(record.file, Math.max(measured.get(record.file) ?? 0, record.durationMs));
|
|
continue;
|
|
}
|
|
// Bootstrap from pre-reporter runs. These values omit import/setup cost;
|
|
// structured measurements take precedence as soon as master emits them.
|
|
const match = line.match(/@paperclipai\/server\s+([^\s]+\.test\.[cm]?[jt]s)\s+\(\d+ tests?[^)]*\)\s+(\d+)ms\s*$/);
|
|
if (!match) continue;
|
|
const file = `server/${match[1]}`, ms = Number(match[2]);
|
|
if (inventory.has(file) && ms <= 3_600_000) fallback.set(file, Math.max(fallback.get(file) ?? 0, ms));
|
|
}
|
|
return new Map([...fallback].map(([file, ms]) => [file, { ms, complete: false }])
|
|
.concat([...measured].map(([file, ms]) => [file, { ms, complete: true }])));
|
|
}
|
|
|
|
export function updatedDurations(previous, inventory, samples, sources) {
|
|
const measured = [...inventory].filter(file => samples.some(sample => sample.has(file)));
|
|
assert.ok(measured.length >= inventory.size * 0.9, `Incomplete timing coverage: ${measured.length}/${inventory.size}`);
|
|
const durations = {};
|
|
let complete = 0;
|
|
for (const file of [...inventory].sort()) {
|
|
const values = samples.flatMap(sample => sample.has(file) ? [sample.get(file)] : []);
|
|
const full = values.filter(value => value.complete);
|
|
if (full.length) complete++;
|
|
const ms = (full.length ? full : values).map(value => value.ms).sort((a, b) => a - b);
|
|
if (!ms.length) {
|
|
if (Number.isFinite(previous.durations[file]) && previous.durations[file] >= 0) durations[file] = previous.durations[file];
|
|
continue;
|
|
}
|
|
const middle = Math.floor(ms.length / 2);
|
|
// A small floor prevents clock resolution from giving a suite zero cost.
|
|
durations[file] = Math.max(100, Math.ceil(ms.length % 2 ? ms[middle] : (ms[middle - 1] + ms[middle]) / 2));
|
|
}
|
|
return {
|
|
$comment: "Generated by node scripts/ci/refresh-test-durations.mjs from successful protected-master Cloud readiness runs. Median per-file cost includes tests, hooks, collection, setup and environment preparation when the timing reporter is present; older logs bootstrap test/hook durations only. Missing files retain their prior weight or the partitioner's median fallback. Timing changes only partition work; they never authorize skipping a test.",
|
|
unit: "ms", sources, coverage: { measured: measured.length, withSetup: complete, inventory: inventory.size }, durations,
|
|
};
|
|
}
|
|
|
|
export async function refreshDurations(runIds = []) {
|
|
const inventory = JSON.parse(execFileSync(process.execPath, ["scripts/run-vitest-stable.mjs", "--mode", "general",
|
|
"--group", "general-server", "--shard-index", "0", "--shard-count", "1", "--dry-run"], {
|
|
encoding: "utf8", env: { ...process.env, GITHUB_WORKFLOW: "PR", PAPERCLIP_CI_TEST_SELECTION: "" },
|
|
}));
|
|
const lanes = {
|
|
general: { files: new Set(inventory.selectedGeneralServerSuites), path: "scripts/general-server-shard-durations.json", samples: [] },
|
|
serialized: { files: new Set(inventory.selectedSerializedSuites), path: "scripts/serialized-shard-durations.json", samples: [] },
|
|
};
|
|
if (!runIds.length) {
|
|
const listed = await github(`${api}/actions/workflows/cloud-readiness.yml/runs?branch=master&event=push&status=success&per_page=3`);
|
|
runIds = listed.workflow_runs.map(run => run.id).slice(0, 2);
|
|
}
|
|
assert.ok(runIds.length > 0 && runIds.length <= 3, "Expected one to three successful master runs");
|
|
const sources = [];
|
|
for (const id of runIds) {
|
|
assert.match(String(id), /^\d+$/);
|
|
const run = await github(`${api}/actions/runs/${id}`);
|
|
validateRun(run);
|
|
execFileSync("git", ["merge-base", "--is-ancestor", run.head_sha, "HEAD"]);
|
|
const jobs = [];
|
|
for (let page = 1; ; page++) {
|
|
const result = await github(`${api}/actions/runs/${id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`);
|
|
jobs.push(...result.jobs);
|
|
if (result.jobs.length < 100) break;
|
|
}
|
|
const samples = { general: new Map(), serialized: new Map() };
|
|
const selected = jobs.map(job => ({ job, lane: /General tests \(server \(\d+\/\d+\)\)$/.test(job.name) ? "general"
|
|
: /Serialized tests \(\d+\/\d+\)$/.test(job.name) ? "serialized" : null })).filter(item => item.lane);
|
|
assert.ok(selected.length, "No server timing jobs found");
|
|
// Keep API traffic and log buffers bounded. Logs are parsed data, never code.
|
|
for (let start = 0; start < selected.length; start += 3) {
|
|
await Promise.all(selected.slice(start, start + 3).map(async ({ job, lane }) => {
|
|
assert.equal(job.run_id, run.id);
|
|
assert.equal(job.conclusion, "success");
|
|
const parsed = parseTimings(await github(`${api}/actions/jobs/${job.id}/logs`, false), lanes[lane].files);
|
|
for (const [file, value] of parsed) {
|
|
const previous = samples[lane].get(file);
|
|
if (!previous || (value.complete && !previous.complete)
|
|
|| (value.complete === previous.complete && value.ms > previous.ms)) samples[lane].set(file, value);
|
|
}
|
|
}));
|
|
}
|
|
for (const lane of Object.keys(lanes)) lanes[lane].samples.push(samples[lane]);
|
|
sources.push({ run: run.id, attempt: run.run_attempt, sha: run.head_sha, url: run.html_url });
|
|
}
|
|
// Validate both lanes before replacing either checked-in manifest.
|
|
const updates = Object.values(lanes).map(lane => [lane.path,
|
|
updatedDurations(JSON.parse(readFileSync(lane.path, "utf8")), lane.files, lane.samples, sources)]);
|
|
for (const [file, data] of updates) {
|
|
writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
console.log(`${file}: ${data.coverage.measured}/${data.coverage.inventory} measured; ${data.coverage.withSetup} include setup`);
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
refreshDurations(process.argv.slice(2)).catch(error => { console.error(error); process.exitCode = 1; });
|
|
}
|