mcp-context-cost 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -7
- package/dist/sweep/cross-check.js +2 -2
- package/dist/sweep/pr-check.d.ts +62 -0
- package/dist/sweep/pr-check.js +296 -0
- package/dist/sweep/registry-scan.d.ts +355 -0
- package/dist/sweep/registry-scan.js +432 -0
- package/dist/sweep/run.d.ts +17 -0
- package/dist/sweep/run.js +49 -9
- package/dist/sweep/servers-schema.d.ts +7 -0
- package/dist/sweep/servers-schema.js +8 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -271,7 +271,7 @@ sample of that range; the full range is in
|
|
|
271
271
|
| filesystem (reference) | 2,823 | 14 |
|
|
272
272
|
| markitdown | 64 | 1 |
|
|
273
273
|
|
|
274
|
-
*(87 of
|
|
274
|
+
*(87 of 107 popular servers measured, each row dated by its own most recent sweep — full table in
|
|
275
275
|
[results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
|
|
276
276
|
Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
|
|
277
277
|
showing which tools its tokens are in.)*
|
|
@@ -347,9 +347,9 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
|
|
|
347
347
|
| `src/audit/` | client-config discovery (5 clients, JSONC-tolerant), the per-stack report, and the baseline diff |
|
|
348
348
|
| `src/cli.ts` | `audit` (measure your own stack), `verify` (re-derive any published number), `measure` |
|
|
349
349
|
| `spec/fixtures/` | golden vectors shared by the TypeScript and bash implementations |
|
|
350
|
-
| `tools/` | the
|
|
350
|
+
| `tools/` | the scripts that call a network API — the Claude divergence run, the adoption reading, the registry scan — kept out of the package so the library stays offline |
|
|
351
351
|
| `upstream/` | `badge.sh` + composite-action patch + bash tests — the self-serve badge recipe, carried here |
|
|
352
|
-
| `servers.yaml` |
|
|
352
|
+
| `servers.yaml` | 107 curated candidates with live install metrics and provenance |
|
|
353
353
|
| `results/` · `badges/` | measurements, leaderboard, history series, shields endpoint JSONs |
|
|
354
354
|
| `docs/` | methodology, dashboard, and a generated page per measured server |
|
|
355
355
|
|
|
@@ -357,11 +357,26 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
|
|
|
357
357
|
|
|
358
358
|
```bash
|
|
359
359
|
npm ci
|
|
360
|
-
npm run sweep -- --name my-server --command "npx -y my-mcp-server"
|
|
361
|
-
cat badges/my-server.json # strict shields.io endpoint JSON
|
|
360
|
+
npm run sweep -- --no-persist --name my-server --command "npx -y my-mcp-server"
|
|
362
361
|
```
|
|
363
362
|
|
|
364
|
-
|
|
363
|
+
That prints the number and writes nothing. Published records
|
|
364
|
+
(`results/<name>/measurement.json`, `badges/<name>.json`, the `history.csv`
|
|
365
|
+
row) come from CI: a developer machine is a different architecture under
|
|
366
|
+
different load, and a measurement taken there describes it rather than the
|
|
367
|
+
server. `local-mcp`'s failing record was made on an arm64 laptop, and its
|
|
368
|
+
stderr named an architecture the record itself did not — which is why every
|
|
369
|
+
measurement now records `isolation.arch`. The entry turned out to be
|
|
370
|
+
unavailable on both architectures, and a record that says where it was made
|
|
371
|
+
is what lets that be told from a broken server. To get your server into the
|
|
372
|
+
leaderboard, add an entry to `servers.yaml` and open a pull request; the check
|
|
373
|
+
on that PR measures the entry read-only, and the rotation publishes it after
|
|
374
|
+
merge.
|
|
375
|
+
|
|
376
|
+
For a badge on your own README, run the published CLI in your server's own CI
|
|
377
|
+
(the [gate](#defend-the-number-dont-just-display-it) below writes
|
|
378
|
+
`results/my-server/measurement.json` and `badges/my-server.json` into that
|
|
379
|
+
repository), then in your README:
|
|
365
380
|
|
|
366
381
|
```markdown
|
|
367
382
|
[](<link target>)
|
|
@@ -455,6 +470,7 @@ public, one click each:
|
|
|
455
470
|
[re-sweep runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/resweep.yml)
|
|
456
471
|
and [self-badge runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/self-badge.yml).
|
|
457
472
|
See [ROADMAP.md](ROADMAP.md) for what's next — contributions welcome, especially new
|
|
458
|
-
`servers.yaml` entries.
|
|
473
|
+
`servers.yaml` entries; [CONTRIBUTING.md](CONTRIBUTING.md) says what an entry needs, the
|
|
474
|
+
order of steps that leaves a pull request green, and where its number comes from.
|
|
459
475
|
|
|
460
476
|
MIT © 2026
|
|
@@ -28,7 +28,7 @@ import { homedir, tmpdir } from 'node:os';
|
|
|
28
28
|
import { join, resolve } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { parse } from 'yaml';
|
|
31
|
-
import { measureServer } from './run.js';
|
|
31
|
+
import { isSelfContainerised, measureServer } from './run.js';
|
|
32
32
|
import { DockerHarnessFault, defaultImageFor, dockerize } from './docker.js';
|
|
33
33
|
import { splitCommand } from './client.js';
|
|
34
34
|
import { selectShard, shardIndexForDate } from './shard.js';
|
|
@@ -305,7 +305,7 @@ if (isMain) {
|
|
|
305
305
|
console.log(` ${e.name}: our measurement ${m.status} — recorded, nothing to compare`);
|
|
306
306
|
continue;
|
|
307
307
|
}
|
|
308
|
-
const selfDocker = e.command
|
|
308
|
+
const selfDocker = isSelfContainerised(e.command);
|
|
309
309
|
const out = await runCli(docker && selfDocker ? await hostBin() : binPath, e, {
|
|
310
310
|
docker: docker && !selfDocker,
|
|
311
311
|
timeoutMs,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type SchemaProblem } from './servers-schema.js';
|
|
2
|
+
import type { ServerEntry } from './report.js';
|
|
3
|
+
import type { Measurement } from '../core/types.js';
|
|
4
|
+
/**
|
|
5
|
+
* The fields that change what a sweep launches — the option object
|
|
6
|
+
* sweep-all.ts hands to `measureServer`, plus the timeout it is given. A change
|
|
7
|
+
* to `metric`, `metricSource`, `category`, `repo`, `package` or `deprecated`
|
|
8
|
+
* changes a row's text, not its process, and is not re-measured here.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LAUNCH_FIELDS: readonly ["command", "dockerImage", "aptPackages", "needsGit", "env", "envValues", "timeoutSeconds", "notApplicable"];
|
|
11
|
+
/**
|
|
12
|
+
* How many entries one pull request may launch, at the default budget. The
|
|
13
|
+
* worst case per entry is a timeout retried on `TIMEOUT_RETRY_FACTOR` times
|
|
14
|
+
* its budget (run.ts:351-352), so at the workflow's `--default-timeout 240`
|
|
15
|
+
* one entry can hold the runner for 240 × (1 + 2) = 720s, and this many of
|
|
16
|
+
* them measured one at a time is what pr-check.yml's `timeout-minutes` is
|
|
17
|
+
* sized to (`launchBudgetSeconds`).
|
|
18
|
+
*
|
|
19
|
+
* The count alone does not hold that bound. An entry's own `timeoutSeconds`
|
|
20
|
+
* replaces the default (the rotation's rule, kept here), and the schema bounds
|
|
21
|
+
* it only below — "a whole number of seconds greater than zero"
|
|
22
|
+
* (servers-schema.ts:166-169) — so one added entry with `timeoutSeconds: 4000`
|
|
23
|
+
* would cost 12000s under the same count and be killed by the job limit with
|
|
24
|
+
* no line printed for it. The bound is held because the script also refuses,
|
|
25
|
+
* before any launch, a selection whose `worstCaseSeconds` exceeds the budget
|
|
26
|
+
* the count was sized to. A PR that trips either is asked to split or shorten,
|
|
27
|
+
* rather than have the runner cut it off with the last entries unmeasured and
|
|
28
|
+
* no line saying so.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_MAX_ENTRIES = 4;
|
|
31
|
+
/** The launched entries among a selection: remote endpoints and self-containerised commands are listed, not spawned. */
|
|
32
|
+
export declare function launchedEntries(entries: ServerEntry[]): ServerEntry[];
|
|
33
|
+
/**
|
|
34
|
+
* The most seconds a selection can hold the runner: each launched entry's own
|
|
35
|
+
* budget (`timeoutSeconds`, or the default) once, then `TIMEOUT_RETRY_FACTOR`
|
|
36
|
+
* times more on the retry. Listed entries cost nothing and are not counted.
|
|
37
|
+
*/
|
|
38
|
+
export declare function worstCaseSeconds(entries: ServerEntry[], defaultTimeout: number): number;
|
|
39
|
+
/** What the job's `timeout-minutes` was sized to: `maxEntries` launches, each at the default budget and its retry. */
|
|
40
|
+
export declare function launchBudgetSeconds(maxEntries: number, defaultTimeout: number): number;
|
|
41
|
+
export interface ServersDiff {
|
|
42
|
+
/** Head fails the shape check; nothing is diffed from a malformed document. */
|
|
43
|
+
problems: SchemaProblem[];
|
|
44
|
+
/** Names present in head and absent from base. */
|
|
45
|
+
added: ServerEntry[];
|
|
46
|
+
/** Names in both whose `LAUNCH_FIELDS` projection differs. */
|
|
47
|
+
relaunched: ServerEntry[];
|
|
48
|
+
}
|
|
49
|
+
/** The part of an entry that decides what gets spawned, as one comparable string. */
|
|
50
|
+
export declare function launchSignature(entry: ServerEntry): string;
|
|
51
|
+
/**
|
|
52
|
+
* What a pull request adds or relaunches, given the parsed base and head
|
|
53
|
+
* documents. Head is shape-checked first and returns only problems when it has
|
|
54
|
+
* any. Base is the committed branch, which `npm test` already holds valid; an
|
|
55
|
+
* unreadable base is treated as empty, which makes every head entry "added"
|
|
56
|
+
* and lets the entry cap refuse the run rather than measuring the whole file.
|
|
57
|
+
*/
|
|
58
|
+
export declare function entriesToMeasure(base: unknown, head: unknown): ServersDiff;
|
|
59
|
+
/** The line printed for a launched entry — sweep-all's summary form, with the evidence behind a failure. */
|
|
60
|
+
export declare function summarise(name: string, m: Measurement, secs: number): string;
|
|
61
|
+
/** Whether an outcome fails the check. */
|
|
62
|
+
export declare function failsCheck(status: Measurement['status']): boolean;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only measurement a pull request gets before any job holding
|
|
3
|
+
* `contents: write` ever runs its launch command:
|
|
4
|
+
* npx tsx src/sweep/pr-check.ts --base <base servers.yaml> [--head servers.yaml]
|
|
5
|
+
* [--docker] [--default-timeout 60] [--max-entries 4]
|
|
6
|
+
*
|
|
7
|
+
* Diffs the two documents BY NAME and measures only what changed: entries the
|
|
8
|
+
* pull request added, and entries whose launch-affecting fields it changed
|
|
9
|
+
* (`LAUNCH_FIELDS`). The second set is not optional. The roadmap first wrote
|
|
10
|
+
* "measures only the entries the PR added", and that misses the point of the
|
|
11
|
+
* check: a PR that rewrites an existing entry's `command` changes what the
|
|
12
|
+
* Wednesday rotation (resweep.yml, `contents: write`) will spawn next, and
|
|
13
|
+
* "added" never sees it. What is launched is what is measured.
|
|
14
|
+
*
|
|
15
|
+
* Nothing is written anywhere. `measureServer` runs with `persist: false`, the
|
|
16
|
+
* form session-start.ts and cross-check.ts already use, and this file imports
|
|
17
|
+
* none of history.js, report.js, regressions.js or regen.js — the number is
|
|
18
|
+
* printed in the check log and the rotation publishes its own later, under its
|
|
19
|
+
* own rules. The measured line here is not the published number and must not
|
|
20
|
+
* be quoted as one: a fresh runner resolves `@latest` on its own day.
|
|
21
|
+
*
|
|
22
|
+
* Four refusals, all before a launch, all exit 2 so a reader can tell "nothing
|
|
23
|
+
* ran" from "the entry failed". A head document that does not parse as YAML
|
|
24
|
+
* prints the parser's message and stops — never its stack trace, which the
|
|
25
|
+
* first draft of this script let through with exit 1, the code the policy
|
|
26
|
+
* below reserves for an entry that launched and failed. A head that parses but
|
|
27
|
+
* fails `validateServers` prints the problems (servers-schema.ts was written
|
|
28
|
+
* for exactly this second caller). More than `--max-entries` entries is refused
|
|
29
|
+
* because one entry can cost `budget × (1 + TIMEOUT_RETRY_FACTOR)` (run.ts
|
|
30
|
+
* retries a timeout on the doubled budget) and the workflow's `timeout-minutes`
|
|
31
|
+
* is derived from that product — and, because the schema bounds an entry's own
|
|
32
|
+
* `timeoutSeconds` only below (servers-schema.ts:166-169), the sum of what the
|
|
33
|
+
* selected entries would actually cost (`worstCaseSeconds`) is refused when it
|
|
34
|
+
* exceeds the same budget, so no entry's `timeoutSeconds: 4000` can push the
|
|
35
|
+
* runner past the job limit with its line unprinted. And an entry whose command
|
|
36
|
+
* is already its own `docker run` (`isSelfContainerised`) is listed, not
|
|
37
|
+
* launched: the harness would spawn it against the runner's own daemon with the
|
|
38
|
+
* pull request's argv, so it waits for a maintainer to measure it via
|
|
39
|
+
* resweep.yml's `servers` input after review. Listing is the `remote` treatment
|
|
40
|
+
* sweep-all.ts gives endpoints, and it is exit 0 — published entries already
|
|
41
|
+
* take this form (the premise record cites the `github`, `grafana` and
|
|
42
|
+
* `terraform` entries), and a check that went red on a shape the file already
|
|
43
|
+
* uses would be a check against the file.
|
|
44
|
+
*
|
|
45
|
+
* Exit policy, by the entry's outcome: `measured`, `auth-required` and a
|
|
46
|
+
* declared `not-applicable` are findings the leaderboard publishes today, so
|
|
47
|
+
* they pass. `startup-failure` and `timeout` mean the entry does not launch as
|
|
48
|
+
* written — the lesson `agent-device` taught, measured only once its `mcp`
|
|
49
|
+
* subcommand was named (CHANGELOG 0.12.0) — and `dynamic` means two captures
|
|
50
|
+
* disagreed, so there is no one number to show. Those fail, with the evidence
|
|
51
|
+
* tail printed so the reader sees what the server said. A `DockerHarnessFault`
|
|
52
|
+
* is the runner's problem, not the entry's (the 2026-08-26 `sequential-thinking`
|
|
53
|
+
* incident, docker.ts), and exits 1 saying so.
|
|
54
|
+
*/
|
|
55
|
+
import { readFileSync } from 'node:fs';
|
|
56
|
+
import { resolve } from 'node:path';
|
|
57
|
+
import { fileURLToPath } from 'node:url';
|
|
58
|
+
import { parse } from 'yaml';
|
|
59
|
+
import { isSelfContainerised, measureServer, TIMEOUT_RETRY_FACTOR } from './run.js';
|
|
60
|
+
import { DockerHarnessFault } from './docker.js';
|
|
61
|
+
import { formatProblems, validateServers } from './servers-schema.js';
|
|
62
|
+
/**
|
|
63
|
+
* The fields that change what a sweep launches — the option object
|
|
64
|
+
* sweep-all.ts hands to `measureServer`, plus the timeout it is given. A change
|
|
65
|
+
* to `metric`, `metricSource`, `category`, `repo`, `package` or `deprecated`
|
|
66
|
+
* changes a row's text, not its process, and is not re-measured here.
|
|
67
|
+
*/
|
|
68
|
+
export const LAUNCH_FIELDS = [
|
|
69
|
+
'command',
|
|
70
|
+
'dockerImage',
|
|
71
|
+
'aptPackages',
|
|
72
|
+
'needsGit',
|
|
73
|
+
'env',
|
|
74
|
+
'envValues',
|
|
75
|
+
'timeoutSeconds',
|
|
76
|
+
'notApplicable',
|
|
77
|
+
];
|
|
78
|
+
/**
|
|
79
|
+
* How many entries one pull request may launch, at the default budget. The
|
|
80
|
+
* worst case per entry is a timeout retried on `TIMEOUT_RETRY_FACTOR` times
|
|
81
|
+
* its budget (run.ts:351-352), so at the workflow's `--default-timeout 240`
|
|
82
|
+
* one entry can hold the runner for 240 × (1 + 2) = 720s, and this many of
|
|
83
|
+
* them measured one at a time is what pr-check.yml's `timeout-minutes` is
|
|
84
|
+
* sized to (`launchBudgetSeconds`).
|
|
85
|
+
*
|
|
86
|
+
* The count alone does not hold that bound. An entry's own `timeoutSeconds`
|
|
87
|
+
* replaces the default (the rotation's rule, kept here), and the schema bounds
|
|
88
|
+
* it only below — "a whole number of seconds greater than zero"
|
|
89
|
+
* (servers-schema.ts:166-169) — so one added entry with `timeoutSeconds: 4000`
|
|
90
|
+
* would cost 12000s under the same count and be killed by the job limit with
|
|
91
|
+
* no line printed for it. The bound is held because the script also refuses,
|
|
92
|
+
* before any launch, a selection whose `worstCaseSeconds` exceeds the budget
|
|
93
|
+
* the count was sized to. A PR that trips either is asked to split or shorten,
|
|
94
|
+
* rather than have the runner cut it off with the last entries unmeasured and
|
|
95
|
+
* no line saying so.
|
|
96
|
+
*/
|
|
97
|
+
export const DEFAULT_MAX_ENTRIES = 4;
|
|
98
|
+
/** The launched entries among a selection: remote endpoints and self-containerised commands are listed, not spawned. */
|
|
99
|
+
export function launchedEntries(entries) {
|
|
100
|
+
return entries.filter((e) => !e.remote && !isSelfContainerised(e.command));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The most seconds a selection can hold the runner: each launched entry's own
|
|
104
|
+
* budget (`timeoutSeconds`, or the default) once, then `TIMEOUT_RETRY_FACTOR`
|
|
105
|
+
* times more on the retry. Listed entries cost nothing and are not counted.
|
|
106
|
+
*/
|
|
107
|
+
export function worstCaseSeconds(entries, defaultTimeout) {
|
|
108
|
+
return launchedEntries(entries).reduce((sum, e) => sum + (e.timeoutSeconds ?? defaultTimeout) * (1 + TIMEOUT_RETRY_FACTOR), 0);
|
|
109
|
+
}
|
|
110
|
+
/** What the job's `timeout-minutes` was sized to: `maxEntries` launches, each at the default budget and its retry. */
|
|
111
|
+
export function launchBudgetSeconds(maxEntries, defaultTimeout) {
|
|
112
|
+
return maxEntries * defaultTimeout * (1 + TIMEOUT_RETRY_FACTOR);
|
|
113
|
+
}
|
|
114
|
+
/** JSON with object keys sorted at every depth, so equal launches stringify equal. */
|
|
115
|
+
function stable(value) {
|
|
116
|
+
if (Array.isArray(value))
|
|
117
|
+
return `[${value.map(stable).join(',')}]`;
|
|
118
|
+
if (typeof value === 'object' && value !== null) {
|
|
119
|
+
const o = value;
|
|
120
|
+
return `{${Object.keys(o)
|
|
121
|
+
.sort()
|
|
122
|
+
.map((k) => `${JSON.stringify(k)}:${stable(o[k])}`)
|
|
123
|
+
.join(',')}}`;
|
|
124
|
+
}
|
|
125
|
+
return JSON.stringify(value);
|
|
126
|
+
}
|
|
127
|
+
/** The part of an entry that decides what gets spawned, as one comparable string. */
|
|
128
|
+
export function launchSignature(entry) {
|
|
129
|
+
const projection = {};
|
|
130
|
+
for (const field of LAUNCH_FIELDS) {
|
|
131
|
+
if (entry[field] !== undefined)
|
|
132
|
+
projection[field] = entry[field];
|
|
133
|
+
}
|
|
134
|
+
return stable(projection);
|
|
135
|
+
}
|
|
136
|
+
function entriesOf(doc) {
|
|
137
|
+
if (typeof doc !== 'object' || doc === null)
|
|
138
|
+
return [];
|
|
139
|
+
const servers = doc.servers;
|
|
140
|
+
if (!Array.isArray(servers))
|
|
141
|
+
return [];
|
|
142
|
+
return servers.filter((e) => typeof e === 'object' && e !== null && typeof e.name === 'string');
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* What a pull request adds or relaunches, given the parsed base and head
|
|
146
|
+
* documents. Head is shape-checked first and returns only problems when it has
|
|
147
|
+
* any. Base is the committed branch, which `npm test` already holds valid; an
|
|
148
|
+
* unreadable base is treated as empty, which makes every head entry "added"
|
|
149
|
+
* and lets the entry cap refuse the run rather than measuring the whole file.
|
|
150
|
+
*/
|
|
151
|
+
export function entriesToMeasure(base, head) {
|
|
152
|
+
const problems = validateServers(head);
|
|
153
|
+
if (problems.length)
|
|
154
|
+
return { problems, added: [], relaunched: [] };
|
|
155
|
+
const before = new Map(entriesOf(base).map((e) => [e.name, e]));
|
|
156
|
+
const added = [];
|
|
157
|
+
const relaunched = [];
|
|
158
|
+
for (const entry of entriesOf(head)) {
|
|
159
|
+
const prior = before.get(entry.name);
|
|
160
|
+
if (!prior)
|
|
161
|
+
added.push(entry);
|
|
162
|
+
else if (launchSignature(prior) !== launchSignature(entry))
|
|
163
|
+
relaunched.push(entry);
|
|
164
|
+
}
|
|
165
|
+
return { problems: [], added, relaunched };
|
|
166
|
+
}
|
|
167
|
+
/** The line printed for a launched entry — sweep-all's summary form, with the evidence behind a failure. */
|
|
168
|
+
export function summarise(name, m, secs) {
|
|
169
|
+
const head = m.status === 'measured' || m.status === 'dynamic'
|
|
170
|
+
? `${name}: ${m.totalTokens} tokens / ${m.toolCount} tools (${m.status}, ${secs}s)`
|
|
171
|
+
: `${name}: ${m.status} (${secs}s)`;
|
|
172
|
+
const tail = m.notes ? `\n ${m.notes.split('\n').join('\n ')}` : '';
|
|
173
|
+
return head + tail;
|
|
174
|
+
}
|
|
175
|
+
/** Whether an outcome fails the check. */
|
|
176
|
+
export function failsCheck(status) {
|
|
177
|
+
return status === 'startup-failure' || status === 'timeout' || status === 'dynamic';
|
|
178
|
+
}
|
|
179
|
+
function arg(name) {
|
|
180
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
181
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
182
|
+
}
|
|
183
|
+
// Exact path match, for the reason src/sweep/run.ts states: any other file whose
|
|
184
|
+
// name merely ends the same way would otherwise run this block.
|
|
185
|
+
const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
186
|
+
if (isMain) {
|
|
187
|
+
const basePath = arg('base');
|
|
188
|
+
if (!basePath) {
|
|
189
|
+
console.error('usage: npx tsx src/sweep/pr-check.ts --base <base servers.yaml> [--head servers.yaml] [--docker] ' +
|
|
190
|
+
`[--default-timeout 60] [--max-entries ${DEFAULT_MAX_ENTRIES}]`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
const headPath = arg('head') ?? 'servers.yaml';
|
|
194
|
+
const docker = process.argv.includes('--docker');
|
|
195
|
+
const defaultTimeout = Number(arg('default-timeout') ?? 60);
|
|
196
|
+
const maxEntries = Number(arg('max-entries') ?? DEFAULT_MAX_ENTRIES);
|
|
197
|
+
// A parse error is a refusal, not a failed launch: exit 2 with the parser's
|
|
198
|
+
// message, never its stack trace. Base is the committed branch and always
|
|
199
|
+
// parses in practice, but the two are treated alike so no path exits 1
|
|
200
|
+
// without an entry having been launched.
|
|
201
|
+
const parseDoc = (path) => {
|
|
202
|
+
try {
|
|
203
|
+
return parse(readFileSync(path, 'utf8'));
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(`${path} does not parse as YAML: ${err.message.trim()}\nnothing was launched`);
|
|
207
|
+
process.exit(2);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const base = parseDoc(basePath);
|
|
211
|
+
const head = parseDoc(headPath);
|
|
212
|
+
const diff = entriesToMeasure(base, head);
|
|
213
|
+
if (diff.problems.length) {
|
|
214
|
+
console.error(`${headPath} is not shaped like a servers list; nothing was launched:`);
|
|
215
|
+
console.error(formatProblems(diff.problems));
|
|
216
|
+
process.exit(2);
|
|
217
|
+
}
|
|
218
|
+
const selected = [...diff.added, ...diff.relaunched];
|
|
219
|
+
if (selected.length === 0) {
|
|
220
|
+
console.log('servers.yaml adds or relaunches no entry; nothing to measure');
|
|
221
|
+
process.exit(0);
|
|
222
|
+
}
|
|
223
|
+
if (selected.length > maxEntries) {
|
|
224
|
+
console.error(`this pull request adds or relaunches ${selected.length} entries ` +
|
|
225
|
+
`(${selected.map((e) => e.name).join(', ')}); the check launches at most ${maxEntries} — ` +
|
|
226
|
+
`one entry can cost --default-timeout × (1 + ${TIMEOUT_RETRY_FACTOR}) seconds, and the ` +
|
|
227
|
+
`job's timeout-minutes is sized to ${maxEntries} of those. Split the pull request. Nothing was launched.`);
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
const budget = launchBudgetSeconds(maxEntries, defaultTimeout);
|
|
231
|
+
const worst = worstCaseSeconds(selected, defaultTimeout);
|
|
232
|
+
if (worst > budget) {
|
|
233
|
+
const named = launchedEntries(selected)
|
|
234
|
+
.map((e) => `${e.name} (timeoutSeconds ${e.timeoutSeconds ?? `${defaultTimeout}, the default`})`)
|
|
235
|
+
.join(', ');
|
|
236
|
+
console.error(`the entries this pull request launches could hold the runner for ${worst}s — ${named}, each ` +
|
|
237
|
+
`retried on ${TIMEOUT_RETRY_FACTOR}× its budget after a timeout — and the job's timeout-minutes ` +
|
|
238
|
+
`is sized to ${budget}s (${maxEntries} entries at --default-timeout ${defaultTimeout}). ` +
|
|
239
|
+
`Shorten timeoutSeconds or split the pull request. Nothing was launched.`);
|
|
240
|
+
process.exit(2);
|
|
241
|
+
}
|
|
242
|
+
console.log(`measuring ${diff.added.length} added and ${diff.relaunched.length} relaunched ` +
|
|
243
|
+
`(docker=${docker}, default timeout ${defaultTimeout}s, one at a time)`);
|
|
244
|
+
// One at a time, deliberately: contention is what produced two false
|
|
245
|
+
// timeouts in an earlier sweep (run.ts, `retriesWithLongerTimeout`), and a
|
|
246
|
+
// check over a handful of entries has no throughput to buy with it.
|
|
247
|
+
let failed = 0;
|
|
248
|
+
for (const e of selected) {
|
|
249
|
+
const kind = diff.added.includes(e) ? 'added' : 'relaunched';
|
|
250
|
+
if (e.remote) {
|
|
251
|
+
console.log(` ${e.name} (${kind}): remote — listed, not measured; an endpoint never reaches initialize without credentials`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (isSelfContainerised(e.command)) {
|
|
255
|
+
console.log(` ${e.name} (${kind}): listed, not launched here — its command is its own \`docker run\`, which ` +
|
|
256
|
+
`the harness would spawn against this runner's daemon with the pull request's argv. ` +
|
|
257
|
+
`A maintainer measures it via resweep.yml servers=${e.name} after review.`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const started = Date.now();
|
|
261
|
+
let m;
|
|
262
|
+
try {
|
|
263
|
+
m = await measureServer(e.name, e.command, {
|
|
264
|
+
timeoutMs: (e.timeoutSeconds ?? defaultTimeout) * 1000,
|
|
265
|
+
docker,
|
|
266
|
+
dockerImage: e.dockerImage,
|
|
267
|
+
dummyEnv: e.env ?? [],
|
|
268
|
+
dummyEnvValues: e.envValues,
|
|
269
|
+
needsGit: e.needsGit,
|
|
270
|
+
aptPackages: e.aptPackages,
|
|
271
|
+
notApplicable: e.notApplicable,
|
|
272
|
+
persist: false, // the check prints; the rotation publishes
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
if (!(err instanceof DockerHarnessFault))
|
|
277
|
+
throw err;
|
|
278
|
+
// A statement about the runner, not the entry — but nothing was measured,
|
|
279
|
+
// and a green check with no number in it would be read as one.
|
|
280
|
+
console.error(`HARNESS FAULT: ${err.message}`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
284
|
+
console.log(` ${summarise(`${e.name} (${kind})`, m, secs)}`);
|
|
285
|
+
if (failsCheck(m.status))
|
|
286
|
+
failed++;
|
|
287
|
+
}
|
|
288
|
+
console.log('Nothing was written: results/, badges/ and history.csv are published only by the rotation ' +
|
|
289
|
+
'(.github/workflows/resweep.yml). If CI is red on this pull request, run `npx tsx src/sweep/regen.ts` ' +
|
|
290
|
+
'and commit what it rewrote, and add a bullet under `## Unreleased` in CHANGELOG.md or start the ' +
|
|
291
|
+
'commit subject with `chore:` — tools/release-readiness.ts fails a servers.yaml commit that does neither.');
|
|
292
|
+
if (failed) {
|
|
293
|
+
console.error(`${failed} entr${failed === 1 ? 'y does' : 'ies do'} not launch as written; see the lines above`);
|
|
294
|
+
}
|
|
295
|
+
process.exit(failed ? 1 : 0);
|
|
296
|
+
}
|