webmcp-gauge 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/action.yml +162 -0
- package/bin/webmcp-gauge.mjs +544 -0
- package/bin/webmcp-gauge.test.mjs +354 -0
- package/browser/launch.mjs +188 -0
- package/browser/serve.mjs +78 -0
- package/browser/session.mjs +210 -0
- package/browser/webmcp.mjs +432 -0
- package/browser/webmcp.test.mjs +299 -0
- package/core/args.mjs +93 -0
- package/core/args.test.mjs +85 -0
- package/core/capture-seam.test.mjs +86 -0
- package/core/cohort.mjs +432 -0
- package/core/cohort.test.mjs +370 -0
- package/core/gallery.mjs +145 -0
- package/core/gallery.test.mjs +128 -0
- package/core/gate.mjs +164 -0
- package/core/gate.test.mjs +213 -0
- package/core/lint.mjs +381 -0
- package/core/lint.test.mjs +346 -0
- package/core/orchestrate.mjs +128 -0
- package/core/orchestrate.test.mjs +191 -0
- package/core/stats.mjs +172 -0
- package/core/stats.test.mjs +156 -0
- package/core/sweep.mjs +274 -0
- package/core/sweep.test.mjs +162 -0
- package/core/taxonomy.mjs +175 -0
- package/core/taxonomy.test.mjs +198 -0
- package/core/trial.mjs +248 -0
- package/core/visibility.mjs +163 -0
- package/core/visibility.test.mjs +164 -0
- package/docs/concept.md +468 -0
- package/docs/explainer.md +161 -0
- package/docs/getting-started.md +331 -0
- package/fixtures/README.md +42 -0
- package/fixtures/airlock.utterances.json +284 -0
- package/fixtures/broken/compose.mjs +52 -0
- package/fixtures/broken/compose.test.mjs +270 -0
- package/fixtures/broken/sample-expenses.csv +966 -0
- package/fixtures/broken/tools.json +1311 -0
- package/fixtures/broken/twin.html +482 -0
- package/fixtures/broken/widget.html +62 -0
- package/fixtures/gallery/gallery.html +56 -0
- package/judges/openai-compatible.mjs +145 -0
- package/package.json +53 -0
- package/report/badge.mjs +110 -0
- package/report/badge.test.mjs +97 -0
- package/report/emit.mjs +282 -0
- package/report/published-runs.test.mjs +77 -0
- package/report/scorecard.mjs +157 -0
- package/report/scorecard.test.mjs +130 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { launchSession } from '../browser/launch.mjs';
|
|
6
|
+
import { startFixtureServer } from '../browser/serve.mjs';
|
|
7
|
+
import { openSession } from '../browser/session.mjs';
|
|
8
|
+
import { captureManifest } from '../browser/webmcp.mjs';
|
|
9
|
+
import { parseOptions } from '../core/args.mjs';
|
|
10
|
+
import { EXIT, gateRun, parseFailUnder } from '../core/gate.mjs';
|
|
11
|
+
import { lintManifest, lintToText } from '../core/lint.mjs';
|
|
12
|
+
import { runSessions } from '../core/orchestrate.mjs';
|
|
13
|
+
import { buildPlan, readCheckpoint, readFailures, runSessionSweep, trialKey } from '../core/sweep.mjs';
|
|
14
|
+
import { runTrial } from '../core/trial.mjs';
|
|
15
|
+
import { createJudge } from '../judges/openai-compatible.mjs';
|
|
16
|
+
import { buildReport, toMarkdown } from '../report/emit.mjs';
|
|
17
|
+
import { buildBadge, renderBadgeSvg } from '../report/badge.mjs';
|
|
18
|
+
|
|
19
|
+
const { name, version } = JSON.parse(
|
|
20
|
+
await readFile(new URL('../package.json', import.meta.url), 'utf8')
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const usage = `${name} ${version}
|
|
24
|
+
|
|
25
|
+
Usage: webmcp-gauge <command> [options]
|
|
26
|
+
|
|
27
|
+
Commands:
|
|
28
|
+
trial run one trial: one utterance, one expected tool, one outcome
|
|
29
|
+
run run S isolated sessions and emit a stamped report
|
|
30
|
+
session run one session (used by run; each session gets its own process)
|
|
31
|
+
lint static checks on a page's tool manifest: no judge, no API key
|
|
32
|
+
|
|
33
|
+
Shared options (each accepts --name value or --name=value; an unknown option is an
|
|
34
|
+
error, never a silently ignored default):
|
|
35
|
+
--fixture <path> utterance set (default fixtures/airlock.utterances.json)
|
|
36
|
+
--url <url> subject page (default the fixture's subject url). With
|
|
37
|
+
--serve, a path relative to the served directory
|
|
38
|
+
--serve <dir> serve <dir> on 127.0.0.1 and resolve --url against it, so a
|
|
39
|
+
measurement can run against a fixture page in this repo
|
|
40
|
+
--judge <model> judge model id (env WEBMCP_GAUGE_JUDGE_MODEL)
|
|
41
|
+
--base-url <url> judge endpoint (env WEBMCP_GAUGE_JUDGE_BASE_URL)
|
|
42
|
+
--port <n> attach to an existing Chrome instead of launching one
|
|
43
|
+
|
|
44
|
+
lint options (no judge required):
|
|
45
|
+
--manifest <path> lint a manifest JSON file instead of a live page. Reads
|
|
46
|
+
{tools:[...]} or a bare array; the only way to lint a name a
|
|
47
|
+
browser refuses to register
|
|
48
|
+
--variant <name> for a multi-variant fixture file, lint variants.<name>
|
|
49
|
+
--json emit the finding list as JSON
|
|
50
|
+
--fail-on <level> error (default) or warning
|
|
51
|
+
--min-description <n> description floor in characters (default 60)
|
|
52
|
+
--max-properties <n> schema property ceiling (default 6)
|
|
53
|
+
--budget-warn <n> tool count that warns about budget headroom (default 64)
|
|
54
|
+
|
|
55
|
+
trial options:
|
|
56
|
+
--tool <name> expected tool
|
|
57
|
+
--utterance <id> utterance id, e.g. sum_by_category-05
|
|
58
|
+
|
|
59
|
+
run / session options:
|
|
60
|
+
--sessions <n> isolated sessions, each its own process, browser and cold
|
|
61
|
+
profile (default 3). Between-session sigma needs >= 2
|
|
62
|
+
--repeats <n> repeats inside one session (default 1). Within-session
|
|
63
|
+
sigma needs >= 2, and it is a floor, not a stability claim
|
|
64
|
+
--concurrency <n> parallel tabs inside a session (default 1)
|
|
65
|
+
--gap <seconds> wait between sessions (default 0)
|
|
66
|
+
--tools <a,b> restrict to these tools
|
|
67
|
+
--no-controls skip the negative controls
|
|
68
|
+
--headful show the browser instead of --headless=new
|
|
69
|
+
--out <dir> report directory (default artifacts/)
|
|
70
|
+
--resume reuse the JSONL checkpoint in the report directory
|
|
71
|
+
--subject <name> name the subject in the report, when it is not the fixture's
|
|
72
|
+
own subject — a report that mislabels what it measured is
|
|
73
|
+
worse than one with no label
|
|
74
|
+
--fail-under <rate> exit 1 when any tool's invocation rate is below this rate,
|
|
75
|
+
e.g. 0.9. Compared against the point rate; the interval is
|
|
76
|
+
reported beside it
|
|
77
|
+
--badge-label <text> label for badge.json / badge.svg, both written on every run
|
|
78
|
+
(default "webmcp invocation"). An incomplete run's badge
|
|
79
|
+
says "incomplete" rather than a rate, and never a colour
|
|
80
|
+
that could be read as a pass
|
|
81
|
+
|
|
82
|
+
Exit codes (a gate is only useful if 1 means one thing):
|
|
83
|
+
0 every planned trial was measured, and nothing fell below --fail-under; for
|
|
84
|
+
lint, no finding at or above --fail-on
|
|
85
|
+
1 a tool's invocation rate is below --fail-under — the page regressed; for lint,
|
|
86
|
+
the manifest carries findings at that level
|
|
87
|
+
2 the command cannot answer: planned trials have no measurement (re-run with
|
|
88
|
+
--resume), a manifest never settled, or the arguments were unusable. Never a
|
|
89
|
+
threshold breach
|
|
90
|
+
|
|
91
|
+
The judge must not be the model that authored the utterance set; the set records
|
|
92
|
+
which one that was. See docs/getting-started.md and .env.example.`;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every option this CLI accepts, declared so the parser can refuse the rest.
|
|
96
|
+
*
|
|
97
|
+
* Before 2026-09-03 an unknown option was silently kept and `--fail-under=0.9`
|
|
98
|
+
* parsed as a *switch* named `fail-under=0.9`, so the threshold was never read and
|
|
99
|
+
* a CI job written that way was never gated. Both forms work now, and a typo is an
|
|
100
|
+
* error rather than a default. `session` is internal: `run` spawns `session` with
|
|
101
|
+
* it (see core/orchestrate.mjs).
|
|
102
|
+
*/
|
|
103
|
+
const CLI_OPTIONS = {
|
|
104
|
+
values: [
|
|
105
|
+
'fixture', 'url', 'serve', 'judge', 'base-url', 'port',
|
|
106
|
+
'manifest', 'variant', 'fail-on', 'min-description', 'max-properties', 'budget-warn',
|
|
107
|
+
'tool', 'utterance',
|
|
108
|
+
'sessions', 'repeats', 'concurrency', 'gap', 'tools', 'out', 'subject', 'fail-under', 'badge-label',
|
|
109
|
+
'session',
|
|
110
|
+
],
|
|
111
|
+
switches: ['json', 'no-controls', 'headful', 'resume'],
|
|
112
|
+
maxPositional: 1,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Usage errors exit 2, the same code as an unmeasurable run: in both cases the
|
|
117
|
+
* command produced no number, which is the distinction a CI job needs. Exit 1 is
|
|
118
|
+
* reserved for a measured rate below the threshold.
|
|
119
|
+
*/
|
|
120
|
+
const fail = (message) => {
|
|
121
|
+
console.error(`webmcp-gauge: ${message}`);
|
|
122
|
+
process.exit(EXIT.incomplete);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A crash is "could not measure", not "the page is bad".
|
|
127
|
+
*
|
|
128
|
+
* This file is a module with top-level await, so anything that throws becomes an
|
|
129
|
+
* unhandled rejection and Node exits **1** — the code reserved for a measured rate
|
|
130
|
+
* below the threshold. CI on 2026-09-02 proved what that costs: Chrome failed to
|
|
131
|
+
* start on the runner, the CLI exited 1, and the Action reported it as *"the
|
|
132
|
+
* manifest has findings"*. A broken environment was presented as a bad page, which
|
|
133
|
+
* is the exact conflation the split exit codes exist to prevent.
|
|
134
|
+
*/
|
|
135
|
+
const cannotMeasure = (error) => {
|
|
136
|
+
console.error(`webmcp-gauge: could not measure — ${error?.stack ?? error}`);
|
|
137
|
+
process.exit(EXIT.incomplete);
|
|
138
|
+
};
|
|
139
|
+
process.on('uncaughtException', cannotMeasure);
|
|
140
|
+
process.on('unhandledRejection', cannotMeasure);
|
|
141
|
+
|
|
142
|
+
const command = process.argv[2];
|
|
143
|
+
|
|
144
|
+
if (command === undefined || command === '--help' || command === '-h') {
|
|
145
|
+
console.log(usage);
|
|
146
|
+
process.exit(EXIT.pass);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (command === '--version' || command === '-v') {
|
|
150
|
+
console.log(version);
|
|
151
|
+
process.exit(EXIT.pass);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!['trial', 'run', 'session', 'lint'].includes(command)) {
|
|
155
|
+
console.error(`webmcp-gauge: no such command '${command}'\n`);
|
|
156
|
+
console.error(usage);
|
|
157
|
+
process.exit(EXIT.incomplete);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { options: flags, positional, error: optionsError } = parseOptions(process.argv.slice(3), CLI_OPTIONS);
|
|
161
|
+
if (optionsError) fail(`${optionsError}. Run 'webmcp-gauge --help' for the full list.`);
|
|
162
|
+
|
|
163
|
+
const needsJudge = command !== 'lint';
|
|
164
|
+
const serveDir = typeof flags.serve === 'string' ? flags.serve : null;
|
|
165
|
+
|
|
166
|
+
const fixturePath = new URL(
|
|
167
|
+
typeof flags.fixture === 'string' ? flags.fixture : '../fixtures/airlock.utterances.json',
|
|
168
|
+
import.meta.url
|
|
169
|
+
);
|
|
170
|
+
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
|
|
171
|
+
const url = (typeof flags.url === 'string' && flags.url) || positional[0] || fixture.subject?.url;
|
|
172
|
+
if (!url && !(command === 'lint' && typeof flags.manifest === 'string')) {
|
|
173
|
+
fail('no url given and the fixture names no subject url');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const judgeModel =
|
|
177
|
+
(typeof flags.judge === 'string' && flags.judge) || process.env.WEBMCP_GAUGE_JUDGE_MODEL;
|
|
178
|
+
const judgeBaseUrl =
|
|
179
|
+
(typeof flags['base-url'] === 'string' && flags['base-url']) ||
|
|
180
|
+
process.env.WEBMCP_GAUGE_JUDGE_BASE_URL;
|
|
181
|
+
// L0 is the free on-ramp: it reads a manifest and calls no model, so demanding a
|
|
182
|
+
// judge for it would put an API key in front of the cheapest useful answer.
|
|
183
|
+
if (needsJudge && (!judgeModel || !judgeBaseUrl)) {
|
|
184
|
+
fail('pass --judge and --base-url, or set WEBMCP_GAUGE_JUDGE_MODEL and WEBMCP_GAUGE_JUDGE_BASE_URL');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (needsJudge && fixture.authoring?.modelId && judgeModel === fixture.authoring.modelId) {
|
|
188
|
+
fail(
|
|
189
|
+
`judge '${judgeModel}' authored this utterance set, so it cannot judge it: the metric would measure self-consistency`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const explicitPort = typeof flags.port === 'string' ? flags.port : process.env.CDP_PORT;
|
|
194
|
+
const outDir = typeof flags.out === 'string' ? flags.out : 'artifacts';
|
|
195
|
+
// A repo measuring more than one page needs more than one badge label, so this is
|
|
196
|
+
// a flag rather than a constant. The default names the metric, not the subject.
|
|
197
|
+
const badgeLabel = typeof flags['badge-label'] === 'string' ? flags['badge-label'] : 'webmcp invocation';
|
|
198
|
+
const checkpointPath = `${outDir}/sweep.jsonl`;
|
|
199
|
+
const tools =
|
|
200
|
+
typeof flags.tools === 'string' ? flags.tools.split(',').map((part) => part.trim()) : null;
|
|
201
|
+
const includeControls = flags['no-controls'] !== true;
|
|
202
|
+
const repeatsPerSession = Number(flags.repeats ?? 1);
|
|
203
|
+
const concurrency = Number(flags.concurrency ?? 1);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* With --serve, --url is a path inside the served directory. The server is started
|
|
207
|
+
* per process, not per run: concurrent sessions must not share one, for the same
|
|
208
|
+
* reason each session starts its own browser.
|
|
209
|
+
*/
|
|
210
|
+
const openTarget = async () => {
|
|
211
|
+
const server = serveDir ? await startFixtureServer({ root: serveDir }) : null;
|
|
212
|
+
return {
|
|
213
|
+
url: server ? server.urlFor(url) : url,
|
|
214
|
+
close: () => (server ? server.close() : Promise.resolve()),
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (command === 'lint') {
|
|
219
|
+
const failOn = flags['fail-on'] === 'warning' ? 'warning' : 'error';
|
|
220
|
+
if (typeof flags['fail-on'] === 'string' && !['error', 'warning'].includes(flags['fail-on'])) {
|
|
221
|
+
fail(`--fail-on takes 'error' or 'warning', not '${flags['fail-on']}'`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const numeric = (flag) => {
|
|
225
|
+
if (flags[flag] === undefined) return undefined;
|
|
226
|
+
const value = Number(flags[flag]);
|
|
227
|
+
if (!Number.isFinite(value) || value < 0) fail(`--${flag} takes a non-negative number`);
|
|
228
|
+
return value;
|
|
229
|
+
};
|
|
230
|
+
const options = {
|
|
231
|
+
minDescriptionChars: numeric('min-description'),
|
|
232
|
+
maxProperties: numeric('max-properties'),
|
|
233
|
+
budgetWarnAt: numeric('budget-warn'),
|
|
234
|
+
};
|
|
235
|
+
for (const key of Object.keys(options)) if (options[key] === undefined) delete options[key];
|
|
236
|
+
|
|
237
|
+
let manifest;
|
|
238
|
+
let subject;
|
|
239
|
+
|
|
240
|
+
if (typeof flags.manifest === 'string') {
|
|
241
|
+
// A name a browser refuses to register cannot appear in a live manifest -
|
|
242
|
+
// Chrome 152 throws "Invalid tool name" for a name with a space - so the static
|
|
243
|
+
// path is the only way to lint what the page actually declares.
|
|
244
|
+
const parsed = JSON.parse(await readFile(flags.manifest, 'utf8'));
|
|
245
|
+
const variant = typeof flags.variant === 'string' ? flags.variant : null;
|
|
246
|
+
const list = Array.isArray(parsed)
|
|
247
|
+
? parsed
|
|
248
|
+
: variant
|
|
249
|
+
? parsed.variants?.[variant]
|
|
250
|
+
: parsed.tools;
|
|
251
|
+
if (!Array.isArray(list)) {
|
|
252
|
+
fail(
|
|
253
|
+
variant
|
|
254
|
+
? `${flags.manifest} has no variants.${variant} array`
|
|
255
|
+
: `${flags.manifest} has no tools array (pass --variant <name> for a multi-variant fixture)`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
manifest = { present: true, settled: true, tools: list };
|
|
259
|
+
subject = `${flags.manifest}${variant ? ` (${variant})` : ''}`;
|
|
260
|
+
} else {
|
|
261
|
+
const target = await openTarget();
|
|
262
|
+
const browser = explicitPort
|
|
263
|
+
? null
|
|
264
|
+
: await launchSession({ headless: flags.headful !== true, profileDir: `${outDir}/lint-profile` });
|
|
265
|
+
const tab = await openSession({ port: explicitPort ?? browser.port });
|
|
266
|
+
try {
|
|
267
|
+
await tab.navigate(target.url);
|
|
268
|
+
manifest = await captureManifest(tab);
|
|
269
|
+
subject = target.url;
|
|
270
|
+
} finally {
|
|
271
|
+
await tab.close();
|
|
272
|
+
if (browser) await browser.close();
|
|
273
|
+
await target.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const result = lintManifest({ manifest, options });
|
|
278
|
+
console.log(flags.json === true ? JSON.stringify({ subject, ...result }, null, 2) : lintToText(result, { subject }));
|
|
279
|
+
|
|
280
|
+
// A manifest that is absent, unsettled or empty is not a clean page: there was
|
|
281
|
+
// nothing to lint, which is exit 2 rather than a pass.
|
|
282
|
+
if (manifest.present !== true) {
|
|
283
|
+
console.error('lint: no WebMCP surface on this page, so nothing was linted');
|
|
284
|
+
process.exitCode = EXIT.incomplete;
|
|
285
|
+
} else if (manifest.settled === false) {
|
|
286
|
+
console.error('lint: the tool set never stopped changing, so this manifest is a partial read');
|
|
287
|
+
process.exitCode = EXIT.incomplete;
|
|
288
|
+
} else if (result.manifest.toolCount === 0) {
|
|
289
|
+
console.error('lint: the page registered no tools, so nothing was linted');
|
|
290
|
+
process.exitCode = EXIT.incomplete;
|
|
291
|
+
} else {
|
|
292
|
+
const blocking =
|
|
293
|
+
failOn === 'warning' ? result.counts.error + result.counts.warning : result.counts.error;
|
|
294
|
+
if (blocking > 0) {
|
|
295
|
+
console.error(
|
|
296
|
+
`lint: ${blocking} finding${blocking === 1 ? '' : 's'} at or above ${failOn}. This is what the manifest says, not a measured invocation rate.`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
process.exitCode = blocking > 0 ? EXIT.breach : EXIT.pass;
|
|
300
|
+
}
|
|
301
|
+
} else if (command === 'trial') {
|
|
302
|
+
const judge = createJudge({ baseUrl: judgeBaseUrl, model: judgeModel });
|
|
303
|
+
const utteranceId = typeof flags.utterance === 'string' ? flags.utterance : null;
|
|
304
|
+
const toolName =
|
|
305
|
+
typeof flags.tool === 'string' ? flags.tool : utteranceId?.replace(/-\d+$/, '') ?? null;
|
|
306
|
+
if (!toolName) fail('pass --tool <name> or --utterance <id>');
|
|
307
|
+
|
|
308
|
+
const toolBlock = fixture.tools.find((tool) => tool.name === toolName);
|
|
309
|
+
if (!toolBlock) fail(`fixture has no block for tool '${toolName}'`);
|
|
310
|
+
|
|
311
|
+
const utterance = utteranceId
|
|
312
|
+
? toolBlock.utterances.find((candidate) => candidate.id === utteranceId)
|
|
313
|
+
: toolBlock.utterances[0];
|
|
314
|
+
if (!utterance) fail(`fixture has no utterance '${utteranceId}'`);
|
|
315
|
+
|
|
316
|
+
const target = await openTarget();
|
|
317
|
+
const browser = explicitPort ? null : await launchSession({ headless: flags.headful !== true });
|
|
318
|
+
const tab = await openSession({ port: explicitPort ?? browser.port });
|
|
319
|
+
try {
|
|
320
|
+
const record = await runTrial({
|
|
321
|
+
session: tab,
|
|
322
|
+
judge,
|
|
323
|
+
url: target.url,
|
|
324
|
+
toolName,
|
|
325
|
+
utterance,
|
|
326
|
+
expectation: utterance,
|
|
327
|
+
setup: toolBlock.setup ?? null,
|
|
328
|
+
fixtureVersion: fixture.version,
|
|
329
|
+
});
|
|
330
|
+
console.log(JSON.stringify(record, null, 2));
|
|
331
|
+
// Same three-way split as `run`: an outcome of null is a trial that produced no
|
|
332
|
+
// measurement, which is not the page failing and must not read as one.
|
|
333
|
+
if (record.outcome === null) {
|
|
334
|
+
console.error(
|
|
335
|
+
`trial: no measurement — ${record.harnessFailure?.kind ?? 'unknown'}: ${record.harnessFailure?.detail ?? 'no reason given'}`
|
|
336
|
+
);
|
|
337
|
+
process.exitCode = EXIT.incomplete;
|
|
338
|
+
} else {
|
|
339
|
+
process.exitCode = record.outcome === 'ok' ? EXIT.pass : EXIT.breach;
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
console.error(`trial: threw before producing a measurement — ${error.message ?? error}`);
|
|
343
|
+
process.exitCode = EXIT.incomplete;
|
|
344
|
+
} finally {
|
|
345
|
+
await tab.close();
|
|
346
|
+
if (browser) await browser.close();
|
|
347
|
+
await target.close();
|
|
348
|
+
}
|
|
349
|
+
} else if (command === 'session') {
|
|
350
|
+
const session = Number(flags.session ?? 1);
|
|
351
|
+
const judge = createJudge({ baseUrl: judgeBaseUrl, model: judgeModel });
|
|
352
|
+
const target = await openTarget();
|
|
353
|
+
|
|
354
|
+
// Each session owns its browser: a cold profile, its own port, its own process
|
|
355
|
+
// tree. Attaching to a shared instance is still allowed with --port, and the
|
|
356
|
+
// report records which of the two it was.
|
|
357
|
+
const browser = explicitPort
|
|
358
|
+
? null
|
|
359
|
+
: await launchSession({
|
|
360
|
+
headless: flags.headful !== true,
|
|
361
|
+
profileDir: `${outDir}/sessions/session-${session}`,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const sessionMeta = browser
|
|
365
|
+
? {
|
|
366
|
+
isolated: true,
|
|
367
|
+
browserPid: browser.pid,
|
|
368
|
+
port: browser.port,
|
|
369
|
+
build: browser.build,
|
|
370
|
+
headless: browser.headless,
|
|
371
|
+
profileDir: browser.profileDir,
|
|
372
|
+
startedAt: browser.startedAt,
|
|
373
|
+
...(serveDir ? { servedFrom: serveDir, servedUrl: target.url } : {}),
|
|
374
|
+
}
|
|
375
|
+
: { isolated: false, port: String(explicitPort), note: 'attached to a pre-existing Chrome' };
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const result = await runSessionSweep({
|
|
379
|
+
fixture,
|
|
380
|
+
judge,
|
|
381
|
+
url: target.url,
|
|
382
|
+
session,
|
|
383
|
+
sessionMeta,
|
|
384
|
+
repeatsPerSession,
|
|
385
|
+
tools,
|
|
386
|
+
includeControls,
|
|
387
|
+
concurrency,
|
|
388
|
+
port: explicitPort ?? browser.port,
|
|
389
|
+
checkpointPath,
|
|
390
|
+
failureLogPath: `${outDir}/harness-failures.jsonl`,
|
|
391
|
+
onProgress: ({ completed, total, item, failures }) => {
|
|
392
|
+
process.stderr.write(
|
|
393
|
+
`\rsession ${session}: ${completed}/${total} · ${item.utterance.id} · ${failures} harness failures `
|
|
394
|
+
);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
process.stderr.write('\n');
|
|
398
|
+
console.error(
|
|
399
|
+
`session ${session}: ${result.written.length} trials recorded, ${result.failures.length} harness failures, ${(result.elapsedMs / 1000).toFixed(0)}s`
|
|
400
|
+
);
|
|
401
|
+
// A session that could not measure part of its plan exits 2, so a hand-run
|
|
402
|
+
// session and the orchestrator agree on what an incomplete measurement is.
|
|
403
|
+
// The parent does not depend on this: it recomputes coverage from the plan.
|
|
404
|
+
process.exitCode = result.failures.length > 0 ? EXIT.incomplete : EXIT.pass;
|
|
405
|
+
} finally {
|
|
406
|
+
if (browser) await browser.close();
|
|
407
|
+
await target.close();
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
const sessions = Number(flags.sessions ?? 3);
|
|
411
|
+
if (!Number.isInteger(sessions) || sessions < 1) fail('--sessions must be a positive integer');
|
|
412
|
+
if (!Number.isInteger(repeatsPerSession) || repeatsPerSession < 1) {
|
|
413
|
+
fail('--repeats must be a positive integer');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let failUnder = null;
|
|
417
|
+
try {
|
|
418
|
+
failUnder = parseFailUnder(flags['fail-under']);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
fail(error.message);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (flags.resume !== true) {
|
|
424
|
+
const existing = await readCheckpoint(checkpointPath);
|
|
425
|
+
if (existing.records.length > 0) {
|
|
426
|
+
fail(
|
|
427
|
+
`${checkpointPath} already holds ${existing.records.length} trials. Pass --resume to continue it, or --out <dir> to start a fresh one.`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const childArgs = [
|
|
433
|
+
'--fixture',
|
|
434
|
+
typeof flags.fixture === 'string' ? flags.fixture : '../fixtures/airlock.utterances.json',
|
|
435
|
+
'--url',
|
|
436
|
+
url,
|
|
437
|
+
'--judge',
|
|
438
|
+
judgeModel,
|
|
439
|
+
'--base-url',
|
|
440
|
+
judgeBaseUrl,
|
|
441
|
+
'--out',
|
|
442
|
+
outDir,
|
|
443
|
+
'--repeats',
|
|
444
|
+
String(repeatsPerSession),
|
|
445
|
+
'--concurrency',
|
|
446
|
+
String(concurrency),
|
|
447
|
+
'--resume',
|
|
448
|
+
...(tools ? ['--tools', tools.join(',')] : []),
|
|
449
|
+
...(includeControls ? [] : ['--no-controls']),
|
|
450
|
+
...(flags.headful === true ? ['--headful'] : []),
|
|
451
|
+
...(serveDir ? ['--serve', serveDir] : []),
|
|
452
|
+
...(explicitPort ? ['--port', String(explicitPort)] : []),
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
const startedAt = new Date().toISOString();
|
|
456
|
+
const startedMs = Date.now();
|
|
457
|
+
|
|
458
|
+
const sessionResults = await runSessions({
|
|
459
|
+
sessions,
|
|
460
|
+
binPath: fileURLToPath(import.meta.url),
|
|
461
|
+
args: childArgs,
|
|
462
|
+
gapSeconds: Number(flags.gap ?? 0),
|
|
463
|
+
// What a working session touches. The watchdog kills a session that stops
|
|
464
|
+
// writing to both, which is the one stall a per-trial deadline cannot see.
|
|
465
|
+
progressPaths: [checkpointPath, `${outDir}/harness-failures.jsonl`],
|
|
466
|
+
onSessionStart: ({ session }) => console.error(`\n=== session ${session} of ${sessions} ===`),
|
|
467
|
+
onSessionEnd: ({ session, stalled, error }) => {
|
|
468
|
+
if (stalled) console.error(`=== session ${session} STALLED: ${error} ===`);
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const { records } = await readCheckpoint(checkpointPath);
|
|
473
|
+
const loggedFailures = await readFailures(`${outDir}/harness-failures.jsonl`);
|
|
474
|
+
|
|
475
|
+
// A failure whose trial later succeeded on --resume is history, not a hole. The
|
|
476
|
+
// report must not list it as a gap in the current measurement, or a resumed run
|
|
477
|
+
// looks permanently incomplete; it is counted as recovered instead.
|
|
478
|
+
const measured = new Set(
|
|
479
|
+
records.map((record) => trialKey(record.session ?? 1, record.repeat, record.utteranceId))
|
|
480
|
+
);
|
|
481
|
+
const harnessFailures = loggedFailures.filter(
|
|
482
|
+
(failure) => !measured.has(trialKey(failure.session ?? 1, failure.repeat, failure.utteranceId))
|
|
483
|
+
);
|
|
484
|
+
const recoveredFailures = loggedFailures.length - harnessFailures.length;
|
|
485
|
+
|
|
486
|
+
// Completeness is derived from the plan, not from the failure log: a session
|
|
487
|
+
// killed mid-plan logs nothing, and a run that silently measured 900 of 960
|
|
488
|
+
// trials must not be allowed to exit 0 on a rate over the wrong denominator.
|
|
489
|
+
const plan = buildPlan({ fixture, repeatsPerSession, tools, includeControls });
|
|
490
|
+
const expectedKeys = [];
|
|
491
|
+
for (let session = 1; session <= sessions; session += 1) {
|
|
492
|
+
for (const item of plan) expectedKeys.push(trialKey(session, item.repeat, item.utterance.id));
|
|
493
|
+
}
|
|
494
|
+
const missing = expectedKeys.filter((key) => !measured.has(key));
|
|
495
|
+
const coverage = {
|
|
496
|
+
expectedTrials: expectedKeys.length,
|
|
497
|
+
measuredTrials: records.length,
|
|
498
|
+
missingTrials: missing.length,
|
|
499
|
+
missing: missing.slice(0, 10),
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const report = buildReport({
|
|
503
|
+
fixture,
|
|
504
|
+
records,
|
|
505
|
+
harnessFailures,
|
|
506
|
+
recoveredFailures,
|
|
507
|
+
coverage,
|
|
508
|
+
judge: { model: judgeModel, baseUrl: judgeBaseUrl, requested: judgeModel },
|
|
509
|
+
settings: {
|
|
510
|
+
url,
|
|
511
|
+
subjectName: typeof flags.subject === 'string' ? flags.subject : null,
|
|
512
|
+
servedFrom: serveDir,
|
|
513
|
+
sessions,
|
|
514
|
+
repeatsPerSession,
|
|
515
|
+
concurrency,
|
|
516
|
+
tools,
|
|
517
|
+
includeControls,
|
|
518
|
+
gapSeconds: Number(flags.gap ?? 0),
|
|
519
|
+
isolatedSessions: !explicitPort,
|
|
520
|
+
},
|
|
521
|
+
sessionResults,
|
|
522
|
+
timing: { startedAt, finishedAt: new Date().toISOString(), elapsedMs: Date.now() - startedMs },
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const gate = gateRun({ report, failUnder });
|
|
526
|
+
const gated = { ...report, gate };
|
|
527
|
+
|
|
528
|
+
await mkdir(dirname(`${outDir}/report.json`), { recursive: true });
|
|
529
|
+
await writeFile(`${outDir}/report.json`, `${JSON.stringify(gated, null, 2)}\n`, 'utf8');
|
|
530
|
+
await writeFile(`${outDir}/report.md`, toMarkdown(gated), 'utf8');
|
|
531
|
+
|
|
532
|
+
// A badge is written for every run, including the ones that cannot report a
|
|
533
|
+
// rate — an incomplete run gets a badge that says "incomplete", because the
|
|
534
|
+
// alternative is a stale badge from the last run that could report one.
|
|
535
|
+
const badge = buildBadge(gated, { label: badgeLabel });
|
|
536
|
+
await writeFile(`${outDir}/badge.json`, `${JSON.stringify(badge, null, 2)}\n`, 'utf8');
|
|
537
|
+
await writeFile(`${outDir}/badge.svg`, renderBadgeSvg(badge), 'utf8');
|
|
538
|
+
|
|
539
|
+
console.log(toMarkdown(gated));
|
|
540
|
+
console.error(`report.json, report.md, badge.json and badge.svg written to ${outDir}/ · checkpoint ${checkpointPath}`);
|
|
541
|
+
console.error(`badge: ${badge.label} — ${badge.message}`);
|
|
542
|
+
console.error(`gate: ${gate.summary}`);
|
|
543
|
+
process.exitCode = gate.code;
|
|
544
|
+
}
|