@benchsdk/runner 0.2.0 → 0.3.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/dist/index.js CHANGED
@@ -11,12 +11,101 @@ var TaskError = class extends Error {
11
11
  this.steps = opts?.steps;
12
12
  }
13
13
  };
14
+ function assertNonEmptyString(value, field) {
15
+ if (typeof value !== "string" || value.trim() === "") {
16
+ throw new Error(`${field} must be a non-empty string`);
17
+ }
18
+ return value;
19
+ }
20
+ function assertOnlyAllowedKeys(value, allowed, field) {
21
+ for (const key of Object.keys(value)) {
22
+ if (!allowed.includes(key)) {
23
+ throw new Error(`${field} contains unexpected key: '${key}'`);
24
+ }
25
+ }
26
+ }
14
27
  function assertPositiveInt(value, field) {
15
28
  if (value === void 0) return;
16
29
  if (!Number.isInteger(value) || value < 1) {
17
30
  throw new Error(`${field} must be an integer >= 1 (got ${value})`);
18
31
  }
19
32
  }
33
+ function assertFiniteNumber(value, field) {
34
+ if (typeof value !== "number" || !Number.isFinite(value)) {
35
+ throw new Error(`${field} must be a finite number (got ${value})`);
36
+ }
37
+ return value;
38
+ }
39
+ function validateBenchmarkScoringConfig(scoring, display) {
40
+ if (!Array.isArray(scoring.metrics) || scoring.metrics.length === 0) {
41
+ throw new Error("scoring.metrics must be a non-empty array");
42
+ }
43
+ if (scoring.success !== void 0) {
44
+ const requireData = scoring.success.requireData;
45
+ if (requireData === null || typeof requireData !== "object" || Array.isArray(requireData)) {
46
+ throw new Error("scoring.success.requireData must be a plain object");
47
+ }
48
+ if (Object.keys(requireData).length === 0) {
49
+ throw new Error("scoring.success.requireData must declare at least one data field");
50
+ }
51
+ for (const [key, value] of Object.entries(requireData)) {
52
+ const type = typeof value;
53
+ if (type !== "string" && type !== "number" && type !== "boolean") {
54
+ throw new Error(
55
+ `scoring.success.requireData.${key} must be a string, number, or boolean (got ${type})`
56
+ );
57
+ }
58
+ }
59
+ }
60
+ const seen = /* @__PURE__ */ new Set();
61
+ let totalWeight = 0;
62
+ for (let i = 0; i < scoring.metrics.length; i++) {
63
+ const metric = scoring.metrics[i];
64
+ if (metric === null || typeof metric !== "object" || Array.isArray(metric)) {
65
+ throw new Error(`scoring.metrics[${i}] must be an object`);
66
+ }
67
+ const key = metric.key;
68
+ if (typeof key !== "string" || key.trim() === "") {
69
+ throw new Error(`scoring.metrics[${i}].key must be a non-empty string`);
70
+ }
71
+ if (seen.has(key)) {
72
+ throw new Error(`duplicate scoring metric key: ${key}`);
73
+ }
74
+ seen.add(key);
75
+ const displayMetrics = Array.isArray(display?.metrics) ? display.metrics : void 0;
76
+ const displayMetric = displayMetrics?.find((m) => m?.key === key);
77
+ if (metric.unit !== void 0) {
78
+ if (typeof metric.unit !== "string") {
79
+ throw new Error(`scoring.metrics[${i}].unit must be a string`);
80
+ }
81
+ if (displayMetric?.unit !== void 0 && metric.unit !== displayMetric.unit) {
82
+ throw new Error(
83
+ `scoring.metrics[${i}].unit '${metric.unit}' conflicts with display.metrics[${i}].unit '${displayMetric.unit}' for key '${key}'`
84
+ );
85
+ }
86
+ }
87
+ assertFiniteNumber(metric.ceiling, `scoring.metrics[${i}].ceiling`);
88
+ if (metric.floor !== void 0) {
89
+ assertFiniteNumber(metric.floor, `scoring.metrics[${i}].floor`);
90
+ }
91
+ if (metric.weights === null || typeof metric.weights !== "object" || Array.isArray(metric.weights)) {
92
+ throw new Error(`scoring.metrics[${i}].weights must be an object`);
93
+ }
94
+ const median = assertFiniteNumber(metric.weights.median, `scoring.metrics[${i}].weights.median`);
95
+ const p95 = assertFiniteNumber(metric.weights.p95, `scoring.metrics[${i}].weights.p95`);
96
+ const p99 = assertFiniteNumber(metric.weights.p99, `scoring.metrics[${i}].weights.p99`);
97
+ if (median < 0 || p95 < 0 || p99 < 0) {
98
+ throw new Error(`scoring.metrics[${i}].weights must be non-negative`);
99
+ }
100
+ totalWeight += median + p95 + p99;
101
+ if (metric.trim !== void 0) {
102
+ assertFiniteNumber(metric.trim, `scoring.metrics[${i}].trim`);
103
+ }
104
+ }
105
+ if (Math.abs(totalWeight - 1) > 0.01) {
106
+ throw new Error(`scoring metric weights must sum to 1.0 (got ${totalWeight.toFixed(3)})`);
107
+ }
108
+ }
20
109
  function defineBenchmarkConfig(config) {
21
110
  if (!config.benchmarkSlug || typeof config.benchmarkSlug !== "string") {
22
111
  throw new Error("benchmarkSlug is required");
@@ -64,6 +153,100 @@ function defineBenchmarkConfig(config) {
64
153
  }
65
154
  }
66
155
  }
156
+ if (config.dimensions !== void 0) {
157
+ if (config.dimensions === null || typeof config.dimensions !== "object" || Array.isArray(config.dimensions)) {
158
+ throw new Error("dimensions must be a plain object");
159
+ }
160
+ }
161
+ if (config.scoring !== void 0) {
162
+ validateBenchmarkScoringConfig(config.scoring, config.display);
163
+ }
164
+ if (config.customCliFlags !== void 0) {
165
+ if (!Array.isArray(config.customCliFlags) || !config.customCliFlags.every((f) => typeof f === "string" && f.startsWith("--"))) {
166
+ throw new Error('customCliFlags must be an array of strings starting with "--"');
167
+ }
168
+ }
169
+ if (config.display !== void 0) {
170
+ if (typeof config.display !== "object" || config.display === null || Array.isArray(config.display)) {
171
+ throw new Error("display must be an object");
172
+ }
173
+ assertOnlyAllowedKeys(config.display, ["metrics", "steps", "overview"], "display");
174
+ const displayMetricKeys = /* @__PURE__ */ new Set();
175
+ if (config.display.metrics !== void 0) {
176
+ if (!Array.isArray(config.display.metrics)) {
177
+ throw new Error("display.metrics must be an array");
178
+ }
179
+ for (let i = 0; i < config.display.metrics.length; i++) {
180
+ const metric = config.display.metrics[i];
181
+ if (metric === null || typeof metric !== "object" || Array.isArray(metric)) {
182
+ throw new Error(`display.metrics[${i}] must be an object`);
183
+ }
184
+ assertOnlyAllowedKeys(metric, ["key", "label", "unit", "direction", "decimals", "order"], `display.metrics[${i}]`);
185
+ const key = assertNonEmptyString(metric.key, `display.metrics[${i}].key`);
186
+ if (displayMetricKeys.has(key)) {
187
+ throw new Error(`duplicate display metric key: ${key}`);
188
+ }
189
+ displayMetricKeys.add(key);
190
+ assertNonEmptyString(metric.label, `display.metrics[${i}].label`);
191
+ if (metric.unit !== void 0 && typeof metric.unit !== "string") {
192
+ throw new Error(`display.metrics[${i}].unit must be a string`);
193
+ }
194
+ if (metric.direction !== void 0 && metric.direction !== "higher-better" && metric.direction !== "lower-better") {
195
+ throw new Error(`display.metrics[${i}].direction must be 'higher-better' or 'lower-better'`);
196
+ }
197
+ if (metric.decimals !== void 0 && (!Number.isInteger(metric.decimals) || metric.decimals < 0)) {
198
+ throw new Error(`display.metrics[${i}].decimals must be a non-negative integer`);
199
+ }
200
+ if (metric.order !== void 0 && (!Number.isInteger(metric.order) || metric.order < 0)) {
201
+ throw new Error(`display.metrics[${i}].order must be a non-negative integer`);
202
+ }
203
+ }
204
+ }
205
+ if (config.display.steps !== void 0) {
206
+ if (!Array.isArray(config.display.steps)) {
207
+ throw new Error("display.steps must be an array");
208
+ }
209
+ const seenStepKeys = /* @__PURE__ */ new Set();
210
+ for (let i = 0; i < config.display.steps.length; i++) {
211
+ const step = config.display.steps[i];
212
+ if (step === null || typeof step !== "object" || Array.isArray(step)) {
213
+ throw new Error(`display.steps[${i}] must be an object`);
214
+ }
215
+ assertOnlyAllowedKeys(step, ["key", "label", "order"], `display.steps[${i}]`);
216
+ const key = assertNonEmptyString(step.key, `display.steps[${i}].key`);
217
+ if (seenStepKeys.has(key)) {
218
+ throw new Error(`duplicate display step key: ${key}`);
219
+ }
220
+ seenStepKeys.add(key);
221
+ assertNonEmptyString(step.label, `display.steps[${i}].label`);
222
+ if (step.order !== void 0 && (!Number.isInteger(step.order) || step.order < 0)) {
223
+ throw new Error(`display.steps[${i}].order must be a non-negative integer`);
224
+ }
225
+ }
226
+ }
227
+ if (config.display.overview !== void 0) {
228
+ if (typeof config.display.overview !== "object" || config.display.overview === null || Array.isArray(config.display.overview)) {
229
+ throw new Error("display.overview must be an object");
230
+ }
231
+ assertOnlyAllowedKeys(config.display.overview, ["defaultMetric", "defaultLayout"], "display.overview");
232
+ const { defaultMetric, defaultLayout } = config.display.overview;
233
+ if (defaultMetric !== void 0) {
234
+ const metric = assertNonEmptyString(defaultMetric, "display.overview.defaultMetric");
235
+ const validDefaultMetrics = new Set(displayMetricKeys);
236
+ validDefaultMetrics.add("compositeScore");
237
+ validDefaultMetrics.add("task");
238
+ if (config.display.metrics !== void 0 && !validDefaultMetrics.has(metric)) {
239
+ throw new Error(`display.overview.defaultMetric '${metric}' is not declared in display.metrics and is not a known default (compositeScore, task)`);
240
+ }
241
+ }
242
+ if (defaultLayout !== void 0 && !["ranking", "cards", "chart", "leaderboard"].includes(defaultLayout)) {
243
+ throw new Error("display.overview.defaultLayout must be 'ranking', 'cards', 'chart', or 'leaderboard'");
244
+ }
245
+ }
246
+ }
247
+ if (config.display?.overview?.defaultMetric === "compositeScore" && config.scoring === void 0 && config.onScore === void 0) {
248
+ throw new Error("display.overview.defaultMetric cannot be 'compositeScore' without config.scoring or config.onScore");
249
+ }
67
250
  return config;
68
251
  }
69
252
  function defineTask(task) {
@@ -88,12 +271,15 @@ var NoAvailableParticipantsError = class extends Error {
88
271
  // src/runner.ts
89
272
  import { execSync } from "child_process";
90
273
  import os from "os";
274
+ import { createBenchmarkClient } from "@benchsdk/api";
275
+ import { resolveAuth } from "@benchsdk/cli";
91
276
  import {
92
277
  BenchmarkReporter,
93
- createBenchmarkClient,
278
+ createSystemMetricsCollector,
94
279
  filterParticipantsByEnv,
280
+ runWorker,
95
281
  selectParticipants
96
- } from "@benchsdk/client";
282
+ } from "@benchsdk/worker";
97
283
 
98
284
  // src/scoring.ts
99
285
  function isFiniteNumber(v) {
@@ -149,40 +335,121 @@ var higherIsBetter = (name, opts) => ({
149
335
  ...opts,
150
336
  higherIsBetter: true
151
337
  });
152
- function score(outcome, spec) {
338
+ var WEIGHT_SUM_TOLERANCE = 0.01;
339
+ var ScoringSpecError = class extends Error {
340
+ constructor(message) {
341
+ super(message);
342
+ this.name = "ScoringSpecError";
343
+ }
344
+ };
345
+ function validateScoringSpec(spec) {
346
+ const totalWeight = spec.metrics.reduce(
347
+ (sum, m) => sum + m.weights.median + m.weights.p95 + m.weights.p99,
348
+ 0
349
+ );
350
+ if (Math.abs(totalWeight - 1) > WEIGHT_SUM_TOLERANCE) {
351
+ const breakdown = spec.metrics.map((m) => `${m.name}=${(m.weights.median + m.weights.p95 + m.weights.p99).toFixed(3)}`).join(", ");
352
+ throw new ScoringSpecError(
353
+ `Scoring spec weights sum to ${totalWeight.toFixed(3)}, expected 1.0 (\xB1${WEIGHT_SUM_TOLERANCE}). Each metric's weights.median + weights.p95 + weights.p99, summed across every declared metric, must total 1.0 for compositeScore to stay a meaningful 0-100 scale. Per-metric totals: ${breakdown || "(no metrics declared)"}`
354
+ );
355
+ }
356
+ }
357
+ function groupRecordsByKey(records, key) {
358
+ const groups = /* @__PURE__ */ new Map();
359
+ for (const record of records) {
360
+ const raw = record.data?.[key];
361
+ const value = raw === void 0 ? void 0 : raw;
362
+ const mapKey = value === void 0 ? "__undefined__" : JSON.stringify(value);
363
+ let group = groups.get(mapKey);
364
+ if (!group) {
365
+ group = { value, records: [] };
366
+ groups.set(mapKey, group);
367
+ }
368
+ group.records.push(record);
369
+ }
370
+ return Array.from(groups.values());
371
+ }
372
+ function scoreGroup(records, spec, baseDimensions, groupKey, groupValue, provider, unitByMetric) {
153
373
  const successFilter = spec.success ?? ((r) => r.status === "success");
154
- const dimensions = toJsonObject(spec.dimensions ?? {});
374
+ const passing = records.filter(successFilter);
375
+ const successRate = records.length === 0 ? 0 : passing.length / records.length;
376
+ const skipped = records.length === 0;
377
+ let metricScoresSum = 0;
378
+ const metrics = [];
379
+ for (const metric of spec.metrics) {
380
+ const samples = collectSamples(metric, passing);
381
+ if (samples.length === 0) {
382
+ continue;
383
+ }
384
+ const { median, p95, p99 } = computeStats(samples, metric.trim ?? 0.05);
385
+ const metricScore = metric.weights.median * scoreStat(median, metric) + metric.weights.p95 * scoreStat(p95, metric) + metric.weights.p99 * scoreStat(p99, metric);
386
+ metricScoresSum += metricScore;
387
+ const unitKey = typeof metric.value === "string" ? metric.value : metric.name;
388
+ const unit = metric.unit ?? unitByMetric.get(unitKey) ?? "";
389
+ metrics.push({ name: metric.name, unit, median, p95, p99 });
390
+ }
391
+ const compositeScore = successRate === 0 ? 0 : Math.round(metricScoresSum * successRate * 100) / 100;
392
+ const dimensions = toJsonObject({
393
+ ...baseDimensions,
394
+ ...groupKey !== void 0 && groupValue !== void 0 ? { [groupKey]: groupValue } : {}
395
+ });
396
+ return {
397
+ provider,
398
+ dimensions,
399
+ metrics,
400
+ compositeScore,
401
+ successRate,
402
+ skipped
403
+ };
404
+ }
405
+ function score(outcome, spec, displayMetrics) {
406
+ validateScoringSpec(spec);
407
+ const baseDimensions = toJsonObject(spec.dimensions ?? {});
408
+ const unitByMetric = new Map(displayMetrics?.map((m) => [m.key, m.unit]));
155
409
  const results = [];
156
410
  for (const { participant, records } of outcome.participants) {
157
- const passing = records.filter(successFilter);
158
- const successRate = records.length === 0 ? 0 : passing.length / records.length;
159
- const skipped = records.length === 0;
160
- let metricScoresSum = 0;
161
- const metrics = [];
162
- for (const metric of spec.metrics) {
163
- const samples = collectSamples(metric, passing);
164
- if (samples.length === 0) {
165
- continue;
166
- }
167
- const { median, p95, p99 } = computeStats(samples, metric.trim ?? 0.05);
168
- const metricScore = metric.weights.median * scoreStat(median, metric) + metric.weights.p95 * scoreStat(p95, metric) + metric.weights.p99 * scoreStat(p99, metric);
169
- metricScoresSum += metricScore;
170
- metrics.push({ name: metric.name, unit: metric.unit, median, p95, p99 });
171
- }
172
- const compositeScore = successRate === 0 ? 0 : Math.round(metricScoresSum * successRate * 100) / 100;
173
- results.push({
174
- provider: participant,
175
- dimensions,
176
- metrics,
177
- compositeScore,
178
- successRate,
179
- skipped
180
- });
411
+ const groups = spec.groupBy && records.length > 0 ? [{ value: void 0, records }, ...groupRecordsByKey(records, spec.groupBy)] : [{ value: void 0, records }];
412
+ for (const group of groups) {
413
+ results.push(scoreGroup(group.records, spec, baseDimensions, spec.groupBy, group.value, participant, unitByMetric));
414
+ }
181
415
  }
182
416
  return results;
183
417
  }
418
+ function scoringConfigToSpec(config, dimensions, display) {
419
+ const success = config.success;
420
+ const unitByMetric = new Map(display?.metrics?.map((m) => [m.key, m.unit]));
421
+ return {
422
+ ...dimensions ? { dimensions: toJsonObject(dimensions) } : {},
423
+ ...config.groupBy ? { groupBy: config.groupBy } : {},
424
+ ...success ? {
425
+ success: (record) => record.status === "success" && Object.entries(success.requireData).every(([key, value]) => record.data?.[key] === value)
426
+ } : {},
427
+ metrics: config.metrics.map((metric) => ({
428
+ name: metric.key,
429
+ value: metric.key,
430
+ unit: (display?.metrics === void 0 ? metric.unit : unitByMetric.get(metric.key) ?? metric.unit) ?? "",
431
+ ceiling: metric.ceiling,
432
+ floor: metric.floor,
433
+ higherIsBetter: metric.higherIsBetter,
434
+ weights: metric.weights,
435
+ trim: metric.trim
436
+ }))
437
+ };
438
+ }
184
439
 
185
440
  // src/log-buffer.ts
441
+ var LOG_LEVEL_ORDER = ["debug", "info", "warn", "error"];
442
+ function isLogOptions(value) {
443
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
444
+ const o = value;
445
+ const keys = Object.keys(o);
446
+ if (keys.length === 0) return false;
447
+ if (!keys.every((k) => k === "level" || k === "meta")) return false;
448
+ if (o.level !== void 0 && (typeof o.level !== "string" || !LOG_LEVEL_ORDER.includes(o.level))) {
449
+ return false;
450
+ }
451
+ return true;
452
+ }
186
453
  var LogBuffer = class {
187
454
  lines = [];
188
455
  step(taskIndex, stepName, outcome) {
@@ -199,9 +466,15 @@ var LogBuffer = class {
199
466
  }
200
467
  }
201
468
  /** Appends a free-form narration line (backs the task context's `log`). */
202
- line(message, meta) {
203
- const suffix = meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : "";
204
- this.lines.push(`${(/* @__PURE__ */ new Date()).toISOString()} ${message}${suffix}`);
469
+ line(message, metaOrOptions) {
470
+ const opts = isLogOptions(metaOrOptions) ? { level: metaOrOptions.level ?? "info", meta: metaOrOptions.meta } : { level: "info", meta: metaOrOptions };
471
+ const suffix = opts.meta && Object.keys(opts.meta).length > 0 ? ` ${JSON.stringify(opts.meta)}` : "";
472
+ const taskMatch = message.match(/^(\[task \d+\])\s*(.*)$/);
473
+ if (taskMatch) {
474
+ this.lines.push(`${(/* @__PURE__ */ new Date()).toISOString()} ${taskMatch[1]} [${opts.level}] ${taskMatch[2]}${suffix}`);
475
+ } else {
476
+ this.lines.push(`${(/* @__PURE__ */ new Date()).toISOString()} [${opts.level}] ${message}${suffix}`);
477
+ }
205
478
  }
206
479
  isEmpty() {
207
480
  return this.lines.length === 0;
@@ -215,7 +488,6 @@ function indent(text, prefix = "") {
215
488
  }
216
489
 
217
490
  // src/runner.ts
218
- var DEFAULT_PLATFORM_URL = "https://platform.computesdk.com";
219
491
  function isEnvNoIngest() {
220
492
  const v = process.env.BENCHSDK_NO_INGEST;
221
493
  return v === "1" || v?.toLowerCase() === "true";
@@ -223,8 +495,55 @@ function isEnvNoIngest() {
223
495
  function sleep(ms) {
224
496
  return new Promise((resolve2) => setTimeout(resolve2, ms));
225
497
  }
498
+ function runWithConcurrency(fns, limit) {
499
+ if (limit >= fns.length || fns.length === 0) {
500
+ return Promise.all(fns.map((fn) => fn()));
501
+ }
502
+ const results = new Array(fns.length);
503
+ let running = 0;
504
+ let completed = 0;
505
+ let nextIndex = 0;
506
+ return new Promise((resolve2, reject) => {
507
+ const runNext = () => {
508
+ if (completed === fns.length) {
509
+ resolve2(results);
510
+ return;
511
+ }
512
+ while (running < limit && nextIndex < fns.length) {
513
+ const index = nextIndex++;
514
+ running++;
515
+ fns[index]().then(
516
+ (value) => {
517
+ results[index] = value;
518
+ running--;
519
+ completed++;
520
+ runNext();
521
+ },
522
+ (error) => reject(error)
523
+ );
524
+ }
525
+ };
526
+ runNext();
527
+ });
528
+ }
226
529
  function getErrorCode(error) {
227
- return error instanceof Error && error.name ? error.name : "ERROR";
530
+ if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code) {
531
+ return error.code;
532
+ }
533
+ if (error instanceof Error && error.name) return error.name;
534
+ return "ERROR";
535
+ }
536
+ function isTaskError(error) {
537
+ return error instanceof Error && (error instanceof TaskError || error.name === "TaskError");
538
+ }
539
+ var STEP_OUTCOME_KEYS = /* @__PURE__ */ new Set(["stdout", "stderr", "error", "exitCode", "code", "signal", "pid"]);
540
+ function isStepOutcome(value) {
541
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
542
+ const o = value;
543
+ const keys = Object.keys(o);
544
+ if (keys.length === 0) return false;
545
+ if (!keys.every((k) => STEP_OUTCOME_KEYS.has(k))) return false;
546
+ return typeof o.stdout === "string" || typeof o.stderr === "string" || typeof o.error === "string";
228
547
  }
229
548
  function withTimeout(promise, ms, name) {
230
549
  return new Promise((resolve2, reject) => {
@@ -285,8 +604,10 @@ async function runStepWithClient(clientStep, name, fn, options) {
285
604
  const result = await clientStep(name, () => runStepInvocations(name, fn, options), clientStepOptions);
286
605
  return result;
287
606
  }
288
- function parseCliArgs(argv) {
607
+ function parseCliArgs(argv, allowedCustomFlags) {
289
608
  const args = {};
609
+ const unknown = [];
610
+ const allowed = new Set(allowedCustomFlags ?? []);
290
611
  const readValue = (raw, i) => {
291
612
  const eq = raw.indexOf("=");
292
613
  if (eq !== -1) return { value: raw.slice(eq + 1), nextIndex: i };
@@ -378,22 +699,46 @@ function parseCliArgs(argv) {
378
699
  case "--dry-run":
379
700
  args.noIngest = true;
380
701
  break;
381
- default:
702
+ default: {
703
+ if (allowed.has(name)) {
704
+ if (!arg.includes("=")) {
705
+ const next = argv[i + 1];
706
+ if (next && !next.startsWith("-")) {
707
+ i++;
708
+ }
709
+ }
710
+ } else {
711
+ unknown.push(name);
712
+ if (!arg.includes("=")) {
713
+ const next = argv[i + 1];
714
+ if (next && !next.startsWith("-")) {
715
+ i++;
716
+ }
717
+ }
718
+ }
382
719
  break;
720
+ }
383
721
  }
384
722
  }
723
+ if (unknown.length > 0) {
724
+ throw new Error(`Unknown flag(s): ${unknown.join(", ")}`);
725
+ }
385
726
  if (!args.noIngest && isEnvNoIngest()) {
386
727
  args.noIngest = true;
387
728
  }
388
729
  return args;
389
730
  }
390
731
  function mergeConfig(config, args) {
391
- const phaseTotal = config.phases?.reduce((sum, p) => sum + p.iterations, 0);
392
- if (phaseTotal !== void 0 && args.iterations !== void 0) {
393
- console.warn("--iterations is ignored because this benchmark declares phases.");
732
+ const phases = config.phases;
733
+ const unevenPhases = phases !== void 0 && phases.some((p) => p.iterations !== phases[0].iterations);
734
+ if (unevenPhases && args.iterations !== void 0) {
735
+ console.warn("--iterations is ignored because this benchmark sizes its phases individually.");
394
736
  }
737
+ const phaseIterations = phases !== void 0 && !unevenPhases ? args.iterations : void 0;
738
+ const phaseTotal = phases !== void 0 ? phaseIterations !== void 0 ? phaseIterations * phases.length : phases.reduce((sum, p) => sum + p.iterations, 0) : void 0;
395
739
  const resolved = {
396
740
  iterations: phaseTotal ?? args.iterations ?? config.iterations ?? 1,
741
+ phaseIterations,
397
742
  concurrency: args.concurrency ?? config.concurrency ?? 1,
398
743
  staggerDelayMs: args.staggerDelayMs ?? config.staggerDelayMs ?? 0,
399
744
  groupBy: args.groupBy ?? config.groupBy ?? "participant",
@@ -407,13 +752,16 @@ function mergeConfig(config, args) {
407
752
  }
408
753
  return resolved;
409
754
  }
410
- function buildSchedule(config, iterations, task) {
755
+ function buildSchedule(config, resolved, task) {
411
756
  if (config.phases?.length) {
412
757
  return config.phases.flatMap(
413
- (phase) => Array.from({ length: phase.iterations }, () => ({ phase: phase.name, task }))
758
+ (phase) => Array.from({ length: resolved.phaseIterations ?? phase.iterations }, () => ({
759
+ phase: phase.name,
760
+ task
761
+ }))
414
762
  );
415
763
  }
416
- return Array.from({ length: iterations }, () => ({ phase: void 0, task }));
764
+ return Array.from({ length: resolved.iterations }, () => ({ phase: void 0, task }));
417
765
  }
418
766
  function defaultOnResult(record, meta) {
419
767
  const n = record.taskIndex + 1;
@@ -424,19 +772,6 @@ function defaultOnResult(record, meta) {
424
772
  console.log(` [${meta.participant}] Task ${n}/${meta.iterations}: FAILED \u2014 ${record.errorCode ?? "unknown error"}`);
425
773
  }
426
774
  }
427
- function resolvePlatform() {
428
- const root = (process.env.BENCHMARKS_PLATFORM_URL || DEFAULT_PLATFORM_URL).replace(/\/+$/, "");
429
- const apiKey = process.env.BENCHMARKS_PLATFORM_API_KEY;
430
- if (!apiKey) {
431
- throw new Error(
432
- "BENCHMARKS_PLATFORM_API_KEY is required. Create an org-scoped API key in your organization settings on the platform and set it in your .env."
433
- );
434
- }
435
- return {
436
- baseUrl: `${root}/api/v1`,
437
- apiKey
438
- };
439
- }
440
775
  function resolveShape(config, shapeName) {
441
776
  if (!shapeName) return void 0;
442
777
  const shape = config.shapes?.[shapeName];
@@ -475,23 +810,44 @@ function resolveParticipants(config, resolved) {
475
810
  if (available.length === 0) throw new NoAvailableParticipantsError(skipped);
476
811
  return available;
477
812
  }
813
+ function runConfigToJson(config, resolved, participants) {
814
+ const phases = config.phases?.map((phase) => ({
815
+ name: phase.name,
816
+ iterations: resolved.phaseIterations ?? phase.iterations
817
+ }));
818
+ const runConfig = {
819
+ benchmarkSlug: config.benchmarkSlug,
820
+ benchmarkName: config.benchmarkName,
821
+ ...resolved.phaseIterations !== void 0 ? { phaseIterations: resolved.phaseIterations } : {},
822
+ ...phases ? { phases } : {},
823
+ ...!config.phases ? { iterations: resolved.iterations } : {},
824
+ concurrency: resolved.concurrency,
825
+ staggerDelayMs: resolved.staggerDelayMs,
826
+ groupBy: resolved.groupBy,
827
+ ...config.dimensions ? { dimensions: config.dimensions } : {},
828
+ ...config.scoring ? { scoring: config.scoring } : {},
829
+ participants
830
+ };
831
+ return JSON.parse(JSON.stringify(runConfig));
832
+ }
478
833
  async function runBenchmark(fileConfig, task, argv = []) {
479
- const args = parseCliArgs(argv);
834
+ const args = parseCliArgs(argv, fileConfig.customCliFlags);
480
835
  const noIngest = args.noIngest ?? isEnvNoIngest();
481
836
  const shaped = applyShape(fileConfig, resolveShape(fileConfig, args.shape));
482
837
  const config = applyIdentityOverrides(shaped, args);
483
838
  const resolved = mergeConfig(config, args);
839
+ const auth = await resolveAuth();
840
+ const client = createBenchmarkClient({
841
+ baseUrl: auth.apiBaseUrl,
842
+ apiKey: auth.apiKey,
843
+ token: auth.token,
844
+ orgSlug: auth.orgSlug,
845
+ orgId: auth.orgId
846
+ });
484
847
  const available = resolveParticipants(config, resolved);
485
- let baseUrl = "";
486
- let apiKey = "";
487
- let client = null;
488
- if (!noIngest) {
489
- ({ baseUrl, apiKey } = resolvePlatform());
490
- client = createBenchmarkClient({ baseUrl, apiKey });
491
- }
492
- const schedule = buildSchedule(config, resolved.iterations, task);
848
+ const schedule = buildSchedule(config, resolved, task);
493
849
  const totalTasks = schedule.length;
494
- const concurrencyLabel = resolved.groupBy === "round" ? "n/a (round mode)" : String(resolved.concurrency);
850
+ const concurrencyLabel = String(resolved.concurrency);
495
851
  console.log(`${config.benchmarkName} (self-contained)`);
496
852
  console.log(`Date: ${(/* @__PURE__ */ new Date()).toISOString()}`);
497
853
  if (noIngest) {
@@ -509,16 +865,23 @@ async function runBenchmark(fileConfig, task, argv = []) {
509
865
  dashboardUrl = "";
510
866
  } else {
511
867
  if (identityIsOurs) {
868
+ const benchmarkConfig = {
869
+ ...config.scoring ? { scoring: config.scoring } : {},
870
+ ...config.display ? { display: config.display } : {}
871
+ };
512
872
  await client.upsertBenchmark(config.benchmarkSlug, {
513
- name: config.benchmarkName
873
+ name: config.benchmarkName,
874
+ ...Object.keys(benchmarkConfig).length > 0 ? { config: benchmarkConfig } : {}
514
875
  });
515
876
  }
877
+ const runConfig = client ? runConfigToJson(config, resolved, available.map((p) => p.name)) : {};
516
878
  if (args.runKey) {
517
879
  const { run: run2, organizationSlug } = await client.createRun(config.benchmarkSlug, {
518
- runKey: args.runKey
880
+ runKey: args.runKey,
881
+ config: runConfig
519
882
  });
520
883
  runId = run2.id;
521
- dashboardUrl = dashboardUrlFor(baseUrl, organizationSlug, config.benchmarkSlug, run2.id);
884
+ dashboardUrl = dashboardUrlFor(auth.apiBaseUrl, organizationSlug, config.benchmarkSlug, run2.id);
522
885
  for (const participant of available) {
523
886
  await client.upsertParticipant(config.benchmarkSlug, runId, participant.name, { totalTasks });
524
887
  }
@@ -529,10 +892,11 @@ async function runBenchmark(fileConfig, task, argv = []) {
529
892
  const { run: run2, organizationSlug } = await client.createRun(config.benchmarkSlug, {
530
893
  totalTasks,
531
894
  workerCount: 1,
532
- participants: available.map((p) => p.name)
895
+ participants: available.map((p) => p.name),
896
+ config: runConfig
533
897
  });
534
898
  runId = run2.id;
535
- dashboardUrl = dashboardUrlFor(baseUrl, organizationSlug, config.benchmarkSlug, run2.id);
899
+ dashboardUrl = dashboardUrlFor(auth.apiBaseUrl, organizationSlug, config.benchmarkSlug, run2.id);
536
900
  console.log(`Run created: ${run2.name} (${runId})`);
537
901
  console.log(`View at: ${dashboardUrl}
538
902
  `);
@@ -541,9 +905,9 @@ async function runBenchmark(fileConfig, task, argv = []) {
541
905
  const onResult = defaultOnResult;
542
906
  let participantRecords;
543
907
  if (resolved.groupBy === "round") {
544
- participantRecords = await runGroupedByRound(config, schedule, available, resolved, client, runId, baseUrl, apiKey, onResult, noIngest);
908
+ participantRecords = await runGroupedByRound(config, schedule, available, resolved, client, runId, auth.apiBaseUrl, auth.apiKey, auth.token, auth.orgSlug, auth.orgId, onResult, noIngest);
545
909
  } else {
546
- participantRecords = await runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult);
910
+ participantRecords = await runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult, noIngest);
547
911
  }
548
912
  console.log(`All done. ${noIngest ? "No platform run created." : `View at: ${dashboardUrl}`}`);
549
913
  const outcome = {
@@ -552,10 +916,10 @@ async function runBenchmark(fileConfig, task, argv = []) {
552
916
  participants: participantRecords,
553
917
  config: resolved
554
918
  };
555
- if (client && config.onScore) {
919
+ if (!noIngest && (config.onScore || config.scoring)) {
556
920
  try {
557
- const spec = await config.onScore(lowerIsBetter, higherIsBetter);
558
- const scored = score(outcome, spec);
921
+ const spec = config.onScore ? await config.onScore(lowerIsBetter, higherIsBetter) : scoringConfigToSpec(config.scoring, config.dimensions, config.display);
922
+ const scored = score(outcome, spec, config.display?.metrics);
559
923
  const run2 = {
560
924
  gitSha: process.env.GITHUB_SHA ?? getGitSha(),
561
925
  gitRef: process.env.GITHUB_REF_NAME ?? process.env.GITHUB_REF ?? getGitRef(),
@@ -564,8 +928,13 @@ async function runBenchmark(fileConfig, task, argv = []) {
564
928
  platform: os.platform(),
565
929
  arch: os.arch()
566
930
  };
567
- await client.submitRunSummary(config.benchmarkSlug, runId, { run: run2, results: scored });
931
+ await client.submitRunSummary(config.benchmarkSlug, runId, {
932
+ run: run2,
933
+ results: scored,
934
+ ...config.scoring ? { scoring: config.scoring } : {}
935
+ });
568
936
  } catch (err) {
937
+ if (err instanceof ScoringSpecError) throw err;
569
938
  const message = err instanceof Error ? err.message : String(err);
570
939
  console.warn(`[benchsdk-runner] failed to submit run summary: ${message}`);
571
940
  }
@@ -590,13 +959,13 @@ function getGitRef() {
590
959
  return void 0;
591
960
  }
592
961
  }
593
- async function runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult) {
962
+ async function runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult, noIngest) {
594
963
  const participantRecords = [];
595
964
  for (const participant of available) {
596
965
  console.log(`${"=".repeat(70)}`);
597
966
  console.log(` Participant: ${participant.name}`);
598
967
  console.log("=".repeat(70));
599
- if (!client) {
968
+ if (noIngest || !client) {
600
969
  const records = [];
601
970
  let rampStartMs2;
602
971
  let nextIndex = 0;
@@ -628,7 +997,7 @@ async function runGroupedByParticipant(config, schedule, available, resolved, cl
628
997
  }
629
998
  let rampStartMs;
630
999
  await client.planWorkers(config.benchmarkSlug, runId, participant.name);
631
- const result = await client.runWorker({
1000
+ const result = await runWorker(client, {
632
1001
  benchmarkSlug: config.benchmarkSlug,
633
1002
  runId,
634
1003
  participantSlug: participant.name,
@@ -641,16 +1010,21 @@ async function runGroupedByParticipant(config, schedule, available, resolved, cl
641
1010
  if (waitMs > 0) await sleep(waitMs);
642
1011
  }
643
1012
  const slot = schedule[scheduleIndex];
644
- const taskResult = await slot.task({
645
- participant,
646
- taskIndex: scheduleIndex,
647
- phase: slot.phase,
648
- step: (name, fn, options) => runStepWithClient(ctx.step, name, fn, options),
649
- measure: ctx.measure,
650
- log: ctx.log
651
- });
652
1013
  if (slot.phase) ctx.measure({ phase: slot.phase });
653
- return taskResult?.data;
1014
+ try {
1015
+ const taskResult = await slot.task({
1016
+ participant,
1017
+ taskIndex: scheduleIndex,
1018
+ phase: slot.phase,
1019
+ step: (name, fn, options) => runStepWithClient(ctx.step, name, fn, options),
1020
+ measure: ctx.measure,
1021
+ log: ctx.log
1022
+ });
1023
+ return taskResult?.data;
1024
+ } catch (error) {
1025
+ if (isTaskError(error) && error.data) ctx.measure(error.data);
1026
+ throw error;
1027
+ }
654
1028
  },
655
1029
  onResult: (record) => onResult(record, { iterations: schedule.length, participant: participant.name })
656
1030
  });
@@ -666,14 +1040,17 @@ async function runGroupedByParticipant(config, schedule, available, resolved, cl
666
1040
  }
667
1041
  return participantRecords;
668
1042
  }
669
- async function runGroupedByRound(config, schedule, available, resolved, client, runId, baseUrl, apiKey, onResult, noIngest = false) {
1043
+ async function runGroupedByRound(config, schedule, available, resolved, client, runId, baseUrl, apiKey, token, orgSlug, orgId, onResult, noIngest = false) {
670
1044
  const reporters = /* @__PURE__ */ new Map();
671
1045
  const logBuffers = /* @__PURE__ */ new Map();
672
1046
  const failed = /* @__PURE__ */ new Map();
673
1047
  const recordsByParticipant = /* @__PURE__ */ new Map();
1048
+ let metricsCollector;
1049
+ const metricsSamples = [];
674
1050
  for (const participant of available) {
675
1051
  logBuffers.set(participant.name, new LogBuffer());
676
1052
  failed.set(participant.name, false);
1053
+ recordsByParticipant.set(participant.name, []);
677
1054
  if (noIngest || !client) {
678
1055
  reporters.set(participant.name, null);
679
1056
  continue;
@@ -687,6 +1064,9 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
687
1064
  reporter = await BenchmarkReporter.claim({
688
1065
  baseUrl,
689
1066
  apiKey,
1067
+ token,
1068
+ orgSlug,
1069
+ orgId,
690
1070
  benchmarkSlug: config.benchmarkSlug,
691
1071
  runId,
692
1072
  participantSlug: participant.name,
@@ -700,6 +1080,10 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
700
1080
  console.warn(` ${participant.name}: could not claim a platform worker \u2014 running without platform reporting.`);
701
1081
  }
702
1082
  reporters.set(participant.name, reporter);
1083
+ if (reporter && !metricsCollector) {
1084
+ metricsCollector = createSystemMetricsCollector();
1085
+ metricsSamples.push(await metricsCollector.sample());
1086
+ }
703
1087
  }
704
1088
  console.log(`Interleaving ${available.length} participant(s), ${schedule.length} round(s) each.
705
1089
  `);
@@ -708,7 +1092,7 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
708
1092
  if (resolved.staggerDelayMs > 0 && i > 0) {
709
1093
  await sleep(resolved.staggerDelayMs);
710
1094
  }
711
- for (const participant of available) {
1095
+ const roundFns = available.map((participant) => async () => {
712
1096
  const reporter = reporters.get(participant.name) ?? null;
713
1097
  const logBuffer = logBuffers.get(participant.name);
714
1098
  const record = await runTaskRecord(
@@ -722,9 +1106,6 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
722
1106
  if (record.status !== "success") failed.set(participant.name, true);
723
1107
  onResult(record, { iterations: schedule.length, participant: participant.name });
724
1108
  reporter?.recordResult(record);
725
- if (!recordsByParticipant.has(participant.name)) {
726
- recordsByParticipant.set(participant.name, []);
727
- }
728
1109
  const participantRecords = recordsByParticipant.get(participant.name);
729
1110
  participantRecords.push(record);
730
1111
  if (reporter) {
@@ -736,7 +1117,22 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
736
1117
  });
737
1118
  await reporter.heartbeat();
738
1119
  }
739
- }
1120
+ });
1121
+ await runWithConcurrency(roundFns, resolved.concurrency);
1122
+ if (metricsCollector) metricsSamples.push(await metricsCollector.sample());
1123
+ }
1124
+ if (metricsCollector) metricsSamples.push(await metricsCollector.sample());
1125
+ metricsCollector?.stop();
1126
+ const metricsReporter = available.map((p) => reporters.get(p.name)).find((r) => Boolean(r));
1127
+ if (metricsReporter && metricsSamples.length > 0) {
1128
+ await metricsReporter.uploadArtifact({
1129
+ kind: "system-metrics",
1130
+ contentType: "application/x-ndjson",
1131
+ name: "metrics.jsonl",
1132
+ metadata: { scope: "shared-process", participants: available.map((p) => p.name) },
1133
+ body: metricsSamples.map((sample) => JSON.stringify(sample)).join("\n") + "\n"
1134
+ }).catch(() => {
1135
+ });
740
1136
  }
741
1137
  for (const participant of available) {
742
1138
  const reporter = reporters.get(participant.name) ?? null;
@@ -783,11 +1179,12 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
783
1179
  activeStep = stepRecord;
784
1180
  try {
785
1181
  const result2 = await runStepInvocations(name, fn, options);
786
- logBuffer.step(taskIndex, name, {});
1182
+ const outcome = options?.captureOutput !== false && !Array.isArray(result2) && isStepOutcome(result2) ? result2 : {};
1183
+ logBuffer.step(taskIndex, name, outcome);
787
1184
  return result2;
788
1185
  } catch (error) {
789
1186
  stepRecord.status = "error";
790
- stepRecord.errorCode = error instanceof TaskError ? error.code ?? error.name : getErrorCode(error);
1187
+ stepRecord.errorCode = isTaskError(error) ? error.code ?? error.name : getErrorCode(error);
791
1188
  logBuffer.step(taskIndex, name, { error: error instanceof Error ? error.message : String(error) });
792
1189
  throw error;
793
1190
  } finally {
@@ -804,8 +1201,8 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
804
1201
  Object.assign(taskMeasures, data);
805
1202
  }
806
1203
  },
807
- log(message, meta) {
808
- logBuffer.line(`[task ${taskIndex}] ${message}`, meta);
1204
+ log(message, metaOrOptions) {
1205
+ logBuffer.line(`[task ${taskIndex}] ${message}`, metaOrOptions);
809
1206
  }
810
1207
  };
811
1208
  let result = void 0;
@@ -814,7 +1211,7 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
814
1211
  record.data = mergeData({ ...taskMeasures, ...result?.data ?? {} }, phase);
815
1212
  } catch (error) {
816
1213
  record.status = "error";
817
- if (error instanceof TaskError) {
1214
+ if (isTaskError(error)) {
818
1215
  record.errorCode = error.code ?? error.name;
819
1216
  record.data = mergeData({ ...taskMeasures, ...error.data ?? {} }, phase);
820
1217
  if (error.steps?.length) frameworkSteps.push(...error.steps);
@@ -850,6 +1247,7 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
850
1247
  // src/cli.ts
851
1248
  import { resolve } from "path";
852
1249
  import { pathToFileURL } from "url";
1250
+ import { run as runPlatformCli } from "@benchsdk/cli";
853
1251
  var USAGE = 'Usage:\n bench run <file.bench.ts> [--shape name] [--provider a,b] [--run-key key]\n [--benchmark slug] [--name "My benchmark"]\n [--iterations N] [--concurrency N] [--stagger-delay-ms N] [--group-by participant|round]\n [--no-ingest | --dry-run]';
854
1252
  function isBenchmarkConfig(value) {
855
1253
  if (typeof value !== "object" || value === null) return false;
@@ -872,6 +1270,9 @@ async function runBenchmarkFile(argv) {
872
1270
  await runBenchmark(config, task, flags);
873
1271
  }
874
1272
  async function run(argv) {
1273
+ if (argv[0] !== "run") {
1274
+ return runPlatformCli(argv);
1275
+ }
875
1276
  try {
876
1277
  await runBenchmarkFile(argv);
877
1278
  process.exit(0);
@@ -884,8 +1285,13 @@ async function run(argv) {
884
1285
  process.exit(1);
885
1286
  }
886
1287
  }
1288
+
1289
+ // src/index.ts
1290
+ var BENCHSDK_RUNNER_VERSION = "0.3.0";
887
1291
  export {
1292
+ BENCHSDK_RUNNER_VERSION,
888
1293
  NoAvailableParticipantsError,
1294
+ ScoringSpecError,
889
1295
  TaskError,
890
1296
  defineBenchmarkConfig,
891
1297
  defineTask,
@@ -896,6 +1302,8 @@ export {
896
1302
  run,
897
1303
  runBenchmark,
898
1304
  runBenchmarkFile,
899
- score
1305
+ score,
1306
+ scoringConfigToSpec,
1307
+ validateScoringSpec
900
1308
  };
901
1309
  //# sourceMappingURL=index.js.map