@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.cjs CHANGED
@@ -30,7 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ BENCHSDK_RUNNER_VERSION: () => BENCHSDK_RUNNER_VERSION,
33
34
  NoAvailableParticipantsError: () => NoAvailableParticipantsError,
35
+ ScoringSpecError: () => ScoringSpecError,
34
36
  TaskError: () => TaskError,
35
37
  defineBenchmarkConfig: () => defineBenchmarkConfig,
36
38
  defineTask: () => defineTask,
@@ -41,7 +43,9 @@ __export(src_exports, {
41
43
  run: () => run,
42
44
  runBenchmark: () => runBenchmark,
43
45
  runBenchmarkFile: () => runBenchmarkFile,
44
- score: () => score
46
+ score: () => score,
47
+ scoringConfigToSpec: () => scoringConfigToSpec,
48
+ validateScoringSpec: () => validateScoringSpec
45
49
  });
46
50
  module.exports = __toCommonJS(src_exports);
47
51
 
@@ -58,12 +62,101 @@ var TaskError = class extends Error {
58
62
  this.steps = opts?.steps;
59
63
  }
60
64
  };
65
+ function assertNonEmptyString(value, field) {
66
+ if (typeof value !== "string" || value.trim() === "") {
67
+ throw new Error(`${field} must be a non-empty string`);
68
+ }
69
+ return value;
70
+ }
71
+ function assertOnlyAllowedKeys(value, allowed, field) {
72
+ for (const key of Object.keys(value)) {
73
+ if (!allowed.includes(key)) {
74
+ throw new Error(`${field} contains unexpected key: '${key}'`);
75
+ }
76
+ }
77
+ }
61
78
  function assertPositiveInt(value, field) {
62
79
  if (value === void 0) return;
63
80
  if (!Number.isInteger(value) || value < 1) {
64
81
  throw new Error(`${field} must be an integer >= 1 (got ${value})`);
65
82
  }
66
83
  }
84
+ function assertFiniteNumber(value, field) {
85
+ if (typeof value !== "number" || !Number.isFinite(value)) {
86
+ throw new Error(`${field} must be a finite number (got ${value})`);
87
+ }
88
+ return value;
89
+ }
90
+ function validateBenchmarkScoringConfig(scoring, display) {
91
+ if (!Array.isArray(scoring.metrics) || scoring.metrics.length === 0) {
92
+ throw new Error("scoring.metrics must be a non-empty array");
93
+ }
94
+ if (scoring.success !== void 0) {
95
+ const requireData = scoring.success.requireData;
96
+ if (requireData === null || typeof requireData !== "object" || Array.isArray(requireData)) {
97
+ throw new Error("scoring.success.requireData must be a plain object");
98
+ }
99
+ if (Object.keys(requireData).length === 0) {
100
+ throw new Error("scoring.success.requireData must declare at least one data field");
101
+ }
102
+ for (const [key, value] of Object.entries(requireData)) {
103
+ const type = typeof value;
104
+ if (type !== "string" && type !== "number" && type !== "boolean") {
105
+ throw new Error(
106
+ `scoring.success.requireData.${key} must be a string, number, or boolean (got ${type})`
107
+ );
108
+ }
109
+ }
110
+ }
111
+ const seen = /* @__PURE__ */ new Set();
112
+ let totalWeight = 0;
113
+ for (let i = 0; i < scoring.metrics.length; i++) {
114
+ const metric = scoring.metrics[i];
115
+ if (metric === null || typeof metric !== "object" || Array.isArray(metric)) {
116
+ throw new Error(`scoring.metrics[${i}] must be an object`);
117
+ }
118
+ const key = metric.key;
119
+ if (typeof key !== "string" || key.trim() === "") {
120
+ throw new Error(`scoring.metrics[${i}].key must be a non-empty string`);
121
+ }
122
+ if (seen.has(key)) {
123
+ throw new Error(`duplicate scoring metric key: ${key}`);
124
+ }
125
+ seen.add(key);
126
+ const displayMetrics = Array.isArray(display?.metrics) ? display.metrics : void 0;
127
+ const displayMetric = displayMetrics?.find((m) => m?.key === key);
128
+ if (metric.unit !== void 0) {
129
+ if (typeof metric.unit !== "string") {
130
+ throw new Error(`scoring.metrics[${i}].unit must be a string`);
131
+ }
132
+ if (displayMetric?.unit !== void 0 && metric.unit !== displayMetric.unit) {
133
+ throw new Error(
134
+ `scoring.metrics[${i}].unit '${metric.unit}' conflicts with display.metrics[${i}].unit '${displayMetric.unit}' for key '${key}'`
135
+ );
136
+ }
137
+ }
138
+ assertFiniteNumber(metric.ceiling, `scoring.metrics[${i}].ceiling`);
139
+ if (metric.floor !== void 0) {
140
+ assertFiniteNumber(metric.floor, `scoring.metrics[${i}].floor`);
141
+ }
142
+ if (metric.weights === null || typeof metric.weights !== "object" || Array.isArray(metric.weights)) {
143
+ throw new Error(`scoring.metrics[${i}].weights must be an object`);
144
+ }
145
+ const median = assertFiniteNumber(metric.weights.median, `scoring.metrics[${i}].weights.median`);
146
+ const p95 = assertFiniteNumber(metric.weights.p95, `scoring.metrics[${i}].weights.p95`);
147
+ const p99 = assertFiniteNumber(metric.weights.p99, `scoring.metrics[${i}].weights.p99`);
148
+ if (median < 0 || p95 < 0 || p99 < 0) {
149
+ throw new Error(`scoring.metrics[${i}].weights must be non-negative`);
150
+ }
151
+ totalWeight += median + p95 + p99;
152
+ if (metric.trim !== void 0) {
153
+ assertFiniteNumber(metric.trim, `scoring.metrics[${i}].trim`);
154
+ }
155
+ }
156
+ if (Math.abs(totalWeight - 1) > 0.01) {
157
+ throw new Error(`scoring metric weights must sum to 1.0 (got ${totalWeight.toFixed(3)})`);
158
+ }
159
+ }
67
160
  function defineBenchmarkConfig(config) {
68
161
  if (!config.benchmarkSlug || typeof config.benchmarkSlug !== "string") {
69
162
  throw new Error("benchmarkSlug is required");
@@ -111,6 +204,100 @@ function defineBenchmarkConfig(config) {
111
204
  }
112
205
  }
113
206
  }
207
+ if (config.dimensions !== void 0) {
208
+ if (config.dimensions === null || typeof config.dimensions !== "object" || Array.isArray(config.dimensions)) {
209
+ throw new Error("dimensions must be a plain object");
210
+ }
211
+ }
212
+ if (config.scoring !== void 0) {
213
+ validateBenchmarkScoringConfig(config.scoring, config.display);
214
+ }
215
+ if (config.customCliFlags !== void 0) {
216
+ if (!Array.isArray(config.customCliFlags) || !config.customCliFlags.every((f) => typeof f === "string" && f.startsWith("--"))) {
217
+ throw new Error('customCliFlags must be an array of strings starting with "--"');
218
+ }
219
+ }
220
+ if (config.display !== void 0) {
221
+ if (typeof config.display !== "object" || config.display === null || Array.isArray(config.display)) {
222
+ throw new Error("display must be an object");
223
+ }
224
+ assertOnlyAllowedKeys(config.display, ["metrics", "steps", "overview"], "display");
225
+ const displayMetricKeys = /* @__PURE__ */ new Set();
226
+ if (config.display.metrics !== void 0) {
227
+ if (!Array.isArray(config.display.metrics)) {
228
+ throw new Error("display.metrics must be an array");
229
+ }
230
+ for (let i = 0; i < config.display.metrics.length; i++) {
231
+ const metric = config.display.metrics[i];
232
+ if (metric === null || typeof metric !== "object" || Array.isArray(metric)) {
233
+ throw new Error(`display.metrics[${i}] must be an object`);
234
+ }
235
+ assertOnlyAllowedKeys(metric, ["key", "label", "unit", "direction", "decimals", "order"], `display.metrics[${i}]`);
236
+ const key = assertNonEmptyString(metric.key, `display.metrics[${i}].key`);
237
+ if (displayMetricKeys.has(key)) {
238
+ throw new Error(`duplicate display metric key: ${key}`);
239
+ }
240
+ displayMetricKeys.add(key);
241
+ assertNonEmptyString(metric.label, `display.metrics[${i}].label`);
242
+ if (metric.unit !== void 0 && typeof metric.unit !== "string") {
243
+ throw new Error(`display.metrics[${i}].unit must be a string`);
244
+ }
245
+ if (metric.direction !== void 0 && metric.direction !== "higher-better" && metric.direction !== "lower-better") {
246
+ throw new Error(`display.metrics[${i}].direction must be 'higher-better' or 'lower-better'`);
247
+ }
248
+ if (metric.decimals !== void 0 && (!Number.isInteger(metric.decimals) || metric.decimals < 0)) {
249
+ throw new Error(`display.metrics[${i}].decimals must be a non-negative integer`);
250
+ }
251
+ if (metric.order !== void 0 && (!Number.isInteger(metric.order) || metric.order < 0)) {
252
+ throw new Error(`display.metrics[${i}].order must be a non-negative integer`);
253
+ }
254
+ }
255
+ }
256
+ if (config.display.steps !== void 0) {
257
+ if (!Array.isArray(config.display.steps)) {
258
+ throw new Error("display.steps must be an array");
259
+ }
260
+ const seenStepKeys = /* @__PURE__ */ new Set();
261
+ for (let i = 0; i < config.display.steps.length; i++) {
262
+ const step = config.display.steps[i];
263
+ if (step === null || typeof step !== "object" || Array.isArray(step)) {
264
+ throw new Error(`display.steps[${i}] must be an object`);
265
+ }
266
+ assertOnlyAllowedKeys(step, ["key", "label", "order"], `display.steps[${i}]`);
267
+ const key = assertNonEmptyString(step.key, `display.steps[${i}].key`);
268
+ if (seenStepKeys.has(key)) {
269
+ throw new Error(`duplicate display step key: ${key}`);
270
+ }
271
+ seenStepKeys.add(key);
272
+ assertNonEmptyString(step.label, `display.steps[${i}].label`);
273
+ if (step.order !== void 0 && (!Number.isInteger(step.order) || step.order < 0)) {
274
+ throw new Error(`display.steps[${i}].order must be a non-negative integer`);
275
+ }
276
+ }
277
+ }
278
+ if (config.display.overview !== void 0) {
279
+ if (typeof config.display.overview !== "object" || config.display.overview === null || Array.isArray(config.display.overview)) {
280
+ throw new Error("display.overview must be an object");
281
+ }
282
+ assertOnlyAllowedKeys(config.display.overview, ["defaultMetric", "defaultLayout"], "display.overview");
283
+ const { defaultMetric, defaultLayout } = config.display.overview;
284
+ if (defaultMetric !== void 0) {
285
+ const metric = assertNonEmptyString(defaultMetric, "display.overview.defaultMetric");
286
+ const validDefaultMetrics = new Set(displayMetricKeys);
287
+ validDefaultMetrics.add("compositeScore");
288
+ validDefaultMetrics.add("task");
289
+ if (config.display.metrics !== void 0 && !validDefaultMetrics.has(metric)) {
290
+ throw new Error(`display.overview.defaultMetric '${metric}' is not declared in display.metrics and is not a known default (compositeScore, task)`);
291
+ }
292
+ }
293
+ if (defaultLayout !== void 0 && !["ranking", "cards", "chart", "leaderboard"].includes(defaultLayout)) {
294
+ throw new Error("display.overview.defaultLayout must be 'ranking', 'cards', 'chart', or 'leaderboard'");
295
+ }
296
+ }
297
+ }
298
+ if (config.display?.overview?.defaultMetric === "compositeScore" && config.scoring === void 0 && config.onScore === void 0) {
299
+ throw new Error("display.overview.defaultMetric cannot be 'compositeScore' without config.scoring or config.onScore");
300
+ }
114
301
  return config;
115
302
  }
116
303
  function defineTask(task) {
@@ -135,7 +322,9 @@ var NoAvailableParticipantsError = class extends Error {
135
322
  // src/runner.ts
136
323
  var import_node_child_process = require("child_process");
137
324
  var import_node_os = __toESM(require("os"), 1);
138
- var import_client = require("@benchsdk/client");
325
+ var import_api = require("@benchsdk/api");
326
+ var import_cli = require("@benchsdk/cli");
327
+ var import_worker = require("@benchsdk/worker");
139
328
 
140
329
  // src/scoring.ts
141
330
  function isFiniteNumber(v) {
@@ -191,40 +380,121 @@ var higherIsBetter = (name, opts) => ({
191
380
  ...opts,
192
381
  higherIsBetter: true
193
382
  });
194
- function score(outcome, spec) {
383
+ var WEIGHT_SUM_TOLERANCE = 0.01;
384
+ var ScoringSpecError = class extends Error {
385
+ constructor(message) {
386
+ super(message);
387
+ this.name = "ScoringSpecError";
388
+ }
389
+ };
390
+ function validateScoringSpec(spec) {
391
+ const totalWeight = spec.metrics.reduce(
392
+ (sum, m) => sum + m.weights.median + m.weights.p95 + m.weights.p99,
393
+ 0
394
+ );
395
+ if (Math.abs(totalWeight - 1) > WEIGHT_SUM_TOLERANCE) {
396
+ const breakdown = spec.metrics.map((m) => `${m.name}=${(m.weights.median + m.weights.p95 + m.weights.p99).toFixed(3)}`).join(", ");
397
+ throw new ScoringSpecError(
398
+ `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)"}`
399
+ );
400
+ }
401
+ }
402
+ function groupRecordsByKey(records, key) {
403
+ const groups = /* @__PURE__ */ new Map();
404
+ for (const record of records) {
405
+ const raw = record.data?.[key];
406
+ const value = raw === void 0 ? void 0 : raw;
407
+ const mapKey = value === void 0 ? "__undefined__" : JSON.stringify(value);
408
+ let group = groups.get(mapKey);
409
+ if (!group) {
410
+ group = { value, records: [] };
411
+ groups.set(mapKey, group);
412
+ }
413
+ group.records.push(record);
414
+ }
415
+ return Array.from(groups.values());
416
+ }
417
+ function scoreGroup(records, spec, baseDimensions, groupKey, groupValue, provider, unitByMetric) {
195
418
  const successFilter = spec.success ?? ((r) => r.status === "success");
196
- const dimensions = toJsonObject(spec.dimensions ?? {});
419
+ const passing = records.filter(successFilter);
420
+ const successRate = records.length === 0 ? 0 : passing.length / records.length;
421
+ const skipped = records.length === 0;
422
+ let metricScoresSum = 0;
423
+ const metrics = [];
424
+ for (const metric of spec.metrics) {
425
+ const samples = collectSamples(metric, passing);
426
+ if (samples.length === 0) {
427
+ continue;
428
+ }
429
+ const { median, p95, p99 } = computeStats(samples, metric.trim ?? 0.05);
430
+ const metricScore = metric.weights.median * scoreStat(median, metric) + metric.weights.p95 * scoreStat(p95, metric) + metric.weights.p99 * scoreStat(p99, metric);
431
+ metricScoresSum += metricScore;
432
+ const unitKey = typeof metric.value === "string" ? metric.value : metric.name;
433
+ const unit = metric.unit ?? unitByMetric.get(unitKey) ?? "";
434
+ metrics.push({ name: metric.name, unit, median, p95, p99 });
435
+ }
436
+ const compositeScore = successRate === 0 ? 0 : Math.round(metricScoresSum * successRate * 100) / 100;
437
+ const dimensions = toJsonObject({
438
+ ...baseDimensions,
439
+ ...groupKey !== void 0 && groupValue !== void 0 ? { [groupKey]: groupValue } : {}
440
+ });
441
+ return {
442
+ provider,
443
+ dimensions,
444
+ metrics,
445
+ compositeScore,
446
+ successRate,
447
+ skipped
448
+ };
449
+ }
450
+ function score(outcome, spec, displayMetrics) {
451
+ validateScoringSpec(spec);
452
+ const baseDimensions = toJsonObject(spec.dimensions ?? {});
453
+ const unitByMetric = new Map(displayMetrics?.map((m) => [m.key, m.unit]));
197
454
  const results = [];
198
455
  for (const { participant, records } of outcome.participants) {
199
- const passing = records.filter(successFilter);
200
- const successRate = records.length === 0 ? 0 : passing.length / records.length;
201
- const skipped = records.length === 0;
202
- let metricScoresSum = 0;
203
- const metrics = [];
204
- for (const metric of spec.metrics) {
205
- const samples = collectSamples(metric, passing);
206
- if (samples.length === 0) {
207
- continue;
208
- }
209
- const { median, p95, p99 } = computeStats(samples, metric.trim ?? 0.05);
210
- const metricScore = metric.weights.median * scoreStat(median, metric) + metric.weights.p95 * scoreStat(p95, metric) + metric.weights.p99 * scoreStat(p99, metric);
211
- metricScoresSum += metricScore;
212
- metrics.push({ name: metric.name, unit: metric.unit, median, p95, p99 });
213
- }
214
- const compositeScore = successRate === 0 ? 0 : Math.round(metricScoresSum * successRate * 100) / 100;
215
- results.push({
216
- provider: participant,
217
- dimensions,
218
- metrics,
219
- compositeScore,
220
- successRate,
221
- skipped
222
- });
456
+ const groups = spec.groupBy && records.length > 0 ? [{ value: void 0, records }, ...groupRecordsByKey(records, spec.groupBy)] : [{ value: void 0, records }];
457
+ for (const group of groups) {
458
+ results.push(scoreGroup(group.records, spec, baseDimensions, spec.groupBy, group.value, participant, unitByMetric));
459
+ }
223
460
  }
224
461
  return results;
225
462
  }
463
+ function scoringConfigToSpec(config, dimensions, display) {
464
+ const success = config.success;
465
+ const unitByMetric = new Map(display?.metrics?.map((m) => [m.key, m.unit]));
466
+ return {
467
+ ...dimensions ? { dimensions: toJsonObject(dimensions) } : {},
468
+ ...config.groupBy ? { groupBy: config.groupBy } : {},
469
+ ...success ? {
470
+ success: (record) => record.status === "success" && Object.entries(success.requireData).every(([key, value]) => record.data?.[key] === value)
471
+ } : {},
472
+ metrics: config.metrics.map((metric) => ({
473
+ name: metric.key,
474
+ value: metric.key,
475
+ unit: (display?.metrics === void 0 ? metric.unit : unitByMetric.get(metric.key) ?? metric.unit) ?? "",
476
+ ceiling: metric.ceiling,
477
+ floor: metric.floor,
478
+ higherIsBetter: metric.higherIsBetter,
479
+ weights: metric.weights,
480
+ trim: metric.trim
481
+ }))
482
+ };
483
+ }
226
484
 
227
485
  // src/log-buffer.ts
486
+ var LOG_LEVEL_ORDER = ["debug", "info", "warn", "error"];
487
+ function isLogOptions(value) {
488
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
489
+ const o = value;
490
+ const keys = Object.keys(o);
491
+ if (keys.length === 0) return false;
492
+ if (!keys.every((k) => k === "level" || k === "meta")) return false;
493
+ if (o.level !== void 0 && (typeof o.level !== "string" || !LOG_LEVEL_ORDER.includes(o.level))) {
494
+ return false;
495
+ }
496
+ return true;
497
+ }
228
498
  var LogBuffer = class {
229
499
  lines = [];
230
500
  step(taskIndex, stepName, outcome) {
@@ -241,9 +511,15 @@ var LogBuffer = class {
241
511
  }
242
512
  }
243
513
  /** Appends a free-form narration line (backs the task context's `log`). */
244
- line(message, meta) {
245
- const suffix = meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : "";
246
- this.lines.push(`${(/* @__PURE__ */ new Date()).toISOString()} ${message}${suffix}`);
514
+ line(message, metaOrOptions) {
515
+ const opts = isLogOptions(metaOrOptions) ? { level: metaOrOptions.level ?? "info", meta: metaOrOptions.meta } : { level: "info", meta: metaOrOptions };
516
+ const suffix = opts.meta && Object.keys(opts.meta).length > 0 ? ` ${JSON.stringify(opts.meta)}` : "";
517
+ const taskMatch = message.match(/^(\[task \d+\])\s*(.*)$/);
518
+ if (taskMatch) {
519
+ this.lines.push(`${(/* @__PURE__ */ new Date()).toISOString()} ${taskMatch[1]} [${opts.level}] ${taskMatch[2]}${suffix}`);
520
+ } else {
521
+ this.lines.push(`${(/* @__PURE__ */ new Date()).toISOString()} [${opts.level}] ${message}${suffix}`);
522
+ }
247
523
  }
248
524
  isEmpty() {
249
525
  return this.lines.length === 0;
@@ -257,7 +533,6 @@ function indent(text, prefix = "") {
257
533
  }
258
534
 
259
535
  // src/runner.ts
260
- var DEFAULT_PLATFORM_URL = "https://platform.computesdk.com";
261
536
  function isEnvNoIngest() {
262
537
  const v = process.env.BENCHSDK_NO_INGEST;
263
538
  return v === "1" || v?.toLowerCase() === "true";
@@ -265,8 +540,55 @@ function isEnvNoIngest() {
265
540
  function sleep(ms) {
266
541
  return new Promise((resolve2) => setTimeout(resolve2, ms));
267
542
  }
543
+ function runWithConcurrency(fns, limit) {
544
+ if (limit >= fns.length || fns.length === 0) {
545
+ return Promise.all(fns.map((fn) => fn()));
546
+ }
547
+ const results = new Array(fns.length);
548
+ let running = 0;
549
+ let completed = 0;
550
+ let nextIndex = 0;
551
+ return new Promise((resolve2, reject) => {
552
+ const runNext = () => {
553
+ if (completed === fns.length) {
554
+ resolve2(results);
555
+ return;
556
+ }
557
+ while (running < limit && nextIndex < fns.length) {
558
+ const index = nextIndex++;
559
+ running++;
560
+ fns[index]().then(
561
+ (value) => {
562
+ results[index] = value;
563
+ running--;
564
+ completed++;
565
+ runNext();
566
+ },
567
+ (error) => reject(error)
568
+ );
569
+ }
570
+ };
571
+ runNext();
572
+ });
573
+ }
268
574
  function getErrorCode(error) {
269
- return error instanceof Error && error.name ? error.name : "ERROR";
575
+ if (error instanceof Error && "code" in error && typeof error.code === "string" && error.code) {
576
+ return error.code;
577
+ }
578
+ if (error instanceof Error && error.name) return error.name;
579
+ return "ERROR";
580
+ }
581
+ function isTaskError(error) {
582
+ return error instanceof Error && (error instanceof TaskError || error.name === "TaskError");
583
+ }
584
+ var STEP_OUTCOME_KEYS = /* @__PURE__ */ new Set(["stdout", "stderr", "error", "exitCode", "code", "signal", "pid"]);
585
+ function isStepOutcome(value) {
586
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
587
+ const o = value;
588
+ const keys = Object.keys(o);
589
+ if (keys.length === 0) return false;
590
+ if (!keys.every((k) => STEP_OUTCOME_KEYS.has(k))) return false;
591
+ return typeof o.stdout === "string" || typeof o.stderr === "string" || typeof o.error === "string";
270
592
  }
271
593
  function withTimeout(promise, ms, name) {
272
594
  return new Promise((resolve2, reject) => {
@@ -327,8 +649,10 @@ async function runStepWithClient(clientStep, name, fn, options) {
327
649
  const result = await clientStep(name, () => runStepInvocations(name, fn, options), clientStepOptions);
328
650
  return result;
329
651
  }
330
- function parseCliArgs(argv) {
652
+ function parseCliArgs(argv, allowedCustomFlags) {
331
653
  const args = {};
654
+ const unknown = [];
655
+ const allowed = new Set(allowedCustomFlags ?? []);
332
656
  const readValue = (raw, i) => {
333
657
  const eq = raw.indexOf("=");
334
658
  if (eq !== -1) return { value: raw.slice(eq + 1), nextIndex: i };
@@ -420,22 +744,46 @@ function parseCliArgs(argv) {
420
744
  case "--dry-run":
421
745
  args.noIngest = true;
422
746
  break;
423
- default:
747
+ default: {
748
+ if (allowed.has(name)) {
749
+ if (!arg.includes("=")) {
750
+ const next = argv[i + 1];
751
+ if (next && !next.startsWith("-")) {
752
+ i++;
753
+ }
754
+ }
755
+ } else {
756
+ unknown.push(name);
757
+ if (!arg.includes("=")) {
758
+ const next = argv[i + 1];
759
+ if (next && !next.startsWith("-")) {
760
+ i++;
761
+ }
762
+ }
763
+ }
424
764
  break;
765
+ }
425
766
  }
426
767
  }
768
+ if (unknown.length > 0) {
769
+ throw new Error(`Unknown flag(s): ${unknown.join(", ")}`);
770
+ }
427
771
  if (!args.noIngest && isEnvNoIngest()) {
428
772
  args.noIngest = true;
429
773
  }
430
774
  return args;
431
775
  }
432
776
  function mergeConfig(config, args) {
433
- const phaseTotal = config.phases?.reduce((sum, p) => sum + p.iterations, 0);
434
- if (phaseTotal !== void 0 && args.iterations !== void 0) {
435
- console.warn("--iterations is ignored because this benchmark declares phases.");
777
+ const phases = config.phases;
778
+ const unevenPhases = phases !== void 0 && phases.some((p) => p.iterations !== phases[0].iterations);
779
+ if (unevenPhases && args.iterations !== void 0) {
780
+ console.warn("--iterations is ignored because this benchmark sizes its phases individually.");
436
781
  }
782
+ const phaseIterations = phases !== void 0 && !unevenPhases ? args.iterations : void 0;
783
+ const phaseTotal = phases !== void 0 ? phaseIterations !== void 0 ? phaseIterations * phases.length : phases.reduce((sum, p) => sum + p.iterations, 0) : void 0;
437
784
  const resolved = {
438
785
  iterations: phaseTotal ?? args.iterations ?? config.iterations ?? 1,
786
+ phaseIterations,
439
787
  concurrency: args.concurrency ?? config.concurrency ?? 1,
440
788
  staggerDelayMs: args.staggerDelayMs ?? config.staggerDelayMs ?? 0,
441
789
  groupBy: args.groupBy ?? config.groupBy ?? "participant",
@@ -449,13 +797,16 @@ function mergeConfig(config, args) {
449
797
  }
450
798
  return resolved;
451
799
  }
452
- function buildSchedule(config, iterations, task) {
800
+ function buildSchedule(config, resolved, task) {
453
801
  if (config.phases?.length) {
454
802
  return config.phases.flatMap(
455
- (phase) => Array.from({ length: phase.iterations }, () => ({ phase: phase.name, task }))
803
+ (phase) => Array.from({ length: resolved.phaseIterations ?? phase.iterations }, () => ({
804
+ phase: phase.name,
805
+ task
806
+ }))
456
807
  );
457
808
  }
458
- return Array.from({ length: iterations }, () => ({ phase: void 0, task }));
809
+ return Array.from({ length: resolved.iterations }, () => ({ phase: void 0, task }));
459
810
  }
460
811
  function defaultOnResult(record, meta) {
461
812
  const n = record.taskIndex + 1;
@@ -466,19 +817,6 @@ function defaultOnResult(record, meta) {
466
817
  console.log(` [${meta.participant}] Task ${n}/${meta.iterations}: FAILED \u2014 ${record.errorCode ?? "unknown error"}`);
467
818
  }
468
819
  }
469
- function resolvePlatform() {
470
- const root = (process.env.BENCHMARKS_PLATFORM_URL || DEFAULT_PLATFORM_URL).replace(/\/+$/, "");
471
- const apiKey = process.env.BENCHMARKS_PLATFORM_API_KEY;
472
- if (!apiKey) {
473
- throw new Error(
474
- "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."
475
- );
476
- }
477
- return {
478
- baseUrl: `${root}/api/v1`,
479
- apiKey
480
- };
481
- }
482
820
  function resolveShape(config, shapeName) {
483
821
  if (!shapeName) return void 0;
484
822
  const shape = config.shapes?.[shapeName];
@@ -510,30 +848,51 @@ function dashboardUrlFor(baseUrl, organizationSlug, benchmarkSlug, runId) {
510
848
  return `${baseUrl.replace(/\/api\/v1\/?$/, "")}/${organizationSlug}/benchmarks/${benchmarkSlug}/runs/${runId}`;
511
849
  }
512
850
  function resolveParticipants(config, resolved) {
513
- const { available, skipped } = (0, import_client.filterParticipantsByEnv)((0, import_client.selectParticipants)(config.participants, resolved.providers));
851
+ const { available, skipped } = (0, import_worker.filterParticipantsByEnv)((0, import_worker.selectParticipants)(config.participants, resolved.providers));
514
852
  for (const s of skipped) {
515
853
  console.log(`Skipping ${s.name}: missing ${s.missing.join(", ")}`);
516
854
  }
517
855
  if (available.length === 0) throw new NoAvailableParticipantsError(skipped);
518
856
  return available;
519
857
  }
858
+ function runConfigToJson(config, resolved, participants) {
859
+ const phases = config.phases?.map((phase) => ({
860
+ name: phase.name,
861
+ iterations: resolved.phaseIterations ?? phase.iterations
862
+ }));
863
+ const runConfig = {
864
+ benchmarkSlug: config.benchmarkSlug,
865
+ benchmarkName: config.benchmarkName,
866
+ ...resolved.phaseIterations !== void 0 ? { phaseIterations: resolved.phaseIterations } : {},
867
+ ...phases ? { phases } : {},
868
+ ...!config.phases ? { iterations: resolved.iterations } : {},
869
+ concurrency: resolved.concurrency,
870
+ staggerDelayMs: resolved.staggerDelayMs,
871
+ groupBy: resolved.groupBy,
872
+ ...config.dimensions ? { dimensions: config.dimensions } : {},
873
+ ...config.scoring ? { scoring: config.scoring } : {},
874
+ participants
875
+ };
876
+ return JSON.parse(JSON.stringify(runConfig));
877
+ }
520
878
  async function runBenchmark(fileConfig, task, argv = []) {
521
- const args = parseCliArgs(argv);
879
+ const args = parseCliArgs(argv, fileConfig.customCliFlags);
522
880
  const noIngest = args.noIngest ?? isEnvNoIngest();
523
881
  const shaped = applyShape(fileConfig, resolveShape(fileConfig, args.shape));
524
882
  const config = applyIdentityOverrides(shaped, args);
525
883
  const resolved = mergeConfig(config, args);
884
+ const auth = await (0, import_cli.resolveAuth)();
885
+ const client = (0, import_api.createBenchmarkClient)({
886
+ baseUrl: auth.apiBaseUrl,
887
+ apiKey: auth.apiKey,
888
+ token: auth.token,
889
+ orgSlug: auth.orgSlug,
890
+ orgId: auth.orgId
891
+ });
526
892
  const available = resolveParticipants(config, resolved);
527
- let baseUrl = "";
528
- let apiKey = "";
529
- let client = null;
530
- if (!noIngest) {
531
- ({ baseUrl, apiKey } = resolvePlatform());
532
- client = (0, import_client.createBenchmarkClient)({ baseUrl, apiKey });
533
- }
534
- const schedule = buildSchedule(config, resolved.iterations, task);
893
+ const schedule = buildSchedule(config, resolved, task);
535
894
  const totalTasks = schedule.length;
536
- const concurrencyLabel = resolved.groupBy === "round" ? "n/a (round mode)" : String(resolved.concurrency);
895
+ const concurrencyLabel = String(resolved.concurrency);
537
896
  console.log(`${config.benchmarkName} (self-contained)`);
538
897
  console.log(`Date: ${(/* @__PURE__ */ new Date()).toISOString()}`);
539
898
  if (noIngest) {
@@ -551,16 +910,23 @@ async function runBenchmark(fileConfig, task, argv = []) {
551
910
  dashboardUrl = "";
552
911
  } else {
553
912
  if (identityIsOurs) {
913
+ const benchmarkConfig = {
914
+ ...config.scoring ? { scoring: config.scoring } : {},
915
+ ...config.display ? { display: config.display } : {}
916
+ };
554
917
  await client.upsertBenchmark(config.benchmarkSlug, {
555
- name: config.benchmarkName
918
+ name: config.benchmarkName,
919
+ ...Object.keys(benchmarkConfig).length > 0 ? { config: benchmarkConfig } : {}
556
920
  });
557
921
  }
922
+ const runConfig = client ? runConfigToJson(config, resolved, available.map((p) => p.name)) : {};
558
923
  if (args.runKey) {
559
924
  const { run: run2, organizationSlug } = await client.createRun(config.benchmarkSlug, {
560
- runKey: args.runKey
925
+ runKey: args.runKey,
926
+ config: runConfig
561
927
  });
562
928
  runId = run2.id;
563
- dashboardUrl = dashboardUrlFor(baseUrl, organizationSlug, config.benchmarkSlug, run2.id);
929
+ dashboardUrl = dashboardUrlFor(auth.apiBaseUrl, organizationSlug, config.benchmarkSlug, run2.id);
564
930
  for (const participant of available) {
565
931
  await client.upsertParticipant(config.benchmarkSlug, runId, participant.name, { totalTasks });
566
932
  }
@@ -571,10 +937,11 @@ async function runBenchmark(fileConfig, task, argv = []) {
571
937
  const { run: run2, organizationSlug } = await client.createRun(config.benchmarkSlug, {
572
938
  totalTasks,
573
939
  workerCount: 1,
574
- participants: available.map((p) => p.name)
940
+ participants: available.map((p) => p.name),
941
+ config: runConfig
575
942
  });
576
943
  runId = run2.id;
577
- dashboardUrl = dashboardUrlFor(baseUrl, organizationSlug, config.benchmarkSlug, run2.id);
944
+ dashboardUrl = dashboardUrlFor(auth.apiBaseUrl, organizationSlug, config.benchmarkSlug, run2.id);
578
945
  console.log(`Run created: ${run2.name} (${runId})`);
579
946
  console.log(`View at: ${dashboardUrl}
580
947
  `);
@@ -583,9 +950,9 @@ async function runBenchmark(fileConfig, task, argv = []) {
583
950
  const onResult = defaultOnResult;
584
951
  let participantRecords;
585
952
  if (resolved.groupBy === "round") {
586
- participantRecords = await runGroupedByRound(config, schedule, available, resolved, client, runId, baseUrl, apiKey, onResult, noIngest);
953
+ participantRecords = await runGroupedByRound(config, schedule, available, resolved, client, runId, auth.apiBaseUrl, auth.apiKey, auth.token, auth.orgSlug, auth.orgId, onResult, noIngest);
587
954
  } else {
588
- participantRecords = await runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult);
955
+ participantRecords = await runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult, noIngest);
589
956
  }
590
957
  console.log(`All done. ${noIngest ? "No platform run created." : `View at: ${dashboardUrl}`}`);
591
958
  const outcome = {
@@ -594,10 +961,10 @@ async function runBenchmark(fileConfig, task, argv = []) {
594
961
  participants: participantRecords,
595
962
  config: resolved
596
963
  };
597
- if (client && config.onScore) {
964
+ if (!noIngest && (config.onScore || config.scoring)) {
598
965
  try {
599
- const spec = await config.onScore(lowerIsBetter, higherIsBetter);
600
- const scored = score(outcome, spec);
966
+ const spec = config.onScore ? await config.onScore(lowerIsBetter, higherIsBetter) : scoringConfigToSpec(config.scoring, config.dimensions, config.display);
967
+ const scored = score(outcome, spec, config.display?.metrics);
601
968
  const run2 = {
602
969
  gitSha: process.env.GITHUB_SHA ?? getGitSha(),
603
970
  gitRef: process.env.GITHUB_REF_NAME ?? process.env.GITHUB_REF ?? getGitRef(),
@@ -606,8 +973,13 @@ async function runBenchmark(fileConfig, task, argv = []) {
606
973
  platform: import_node_os.default.platform(),
607
974
  arch: import_node_os.default.arch()
608
975
  };
609
- await client.submitRunSummary(config.benchmarkSlug, runId, { run: run2, results: scored });
976
+ await client.submitRunSummary(config.benchmarkSlug, runId, {
977
+ run: run2,
978
+ results: scored,
979
+ ...config.scoring ? { scoring: config.scoring } : {}
980
+ });
610
981
  } catch (err) {
982
+ if (err instanceof ScoringSpecError) throw err;
611
983
  const message = err instanceof Error ? err.message : String(err);
612
984
  console.warn(`[benchsdk-runner] failed to submit run summary: ${message}`);
613
985
  }
@@ -632,13 +1004,13 @@ function getGitRef() {
632
1004
  return void 0;
633
1005
  }
634
1006
  }
635
- async function runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult) {
1007
+ async function runGroupedByParticipant(config, schedule, available, resolved, client, runId, onResult, noIngest) {
636
1008
  const participantRecords = [];
637
1009
  for (const participant of available) {
638
1010
  console.log(`${"=".repeat(70)}`);
639
1011
  console.log(` Participant: ${participant.name}`);
640
1012
  console.log("=".repeat(70));
641
- if (!client) {
1013
+ if (noIngest || !client) {
642
1014
  const records = [];
643
1015
  let rampStartMs2;
644
1016
  let nextIndex = 0;
@@ -670,7 +1042,7 @@ async function runGroupedByParticipant(config, schedule, available, resolved, cl
670
1042
  }
671
1043
  let rampStartMs;
672
1044
  await client.planWorkers(config.benchmarkSlug, runId, participant.name);
673
- const result = await client.runWorker({
1045
+ const result = await (0, import_worker.runWorker)(client, {
674
1046
  benchmarkSlug: config.benchmarkSlug,
675
1047
  runId,
676
1048
  participantSlug: participant.name,
@@ -683,16 +1055,21 @@ async function runGroupedByParticipant(config, schedule, available, resolved, cl
683
1055
  if (waitMs > 0) await sleep(waitMs);
684
1056
  }
685
1057
  const slot = schedule[scheduleIndex];
686
- const taskResult = await slot.task({
687
- participant,
688
- taskIndex: scheduleIndex,
689
- phase: slot.phase,
690
- step: (name, fn, options) => runStepWithClient(ctx.step, name, fn, options),
691
- measure: ctx.measure,
692
- log: ctx.log
693
- });
694
1058
  if (slot.phase) ctx.measure({ phase: slot.phase });
695
- return taskResult?.data;
1059
+ try {
1060
+ const taskResult = await slot.task({
1061
+ participant,
1062
+ taskIndex: scheduleIndex,
1063
+ phase: slot.phase,
1064
+ step: (name, fn, options) => runStepWithClient(ctx.step, name, fn, options),
1065
+ measure: ctx.measure,
1066
+ log: ctx.log
1067
+ });
1068
+ return taskResult?.data;
1069
+ } catch (error) {
1070
+ if (isTaskError(error) && error.data) ctx.measure(error.data);
1071
+ throw error;
1072
+ }
696
1073
  },
697
1074
  onResult: (record) => onResult(record, { iterations: schedule.length, participant: participant.name })
698
1075
  });
@@ -708,14 +1085,17 @@ async function runGroupedByParticipant(config, schedule, available, resolved, cl
708
1085
  }
709
1086
  return participantRecords;
710
1087
  }
711
- async function runGroupedByRound(config, schedule, available, resolved, client, runId, baseUrl, apiKey, onResult, noIngest = false) {
1088
+ async function runGroupedByRound(config, schedule, available, resolved, client, runId, baseUrl, apiKey, token, orgSlug, orgId, onResult, noIngest = false) {
712
1089
  const reporters = /* @__PURE__ */ new Map();
713
1090
  const logBuffers = /* @__PURE__ */ new Map();
714
1091
  const failed = /* @__PURE__ */ new Map();
715
1092
  const recordsByParticipant = /* @__PURE__ */ new Map();
1093
+ let metricsCollector;
1094
+ const metricsSamples = [];
716
1095
  for (const participant of available) {
717
1096
  logBuffers.set(participant.name, new LogBuffer());
718
1097
  failed.set(participant.name, false);
1098
+ recordsByParticipant.set(participant.name, []);
719
1099
  if (noIngest || !client) {
720
1100
  reporters.set(participant.name, null);
721
1101
  continue;
@@ -726,9 +1106,12 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
726
1106
  });
727
1107
  let reporter = null;
728
1108
  try {
729
- reporter = await import_client.BenchmarkReporter.claim({
1109
+ reporter = await import_worker.BenchmarkReporter.claim({
730
1110
  baseUrl,
731
1111
  apiKey,
1112
+ token,
1113
+ orgSlug,
1114
+ orgId,
732
1115
  benchmarkSlug: config.benchmarkSlug,
733
1116
  runId,
734
1117
  participantSlug: participant.name,
@@ -742,6 +1125,10 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
742
1125
  console.warn(` ${participant.name}: could not claim a platform worker \u2014 running without platform reporting.`);
743
1126
  }
744
1127
  reporters.set(participant.name, reporter);
1128
+ if (reporter && !metricsCollector) {
1129
+ metricsCollector = (0, import_worker.createSystemMetricsCollector)();
1130
+ metricsSamples.push(await metricsCollector.sample());
1131
+ }
745
1132
  }
746
1133
  console.log(`Interleaving ${available.length} participant(s), ${schedule.length} round(s) each.
747
1134
  `);
@@ -750,7 +1137,7 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
750
1137
  if (resolved.staggerDelayMs > 0 && i > 0) {
751
1138
  await sleep(resolved.staggerDelayMs);
752
1139
  }
753
- for (const participant of available) {
1140
+ const roundFns = available.map((participant) => async () => {
754
1141
  const reporter = reporters.get(participant.name) ?? null;
755
1142
  const logBuffer = logBuffers.get(participant.name);
756
1143
  const record = await runTaskRecord(
@@ -764,9 +1151,6 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
764
1151
  if (record.status !== "success") failed.set(participant.name, true);
765
1152
  onResult(record, { iterations: schedule.length, participant: participant.name });
766
1153
  reporter?.recordResult(record);
767
- if (!recordsByParticipant.has(participant.name)) {
768
- recordsByParticipant.set(participant.name, []);
769
- }
770
1154
  const participantRecords = recordsByParticipant.get(participant.name);
771
1155
  participantRecords.push(record);
772
1156
  if (reporter) {
@@ -778,7 +1162,22 @@ async function runGroupedByRound(config, schedule, available, resolved, client,
778
1162
  });
779
1163
  await reporter.heartbeat();
780
1164
  }
781
- }
1165
+ });
1166
+ await runWithConcurrency(roundFns, resolved.concurrency);
1167
+ if (metricsCollector) metricsSamples.push(await metricsCollector.sample());
1168
+ }
1169
+ if (metricsCollector) metricsSamples.push(await metricsCollector.sample());
1170
+ metricsCollector?.stop();
1171
+ const metricsReporter = available.map((p) => reporters.get(p.name)).find((r) => Boolean(r));
1172
+ if (metricsReporter && metricsSamples.length > 0) {
1173
+ await metricsReporter.uploadArtifact({
1174
+ kind: "system-metrics",
1175
+ contentType: "application/x-ndjson",
1176
+ name: "metrics.jsonl",
1177
+ metadata: { scope: "shared-process", participants: available.map((p) => p.name) },
1178
+ body: metricsSamples.map((sample) => JSON.stringify(sample)).join("\n") + "\n"
1179
+ }).catch(() => {
1180
+ });
782
1181
  }
783
1182
  for (const participant of available) {
784
1183
  const reporter = reporters.get(participant.name) ?? null;
@@ -825,11 +1224,12 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
825
1224
  activeStep = stepRecord;
826
1225
  try {
827
1226
  const result2 = await runStepInvocations(name, fn, options);
828
- logBuffer.step(taskIndex, name, {});
1227
+ const outcome = options?.captureOutput !== false && !Array.isArray(result2) && isStepOutcome(result2) ? result2 : {};
1228
+ logBuffer.step(taskIndex, name, outcome);
829
1229
  return result2;
830
1230
  } catch (error) {
831
1231
  stepRecord.status = "error";
832
- stepRecord.errorCode = error instanceof TaskError ? error.code ?? error.name : getErrorCode(error);
1232
+ stepRecord.errorCode = isTaskError(error) ? error.code ?? error.name : getErrorCode(error);
833
1233
  logBuffer.step(taskIndex, name, { error: error instanceof Error ? error.message : String(error) });
834
1234
  throw error;
835
1235
  } finally {
@@ -846,8 +1246,8 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
846
1246
  Object.assign(taskMeasures, data);
847
1247
  }
848
1248
  },
849
- log(message, meta) {
850
- logBuffer.line(`[task ${taskIndex}] ${message}`, meta);
1249
+ log(message, metaOrOptions) {
1250
+ logBuffer.line(`[task ${taskIndex}] ${message}`, metaOrOptions);
851
1251
  }
852
1252
  };
853
1253
  let result = void 0;
@@ -856,7 +1256,7 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
856
1256
  record.data = mergeData({ ...taskMeasures, ...result?.data ?? {} }, phase);
857
1257
  } catch (error) {
858
1258
  record.status = "error";
859
- if (error instanceof TaskError) {
1259
+ if (isTaskError(error)) {
860
1260
  record.errorCode = error.code ?? error.name;
861
1261
  record.data = mergeData({ ...taskMeasures, ...error.data ?? {} }, phase);
862
1262
  if (error.steps?.length) frameworkSteps.push(...error.steps);
@@ -892,6 +1292,7 @@ async function runTaskRecord(task, participant, scheduleIndex, taskIndex, phase,
892
1292
  // src/cli.ts
893
1293
  var import_node_path = require("path");
894
1294
  var import_node_url = require("url");
1295
+ var import_cli2 = require("@benchsdk/cli");
895
1296
  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]';
896
1297
  function isBenchmarkConfig(value) {
897
1298
  if (typeof value !== "object" || value === null) return false;
@@ -914,6 +1315,9 @@ async function runBenchmarkFile(argv) {
914
1315
  await runBenchmark(config, task, flags);
915
1316
  }
916
1317
  async function run(argv) {
1318
+ if (argv[0] !== "run") {
1319
+ return (0, import_cli2.run)(argv);
1320
+ }
917
1321
  try {
918
1322
  await runBenchmarkFile(argv);
919
1323
  process.exit(0);
@@ -926,9 +1330,14 @@ async function run(argv) {
926
1330
  process.exit(1);
927
1331
  }
928
1332
  }
1333
+
1334
+ // src/index.ts
1335
+ var BENCHSDK_RUNNER_VERSION = "0.3.0";
929
1336
  // Annotate the CommonJS export names for ESM import in node:
930
1337
  0 && (module.exports = {
1338
+ BENCHSDK_RUNNER_VERSION,
931
1339
  NoAvailableParticipantsError,
1340
+ ScoringSpecError,
932
1341
  TaskError,
933
1342
  defineBenchmarkConfig,
934
1343
  defineTask,
@@ -939,6 +1348,8 @@ async function run(argv) {
939
1348
  run,
940
1349
  runBenchmark,
941
1350
  runBenchmarkFile,
942
- score
1351
+ score,
1352
+ scoringConfigToSpec,
1353
+ validateScoringSpec
943
1354
  });
944
1355
  //# sourceMappingURL=index.cjs.map