@leostera/evalkit 0.8.0 → 0.10.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +93 -2
  2. package/README.md +5 -4
  3. package/dist/cli.mjs +510 -379
  4. package/dist/dashboard/assets/index-DNR4shrM.js +67 -0
  5. package/dist/dashboard/assets/index-TPYprg75.css +2 -0
  6. package/dist/dashboard/index.html +2 -2
  7. package/dist/{files-C-U0-PWW.mjs → files-DkDQs2JL.mjs} +3 -0
  8. package/dist/{index-db-vbqEGFaU.mjs → index-db-B1FeXzmS.mjs} +333 -51
  9. package/dist/index.d.mts +529 -183
  10. package/dist/index.d.mts.map +1 -1
  11. package/dist/index.mjs +2 -2
  12. package/dist/runner.d.mts +447 -173
  13. package/dist/runner.d.mts.map +1 -1
  14. package/dist/runner.mjs +2 -2
  15. package/dist/share-worker.d.mts +10 -3
  16. package/dist/share-worker.d.mts.map +1 -1
  17. package/dist/share-worker.mjs +6 -3
  18. package/dist/share.d.mts +49 -7
  19. package/dist/share.d.mts.map +1 -1
  20. package/dist/share.mjs +4 -4
  21. package/dist/skills/evalkit/SKILL.md +5 -5
  22. package/dist/skills/evalkit-agents/SKILL.md +5 -3
  23. package/dist/skills/evalkit-datasets/SKILL.md +5 -5
  24. package/dist/skills/evalkit-evals/SKILL.md +5 -5
  25. package/dist/skills/evalkit-judges/SKILL.md +8 -6
  26. package/dist/skills/evalkit-matrices/SKILL.md +2 -2
  27. package/dist/skills/evalkit-results/SKILL.md +6 -6
  28. package/dist/{src-LeGSyDD2.mjs → src-B14fXAGp.mjs} +334 -73
  29. package/dist/{src-CZnDXP1K.mjs → src-Bccv9igQ.mjs} +8 -3
  30. package/dist/{src-mm8L_IxM.mjs → src-BfSQKOJP.mjs} +676 -321
  31. package/dist/upgrade-G-uFPJKg.mjs +111 -0
  32. package/package.json +1 -1
  33. package/dist/dashboard/assets/index-C12IeG2y.css +0 -2
  34. package/dist/dashboard/assets/index-xlVsW6ZR.js +0 -37
package/dist/cli.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/bin/sh
2
2
  ':' //; exec "${EVALKIT_RUNTIME:-$(command -v bun || command -v node)}" "$0" "$@"
3
- import { B as defineEvalMatrix, V as canonicalParameters, a as authoringId, c as defineSuite, i as agentRuntimes, m as registerEvals, p as recordError } from "./src-LeGSyDD2.mjs";
4
- import { a as readRunSummary, d as trialWorkspacePath, f as staticDatasetFile, i as readRunManifest, l as readTrialSummary, m as globMatcher, n as runMatrix, o as readTrialEvents, p as staticDatasetFiles, r as ownerIsGone, s as readTrialManifest, t as runEval, u as localReportStore } from "./src-mm8L_IxM.mjs";
5
- import { c as dashboardIndex, d as encodeBundle, o as repoKey } from "./index-db-vbqEGFaU.mjs";
6
- import { t as createDirectoryShareApp } from "./src-CZnDXP1K.mjs";
3
+ import { H as defineEvalMatrix, U as canonicalParameters, h as registerEvals, i as agentRuntimes, l as defineSuite, m as recordError, o as authoringId, tt as passMark } from "./src-B14fXAGp.mjs";
4
+ import { a as readRunEvents, c as readRunSummary, d as staticDatasetFile, f as staticDatasetFiles, i as ownerIsGone, l as localReportStore, n as upgradeReports, o as readRunManifest, p as globMatcher, r as runMatrix, t as runEval, u as runWorkspacePath } from "./src-BfSQKOJP.mjs";
5
+ import { f as encodeBundle, i as normalizeBasePath, l as dashboardIndex, s as repoKey } from "./index-db-B1FeXzmS.mjs";
6
+ import { t as createDirectoryShareApp } from "./src-Bccv9igQ.mjs";
7
7
  import { Effect, LogLevel, Logger } from "effect";
8
- import { cp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
9
- import path, { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import { cp, lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
10
+ import path, { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+ import { createHash } from "node:crypto";
10
12
  import fs, { existsSync } from "node:fs";
11
13
  import { fileURLToPath, pathToFileURL } from "node:url";
12
14
  import { Hono } from "hono";
@@ -166,63 +168,18 @@ function shortSource(source) {
166
168
  //#endregion
167
169
  //#region ../cli/src/dashboard-reports.ts
168
170
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
169
- function runMetrics(trials) {
170
- const byName = /* @__PURE__ */ new Map();
171
- for (const trial of trials) for (const result of trial?.scoring?.metrics ?? []) {
172
- const entry = byName.get(result.name) ?? {
173
- name: result.name,
174
- ...result.unit ? { unit: result.unit } : {},
175
- ...result.better ? { better: result.better } : {},
176
- ...result.builtIn ? { builtIn: true } : {},
177
- count: 0,
178
- sum: 0
179
- };
180
- if (result.value !== void 0) {
181
- entry.count += 1;
182
- entry.sum += result.value;
183
- }
184
- byName.set(result.name, entry);
185
- }
186
- return [...byName.values()].map(({ sum, ...metric }) => ({
187
- ...metric,
188
- ...metric.count ? { mean: sum / metric.count } : {}
189
- }));
190
- }
191
- function runScorers(trials) {
192
- const byKey = /* @__PURE__ */ new Map();
193
- for (const trial of trials) for (const score of trial?.scoring?.results ?? []) {
194
- const kind = score.kind;
195
- const key = `${kind}\0${score.name}`;
196
- const entry = byKey.get(key) ?? {
197
- name: score.name,
198
- kind,
199
- count: 0,
200
- skipped: 0,
201
- sum: 0
202
- };
203
- if (score.skipped) entry.skipped += 1;
204
- else if (score.value !== void 0) {
205
- entry.count += 1;
206
- entry.sum += score.value;
207
- }
208
- byKey.set(key, entry);
209
- }
210
- return [...byKey.values()].map(({ sum, ...scorer }) => ({
211
- ...scorer,
212
- ...scorer.count ? { mean: sum / scorer.count } : {}
213
- }));
214
- }
215
- function runDatasets(manifests) {
216
- const byId = /* @__PURE__ */ new Map();
217
- for (const manifest of manifests) for (const dataset of manifest?.datasets ?? []) {
218
- const digests = byId.get(dataset.id) ?? /* @__PURE__ */ new Set();
219
- digests.add(dataset.digest);
220
- byId.set(dataset.id, digests);
221
- }
222
- return [...byId].map(([id, digests]) => ({
223
- id,
224
- digests: [...digests].sort()
225
- }));
171
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
172
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
173
+ /**
174
+ * The first non-empty line of a message, without terminal colours, cut to
175
+ * `max` characters: a scorer may explain itself with 300 lines of stderr,
176
+ * and a summary line must stay one line. The run page has the rest.
177
+ */
178
+ function firstLine(text, max = 100) {
179
+ const lines = (text ?? "").replace(ANSI, "").split(/\r?\n/).map((line) => line.replace(CONTROL, "").trim()).filter(Boolean);
180
+ const line = lines[0] ?? "";
181
+ const more = lines.length > 1 ? " …" : "";
182
+ return line.length > max ? `${line.slice(0, max - 1)}…` : `${line}${more}`;
226
183
  }
227
184
  /** Written into a run directory by `evalkit tidy`; deleting it restores the run. */
228
185
  const ARCHIVE_MARKER = "archived.json";
@@ -240,23 +197,19 @@ async function ifPresent(operation) {
240
197
  function assertUuid(id, label) {
241
198
  if (!UUID_PATTERN.test(id)) throw new Error(`Invalid ${label}`);
242
199
  }
243
- function trialPath(root, runId, trialId) {
200
+ function runPath(root, runId) {
244
201
  assertUuid(runId, "run ID");
245
- assertUuid(trialId, "trial ID");
246
- return resolve(root, runId, "trials", trialId);
247
- }
248
- function runStatus(summary) {
249
- if (summary.status === "running") return "running";
250
- if (summary.status === "cancelled") return "cancelled";
251
- if (summary.status !== "completed") return "errored";
252
- return summary.failed === 0 && summary.passed === summary.trialCount ? "passed" : "failed";
253
- }
254
- function trialStatus(summary) {
255
- if (summary.status === "running") return "running";
256
- if (summary.status === "cancelled") return "cancelled";
257
- if (summary.status !== "completed") return "errored";
258
- return summary.scoring?.passed === true ? "passed" : "failed";
202
+ return resolve(root, runId);
259
203
  }
204
+ /**
205
+ * Headers for files an agent, a dataset or an adapter produced: untrusted
206
+ * content on the dashboard's origin, so never rendered or sniffed as HTML.
207
+ */
208
+ const UNTRUSTED_FILE = {
209
+ "content-type": "application/octet-stream",
210
+ "x-content-type-options": "nosniff",
211
+ "content-security-policy": "sandbox"
212
+ };
260
213
  function fileContentType(file) {
261
214
  return {
262
215
  ".css": "text/css",
@@ -265,15 +218,17 @@ function fileContentType(file) {
265
218
  }[extname(file)] ?? "application/octet-stream";
266
219
  }
267
220
  /**
268
- * Read saved local reports. A missing summary means "running"; malformed reports are errors.
269
- * Runs written by an older EvalKit (manifest `schemaVersion` < 2) are skipped and counted.
221
+ * Read saved local reports. A missing summary means "running"; malformed
222
+ * reports are errors. Runs from an older report format (before schema
223
+ * version 6) are skipped and counted, for `evalkit upgrade` to convert.
270
224
  */
271
- function createDashboardReportReader(root, registry) {
225
+ function createDashboardReportReader(root) {
272
226
  const legacyRuns = /* @__PURE__ */ new Set();
273
227
  async function listRuns() {
274
228
  const entries = await ifPresent(() => readdir(root, { withFileTypes: true }));
275
229
  if (!entries) return [];
276
230
  const ids = entries.filter((entry) => entry.isDirectory() && UUID_PATTERN.test(entry.name)).map((entry) => entry.name);
231
+ legacyRuns.clear();
277
232
  return (await Promise.all(ids.map(readRun))).filter((run) => run !== void 0);
278
233
  }
279
234
  /** True for a run from a report format this version cannot read; never for corrupt JSON. */
@@ -286,7 +241,7 @@ function createDashboardReportReader(root, registry) {
286
241
  } catch {
287
242
  return false;
288
243
  }
289
- return typeof version === "number" && version < 2;
244
+ return typeof version === "number" && version < 6;
290
245
  }
291
246
  async function readRun(id) {
292
247
  if (await isLegacyRun(id)) {
@@ -296,15 +251,9 @@ function createDashboardReportReader(root, registry) {
296
251
  const manifest = await ifPresent(() => readRunManifest(root, id));
297
252
  if (!manifest) return void 0;
298
253
  const summary = await ifPresent(() => readRunSummary(root, id));
299
- const trialIds = await ifPresent(() => readdir(resolve(root, id, "trials")));
300
- const trials = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialSummary(root, id, trialId))));
301
- const trialManifests = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialManifest(root, id, trialId))));
302
- const scores = trials.flatMap((trial) => trial?.scoring?.overall === void 0 ? [] : [trial.scoring.overall]);
303
- const scorers = runScorers(trials);
304
- const metrics = runMetrics(trials);
305
- const datasets = runDatasets(trialManifests);
306
254
  const archived = await ifPresent(() => stat(resolve(root, id, ARCHIVE_MARKER)));
307
255
  const interrupted = !summary && ownerIsGone(manifest.process);
256
+ const scoring = summary?.scoring;
308
257
  return {
309
258
  id,
310
259
  ...archived ? { archived: true } : {},
@@ -321,76 +270,81 @@ function createDashboardReportReader(root, registry) {
321
270
  manifest.aut.id,
322
271
  manifest.aut.version
323
272
  ].filter(Boolean).join(" / ") } : {},
324
- status: summary ? runStatus(summary) : interrupted ? "cancelled" : "running",
273
+ status: summary ? summary.status : interrupted ? "cancelled" : "running",
325
274
  ...interrupted ? { interrupted: true } : {},
326
275
  startedAt: manifest.startedAt,
327
276
  ...summary?.endedAt ? { completedAt: summary.endedAt } : {},
328
277
  ...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
329
- completedTrials: summary?.trialCount ?? trials.filter(Boolean).length,
330
- requestedTrials: summary?.trialCount ?? registry.get(manifest.evalId)?.policy?.trials ?? 1,
331
- ...scores.length ? { score: scores.reduce((sum, score) => sum + score, 0) / scores.length } : {},
332
- ...scorers.length ? { scorers } : {},
333
- ...metrics.length ? { metrics } : {},
334
- ...datasets.length ? { datasets } : {}
335
- };
336
- }
337
- async function listTrials(runId) {
338
- assertUuid(runId, "run ID");
339
- const ids = await ifPresent(() => readdir(resolve(root, runId, "trials")));
340
- if (!ids) return [];
341
- const runManifest = await ifPresent(() => readRunManifest(root, runId));
342
- const ownerGone = ownerIsGone(runManifest?.process);
343
- return (await Promise.all(ids.map(async (id) => {
344
- const manifest = await readTrialManifest(root, runId, id);
345
- const summary = await ifPresent(() => readTrialSummary(root, runId, id));
346
- return {
278
+ ...manifest.batch ? { batch: manifest.batch } : {},
279
+ ...scoring?.overall === void 0 ? {} : { score: scoring.overall },
280
+ ...scoring?.results.length ? { scorers: scoring.results.map((result) => ({
281
+ name: result.name,
282
+ kind: result.kind,
283
+ ...result.skipped ? { skipped: true } : {},
284
+ ...result.value === void 0 || result.skipped ? {} : { value: result.value },
285
+ ...result.passed === void 0 || result.skipped ? {} : { passed: result.passed },
286
+ ...result.error ? { errored: true } : {},
287
+ ...result.choice ? { choice: result.choice } : {},
288
+ ...result.explanation ? { explanation: firstLine(result.explanation, 200) } : {},
289
+ ...result.judge?.model ? { model: result.judge.model } : {}
290
+ })) } : {},
291
+ ...scoring?.metrics?.length ? { metrics: scoring.metrics.map((metric) => ({
292
+ name: metric.name,
293
+ ...metric.unit ? { unit: metric.unit } : {},
294
+ ...metric.better ? { better: metric.better } : {},
295
+ ...metric.builtIn ? { builtIn: true } : {},
296
+ ...metric.value === void 0 ? {} : { value: metric.value }
297
+ })) } : {},
298
+ ...manifest.datasets?.length ? { datasets: manifest.datasets.map(({ id, digest }) => ({
347
299
  id,
348
- index: manifest.trialIndex,
349
- status: summary ? trialStatus(summary) : ownerGone ? "cancelled" : "running",
350
- startedAt: manifest.startedAt,
351
- ...summary?.endedAt ? { completedAt: summary.endedAt } : {},
352
- ...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
353
- ...summary?.scoring?.overall === void 0 ? {} : { score: summary.scoring.overall },
354
- scores: summary?.scoring?.results ?? [],
355
- ...summary?.scoring?.metrics ? { metrics: summary.scoring.metrics } : {},
356
- checkpoints: summary?.scoring?.checkpoints ?? [],
357
- ...summary?.scoring?.skippedScorers ? { skippedScorers: summary.scoring.skippedScorers } : {}
358
- };
359
- }))).sort((left, right) => left.index - right.index);
300
+ digest
301
+ })) } : {}
302
+ };
360
303
  }
361
- async function trialDetail(runId, trialId) {
362
- trialPath(root, runId, trialId);
304
+ async function runDetail(runId) {
305
+ runPath(root, runId);
306
+ const manifest = await readRunManifest(root, runId);
307
+ const summary = await ifPresent(() => readRunSummary(root, runId));
308
+ const scoring = summary?.scoring;
363
309
  return {
364
- manifest: await readTrialManifest(root, runId, trialId),
365
- summary: await ifPresent(() => readTrialSummary(root, runId, trialId)) ?? { status: "running" }
310
+ scores: scoring?.results ?? [],
311
+ ...scoring?.metrics ? { metrics: scoring.metrics } : {},
312
+ checkpoints: scoring?.checkpoints ?? [],
313
+ ...scoring?.skippedScorers ? { skippedScorers: scoring.skippedScorers } : {},
314
+ ...summary?.attachments?.length ? { attachments: summary.attachments } : {},
315
+ manifest,
316
+ summary: summary ?? { status: "running" }
366
317
  };
367
318
  }
368
- async function listArtifacts(runId, trialId) {
369
- return walkArtifacts(resolve(trialPath(root, runId, trialId), "artifacts"));
319
+ async function listArtifacts(runId) {
320
+ return walkArtifacts(resolve(runPath(root, runId), "artifacts"));
370
321
  }
371
- async function listCandidateWorkspace(runId, trialId) {
372
- return walkArtifacts(resolve(trialPath(root, runId, trialId), "artifacts", "candidate"));
322
+ async function listCandidateWorkspace(runId) {
323
+ return walkArtifacts(resolve(runPath(root, runId), "artifacts", "candidate"));
373
324
  }
374
- async function candidateFile(runId, trialId, relativePath) {
375
- const candidateRoot = resolve(trialPath(root, runId, trialId), "artifacts", "candidate");
376
- const file = resolve(candidateRoot, relativePath);
377
- if (file !== candidateRoot && !file.startsWith(`${candidateRoot}${sep}`)) throw new Error("Invalid workspace path");
378
- if (!(await stat(file)).isFile()) throw new Error("Workspace path is not a file");
379
- return new Response(await readFile(file), { headers: { "content-type": fileContentType(file) } });
325
+ /** A file below one of a run's artifact trees; never outside it. */
326
+ async function artifactFile(runId, tree, relativePath) {
327
+ const treeRoot = resolve(runPath(root, runId), "artifacts", tree);
328
+ const file = resolve(treeRoot, relativePath);
329
+ if (file === treeRoot || !file.startsWith(`${treeRoot}${sep}`)) throw new Error(`Invalid ${tree === "candidate" ? "workspace" : "attachment"} path`);
330
+ if (!(await lstat(file)).isFile()) throw new Error(`${tree === "candidate" ? "Workspace path" : "Attachment"} is not a file`);
331
+ return new Response(await readFile(file), { headers: UNTRUSTED_FILE });
380
332
  }
381
- async function trajectory(runId, trialId) {
382
- trialPath(root, runId, trialId);
383
- return await ifPresent(() => readTrialEvents(root, runId, trialId)) ?? [];
333
+ const candidateFile = (runId, path) => artifactFile(runId, "candidate", path);
334
+ const attachmentFile = (runId, name) => artifactFile(runId, "attachments", name);
335
+ async function trajectory(runId) {
336
+ runPath(root, runId);
337
+ return await ifPresent(() => readRunEvents(root, runId)) ?? [];
384
338
  }
385
339
  return {
386
340
  listRuns,
387
- /** Run IDs skipped by the last `listRuns()` because an older EvalKit wrote them. */
341
+ /** Run IDs skipped by the last `listRuns()`: an older EvalKit wrote them; `evalkit upgrade` converts them. */
388
342
  legacyRunIds: () => [...legacyRuns],
389
- listTrials,
390
- trialDetail,
343
+ runDetail,
391
344
  listArtifacts,
392
345
  listCandidateWorkspace,
393
346
  candidateFile,
347
+ attachmentFile,
394
348
  trajectory
395
349
  };
396
350
  }
@@ -461,40 +415,32 @@ async function buildDashboard(options, step = quietStep) {
461
415
  }
462
416
  return files;
463
417
  }, (files) => count(files, "dataset file"));
464
- const reports = createDashboardReportReader(reportRoot, registry);
418
+ const reports = createDashboardReportReader(reportRoot);
465
419
  const snapshot = await step("Run reports", async (progress) => {
466
420
  const all = await reports.listRuns();
467
421
  const runs = all.filter((run) => !run.archived);
468
422
  await write("runs.json", { runs });
469
- let trials = 0;
470
423
  let files = 0;
471
- const runTrials = [];
472
424
  for (const [index, run] of runs.entries()) {
473
425
  progress(`run ${index + 1}/${runs.length} · ${run.id.slice(0, 8)}`);
474
426
  const written = await snapshotRun(reports, run.id, directorySink(resolve(data, "runs", run.id)));
475
- trials += written.trialIds.length;
476
427
  files += written.files;
477
- runTrials.push({
478
- id: run.id,
479
- trials: written.trialIds
480
- });
481
428
  }
482
429
  return {
483
430
  runs: runs.length,
484
- trials,
485
431
  files,
486
432
  skipped: reports.legacyRunIds().length,
487
433
  archived: all.length - runs.length,
488
- runTrials
434
+ runIds: runs.map((run) => run.id)
489
435
  };
490
- }, ({ runs, trials, files, skipped, archived }) => `${count(runs, "run")} · ${count(trials, "trial")} · ${count(files, "candidate file")}` + (archived ? ` · ${archived} archived excluded` : "") + (skipped ? ` · ${skipped} older-format skipped` : ""));
436
+ }, ({ runs, files, skipped, archived }) => `${count(runs, "run")} · ${count(files, "candidate file")}` + (archived ? ` · ${archived} archived excluded` : "") + (skipped ? ` · ${skipped} older-format skipped (run \`evalkit upgrade\`)` : ""));
491
437
  await step("Route pages", async () => {
492
438
  const catalog = registry.catalog();
493
439
  const routes = staticRoutes({
494
440
  scenarios: catalog.map((entry) => entry.id),
495
441
  agents: catalog.flatMap((entry) => entry.agent.id ? [entry.agent.id] : []),
496
442
  datasets: catalog.flatMap((entry) => entry.datasets.map((dataset) => dataset.id)),
497
- runs: snapshot.runTrials
443
+ runs: snapshot.runIds
498
444
  });
499
445
  for (const route of routes) {
500
446
  const file = resolve(outDir, `${route}.html`);
@@ -504,7 +450,7 @@ async function buildDashboard(options, step = quietStep) {
504
450
  await writeFile(resolve(outDir, "404.html"), dashboardIndex(template, { base: "/" }));
505
451
  return routes.length;
506
452
  }, (pages) => `${count(pages, "page")} with clean /path URLs`);
507
- const { runTrials: _runs, ...summary } = snapshot;
453
+ const { runIds: _runs, ...summary } = snapshot;
508
454
  return {
509
455
  outDir,
510
456
  ...summary
@@ -522,7 +468,6 @@ function staticRoutes(ids) {
522
468
  return [
523
469
  "scoreboard",
524
470
  "runs",
525
- "trials",
526
471
  "workspaces",
527
472
  "library/agents",
528
473
  "library/judges",
@@ -531,9 +476,22 @@ function staticRoutes(ids) {
531
476
  ...under("library/scenarios", ids.scenarios),
532
477
  ...under("library/agents", ids.agents),
533
478
  ...under("library/datasets", ids.datasets),
534
- ...ids.runs.filter((run) => safe(run.id)).flatMap((run) => [`runs/${run.id}`, ...run.trials.filter(safe).flatMap((trial) => [`runs/${run.id}/trials/${trial}`, `runs/${run.id}/trials/${trial}/workspace`])])
479
+ ...ids.runs.filter(safe).flatMap((id) => [`runs/${id}`, ...RUN_SECTIONS.map((section) => `runs/${id}/${section}`)])
535
480
  ];
536
481
  }
482
+ /**
483
+ * A run's sections, each its own page; `attachments` is the logs' URL from
484
+ * before 0.10, kept so old links redirect.
485
+ */
486
+ const RUN_SECTIONS = [
487
+ "judging",
488
+ "metrics",
489
+ "workspace",
490
+ "logs",
491
+ "datasets",
492
+ "raw",
493
+ "attachments"
494
+ ];
537
495
  /** `runs/abc.html` is served at `<mount>/runs/abc`; its base is one level up. */
538
496
  function baseFor(route) {
539
497
  const depth = route.split("/").length - 1;
@@ -564,33 +522,33 @@ function directorySink(root) {
564
522
  }
565
523
  /**
566
524
  * One run's snapshot, shared by `evalkit build` (files on disk) and
567
- * `evalkit share` (an upload bundle): `trials.json`, then per trial
568
- * `detail.json`, `events.json`, `artifacts.json`, `workspace.json`, and the
569
- * candidate files under `workspace/`. Evaluator workspaces are never read.
525
+ * `evalkit share` (an upload bundle): `detail.json`, `events.json`,
526
+ * `artifacts.json` and `workspace.json`, the candidate files under
527
+ * `workspace/`, and attachments under `attachments/` (both left out with
528
+ * `workspaceFiles: false`; they stay listed). Evaluator workspaces are never
529
+ * read.
570
530
  */
571
531
  async function snapshotRun(reports, runId, sink, options = {}) {
572
- const trials = await reports.listTrials(runId);
573
- await sink.json("trials.json", { trials });
532
+ const detail = await reports.runDetail(runId);
533
+ await sink.json("detail.json", detail);
534
+ await sink.json("events.json", { events: await reports.trajectory(runId) });
535
+ await sink.json("artifacts.json", { artifacts: await reports.listArtifacts(runId) });
536
+ const workspace = await reports.listCandidateWorkspace(runId);
537
+ await sink.json("workspace.json", workspace);
538
+ if (options.workspaceFiles === false) return { files: 0 };
574
539
  let files = 0;
575
- for (const trial of trials) {
576
- const base = `trials/${trial.id}`;
577
- await sink.json(`${base}/detail.json`, await reports.trialDetail(runId, trial.id));
578
- await sink.json(`${base}/events.json`, { events: await reports.trajectory(runId, trial.id) });
579
- await sink.json(`${base}/artifacts.json`, { artifacts: await reports.listArtifacts(runId, trial.id) });
580
- const workspace = await reports.listCandidateWorkspace(runId, trial.id);
581
- await sink.json(`${base}/workspace.json`, workspace);
582
- if (options.workspaceFiles === false) continue;
583
- for (const entry of workspace) {
584
- if (entry.kind !== "file") continue;
585
- const response = await reports.candidateFile(runId, trial.id, entry.path);
586
- await sink.bytes(`${base}/workspace/${entry.path}`, new Uint8Array(await response.arrayBuffer()));
587
- files += 1;
588
- }
540
+ for (const attachment of detail.attachments ?? []) {
541
+ const response = await reports.attachmentFile(runId, attachment.name);
542
+ await sink.bytes(`attachments/${attachment.name}`, new Uint8Array(await response.arrayBuffer()));
543
+ files += 1;
589
544
  }
590
- return {
591
- trialIds: trials.map((trial) => trial.id),
592
- files
593
- };
545
+ for (const entry of workspace) {
546
+ if (entry.kind !== "file") continue;
547
+ const response = await reports.candidateFile(runId, entry.path);
548
+ await sink.bytes(`workspace/${entry.path}`, new Uint8Array(await response.arrayBuffer()));
549
+ files += 1;
550
+ }
551
+ return { files };
594
552
  }
595
553
  /** Total size and file count of a directory, for the build summary. */
596
554
  async function directorySize(root) {
@@ -639,7 +597,7 @@ function listMatrixCells(matrix, input) {
639
597
  if (![
640
598
  "eval",
641
599
  "agent",
642
- "trials",
600
+ "repeat",
643
601
  ...[...Object.keys(matrix.cases?.[0] ?? {}), ...Object.keys(matrix.parameters)].map((key) => `axis:${key}`)
644
602
  ].includes(input.sort)) throw new Error(`Unknown matrix sort: ${input.sort}`);
645
603
  const selection = input.evalIds.length ? { evals: input.evalIds } : {};
@@ -663,7 +621,7 @@ function listMatrixCells(matrix, input) {
663
621
  }
664
622
  if (input.sort === "eval") return cell.eval.name ?? cell.eval.id;
665
623
  if (input.sort === "agent") return cell.eval.agent?.identity?.name ?? cell.eval.agent?.identity?.kind ?? "";
666
- return cell.eval.policy?.trials ?? 1;
624
+ return cell.eval.repeat ?? 1;
667
625
  }
668
626
  cells.sort((left, right) => {
669
627
  const a = value(left);
@@ -894,7 +852,12 @@ async function discover(root, config) {
894
852
  async function isDirectory(path) {
895
853
  return stat(path).then((info) => info.isDirectory(), () => false);
896
854
  }
897
- async function loadProject(cwd, configPath) {
855
+ /**
856
+ * The project's config and root, without importing any scenario: enough for
857
+ * commands that only touch results (`evalkit upgrade`), and that must work
858
+ * while scenarios from an older EvalKit still fail to load.
859
+ */
860
+ async function loadConfig(cwd, configPath) {
898
861
  const candidates = configPath ? [resolve(cwd, configPath)] : [
899
862
  "evalkit.config.ts",
900
863
  "evalkit.config.js",
@@ -908,7 +871,17 @@ async function loadProject(cwd, configPath) {
908
871
  const root = file ? dirname(file) : resolve(cwd);
909
872
  const config = file ? (await importProjectModule(file)).default : {};
910
873
  if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Config must default-export defineConfig({...})");
874
+ if (config.execution && "trials" in config.execution) throw new Error("execution.trials was renamed to execution.repeat in EvalKit 0.9");
875
+ if (config.judge !== void 0 && typeof config.judge?.start !== "function") throw new Error("judge in the config must be an agent made with defineAgent()");
911
876
  for (const [key, value] of Object.entries(config.execution ?? {})) if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) throw new Error(`execution.${key} must be a positive integer`);
877
+ return {
878
+ root,
879
+ ...file ? { file } : {},
880
+ config
881
+ };
882
+ }
883
+ async function loadProject(cwd, configPath) {
884
+ const { root, file, config } = await loadConfig(cwd, configPath);
912
885
  let registry = config.registry;
913
886
  if (!file && !configPath && await exists$1(resolve(root, "src/registry.ts"))) registry = (await importProjectModule(resolve(root, "src/registry.ts"))).default;
914
887
  let loadErrors = [];
@@ -937,6 +910,17 @@ async function loadProject(cwd, configPath) {
937
910
  matrices: [...registry.matrices, matrix]
938
911
  };
939
912
  }
913
+ const judge = config.judge;
914
+ if (judge) {
915
+ const catalog = registry.catalog;
916
+ registry = {
917
+ ...registry,
918
+ catalog: (options) => catalog({
919
+ judge,
920
+ ...options
921
+ })
922
+ };
923
+ }
940
924
  return {
941
925
  root,
942
926
  config,
@@ -944,6 +928,34 @@ async function loadProject(cwd, configPath) {
944
928
  loadErrors
945
929
  };
946
930
  }
931
+ /**
932
+ * Where a project's run workspaces live. By default outside the project, in
933
+ * the system temp directory (one directory per project, so `tidy` and `dev`
934
+ * find the ones it kept): an agent that looks around its workspace finds no
935
+ * project source, no `AGENTS.md` or skills above it, and no results.
936
+ * `sandboxDir` overrides it; `inside` says when that puts them back in the
937
+ * project, which `run` warns about.
938
+ */
939
+ function sandboxLocation(project) {
940
+ if (project.config.sandboxDir === void 0) return {
941
+ path: defaultSandboxDir(project.root),
942
+ inside: false
943
+ };
944
+ const path = resolve(project.root, project.config.sandboxDir);
945
+ const fromRoot = relative(project.root, path);
946
+ return {
947
+ path,
948
+ inside: !fromRoot.startsWith("..") && !isAbsolute(fromRoot)
949
+ };
950
+ }
951
+ /** `<tmp>/evalkit/<project name>-<hash of its path>`: stable per checkout. */
952
+ function defaultSandboxDir(root) {
953
+ const hash = createHash("sha256").update(root).digest("hex").slice(0, 10);
954
+ const name = basename(root).replace(/[^A-Za-z0-9._-]/g, "-") || "project";
955
+ return join(tmpdir(), "evalkit", `${name}-${hash}`);
956
+ }
957
+ /** The warning `run` prints when workspaces sit inside the project. */
958
+ const SANDBOX_INSIDE_WARNING = "sandboxDir is inside the project: agents can read its source, its AGENTS.md and skills, its results and other runs’ workspaces from there. Leave sandboxDir unset to keep workspaces in the system temp directory.";
947
959
  //#endregion
948
960
  //#region ../cli/src/terminal.ts
949
961
  /** Human CLI output. Colors and live progress only appear on an interactive terminal. */
@@ -996,25 +1008,23 @@ function cellLabel(name, parameters) {
996
1008
  const shown = Object.entries(parameters).map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`).join(" ");
997
1009
  return shown ? `${bold(name)} ${dim(shown)}` : bold(name);
998
1010
  }
999
- function passed(result) {
1000
- return result.status === "completed" && (result.aggregateScoring?.passRate === 1 || Boolean(result.scoring?.passed));
1001
- }
1002
1011
  /** Live log for runs launched from the `evalkit dev` dashboard. */
1003
1012
  const runLog = {
1004
1013
  started(name, parameters = {}, suiteId) {
1005
1014
  const suite = suiteId ? dim(` (suite ${suiteId})`) : "";
1006
1015
  console.log(`${clock()} ${cyan("▶ run ")} ${cellLabel(name, parameters)}${suite}`);
1007
1016
  },
1008
- finished(name, parameters, result, ms) {
1009
- const ok = passed(result);
1010
- const trials = result.trials?.length ?? 1;
1011
- const passedTrials = result.aggregateScoring?.passed ?? (ok ? trials : 0);
1012
- const status = result.status === "completed" ? ok ? green("✓ pass") : red("✗ fail") : red(`✗ ${result.status}`);
1013
- console.log(`${clock()} ${status} ${cellLabel(name, parameters)} ${dim(`${passedTrials}/${trials} trials · ${duration(ms)}`)}`);
1014
- console.log(` ${dim(`report ${result.reportLocation}`)}`);
1017
+ /** One line per run; a batch's runs say which one they are. */
1018
+ finished(name, parameters, runs, ms) {
1019
+ for (const result of runs) {
1020
+ const status = result.status === "passed" ? green("✓ pass") : result.status === "failed" ? red("✗ fail") : red(`✗ ${result.status}`);
1021
+ const batch = result.batch ? ` #${result.batch.index + 1}/${result.batch.size}` : "";
1022
+ console.log(`${clock()} ${status} ${cellLabel(name, parameters)}${dim(`${batch} · ${duration(result.durationMs ?? ms)}`)}`);
1023
+ console.log(` ${dim(`report ${result.reportLocation}`)}`);
1024
+ }
1015
1025
  },
1016
- cell(cell, result, ms) {
1017
- runLog.finished(cell.eval.name ?? authoringId(cell.eval), cell.parameters, result, ms);
1026
+ cell(cell, runs, ms) {
1027
+ runLog.finished(cell.eval.name ?? authoringId(cell.eval), cell.parameters, runs, ms);
1018
1028
  },
1019
1029
  failed(name, error) {
1020
1030
  console.log(`${clock()} ${red("✗ error")} ${bold(name)} ${error instanceof Error ? error.message : String(error)}`);
@@ -1066,72 +1076,88 @@ async function confirm(question) {
1066
1076
  }
1067
1077
  //#endregion
1068
1078
  //#region ../cli/src/run-reporter.ts
1069
- function scenarioOutcome(result) {
1079
+ function runOutcome(result) {
1070
1080
  if (result.status === "cancelled") return "cancelled";
1071
- return result.status === "completed" && (result.trials ? result.trials.length > 0 && result.trials.every((trial) => trial.status === "completed" && trial.scoring?.passed) : Boolean(result.scoring?.passed)) ? "passed" : "failed";
1072
- }
1073
- function passAtFor(definition, name) {
1074
- const rule = [...definition.transcript, ...definition.scoring].find((step) => step.kind === "predicate" && step.name === name);
1075
- return rule?.kind === "predicate" ? rule.passAt : void 0;
1081
+ return result.status === "passed" ? "passed" : "failed";
1076
1082
  }
1077
1083
  function value(score) {
1078
1084
  return score.value === void 0 ? "" : score.value.toFixed(1);
1079
1085
  }
1086
+ /**
1087
+ * One failed verdict in words: `history 0.0 (passes at 1): got 2 entries`,
1088
+ * or for a choices judge `Correctness C (passes at A or B): wrong product`.
1089
+ */
1090
+ function verdictReason(definition, verdict) {
1091
+ const rule = [...definition.transcript, ...definition.scoring].find((step) => step.kind === "judge" && step.name === verdict.name);
1092
+ const answer = rule?.kind === "judge" ? rule.answer : void 0;
1093
+ const mark = verdict.passAt === void 0 ? "" : answer?.kind === "choices" ? ` (passes at ${passMark(verdict.passAt, answer)})` : ` (passes at ${verdict.passAt})`;
1094
+ const why = [criteriaReason(verdict.criteria), firstLine(verdict.explanation)].filter(Boolean).join(" · ");
1095
+ return `${verdict.name} ${verdict.choice ?? value(verdict)}${mark}${why ? `: ${why}` : ""}`;
1096
+ }
1097
+ /**
1098
+ * Which criteria let the score down, so seven checks the app never reached
1099
+ * read as one cause (`not reached: saves, history`), not seven failures.
1100
+ */
1101
+ function criteriaReason(criteria) {
1102
+ if (!criteria?.length) return "";
1103
+ const failed = criteria.filter((criterion) => "value" in criterion && criterion.value < 1);
1104
+ const unreached = criteria.filter((criterion) => !("value" in criterion));
1105
+ return [failed.length ? `failed ${failed.map((criterion) => {
1106
+ const why = firstLine(criterion.explanation, 60);
1107
+ return why ? `${criterion.name} (${why})` : criterion.name;
1108
+ }).join(", ")}` : "", unreached.length ? `not reached ${unreached.map((criterion) => criterion.name).join(", ")}` : ""].filter(Boolean).join(" · ");
1109
+ }
1080
1110
  function scoreReason(definition, score) {
1081
- if (score.error) return `${score.name} error: ${score.error.message}`;
1082
- const passAt = passAtFor(definition, score.name);
1083
- return `${score.name} ${value(score)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
1111
+ if (score.error) return `${score.name} error: ${firstLine(score.error.message)}`;
1112
+ return verdictReason(definition, score);
1084
1113
  }
1085
1114
  function checkpointReason(definition, checkpoint) {
1086
- if (checkpoint.status === "error") return `step ${checkpoint.step} ${checkpoint.name} error: ${checkpoint.error?.message ?? ""}`;
1087
- const passAt = passAtFor(definition, checkpoint.name);
1088
- return `step ${checkpoint.step} ${checkpoint.name} ${checkpoint.value === void 0 ? checkpoint.status : value(checkpoint)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
1115
+ if (checkpoint.status === "error") return `step ${checkpoint.step} ${checkpoint.name} error: ${firstLine(checkpoint.error?.message)}`;
1116
+ if (checkpoint.value === void 0 && !checkpoint.choice) return `step ${checkpoint.step} ${checkpoint.name} ${checkpoint.status}`;
1117
+ return `step ${checkpoint.step} ${verdictReason(definition, checkpoint)}`;
1089
1118
  }
1090
- /** What went wrong in a scenario, deduplicated across its trials. */
1091
- function reasonsFor(definition, trials) {
1119
+ /** What went wrong in a run. */
1120
+ function reasonsFor(definition, run) {
1092
1121
  const reasons = /* @__PURE__ */ new Set();
1093
1122
  const skipped = /* @__PURE__ */ new Set();
1094
- for (const trial of trials) {
1095
- if (trial.status === "failed" && trial.error) reasons.add(`error: ${trial.error.message}`);
1096
- for (const checkpoint of trial.scoring?.checkpoints ?? []) if (checkpoint.status === "failed" || checkpoint.status === "error") reasons.add(checkpointReason(definition, checkpoint));
1097
- for (const score of trial.scoring?.results ?? []) if (score.skipped) skipped.add(score.name);
1098
- else if (!score.passed) reasons.add(scoreReason(definition, score));
1099
- for (const name of trial.scoring?.skippedScorers ?? []) if (trial.status !== "cancelled") reasons.add(`${name} not run`);
1100
- }
1123
+ if (run.status === "errored" && run.error) reasons.add(`error: ${firstLine(run.error.message, 160)}`);
1124
+ for (const checkpoint of run.scoring?.checkpoints ?? []) if (checkpoint.status === "failed" || checkpoint.status === "error") reasons.add(checkpointReason(definition, checkpoint));
1125
+ for (const score of run.scoring?.results ?? []) if (score.skipped) skipped.add(score.name);
1126
+ else if (!score.passed) reasons.add(scoreReason(definition, score));
1127
+ for (const name of run.scoring?.skippedScorers ?? []) if (run.status !== "cancelled") reasons.add(`${name} not run`);
1101
1128
  return {
1102
1129
  reasons: [...reasons],
1103
1130
  skipped: [...skipped]
1104
1131
  };
1105
1132
  }
1106
- function scenarioResult(cell, result, suiteId) {
1133
+ function runReport(cell, result, suiteId) {
1107
1134
  const evalId = authoringId(cell.eval);
1108
- const trials = result.trials ?? [result];
1109
1135
  return {
1110
1136
  label: suiteId ? `${suiteId}/${evalId}` : evalId,
1111
1137
  evalId,
1112
1138
  ...suiteId ? { suiteId } : {},
1113
1139
  parameters: cell.parameters,
1114
- outcome: scenarioOutcome(result),
1140
+ outcome: runOutcome(result),
1115
1141
  ...result.durationMs !== void 0 ? { durationMs: result.durationMs } : {},
1116
1142
  result,
1117
- ...reasonsFor(cell.eval, trials)
1143
+ ...reasonsFor(cell.eval, result)
1118
1144
  };
1119
1145
  }
1120
1146
  function parameterLabel(parameters) {
1121
1147
  return Object.entries(parameters).map(([key, item]) => `${key}=${typeof item === "string" ? item : JSON.stringify(item)}`).join(" ");
1122
1148
  }
1123
- /** One printed line for a finished scenario; failing reasons on the same line. */
1124
- function scenarioLine(scenario) {
1149
+ /** One printed line for a finished run; failing reasons on the same line. */
1150
+ function runLine(scenario) {
1125
1151
  const mark = scenario.outcome === "passed" ? green("✓") : scenario.outcome === "cancelled" ? yellow("◌") : red("✗");
1126
1152
  const parameters = parameterLabel(scenario.parameters);
1127
- const trials = scenario.result.trials;
1128
- const trialCount = trials && trials.length > 1 ? dim(` ${trials.filter((trial) => trial.status === "completed" && trial.scoring?.passed).length}/${trials.length} trials`) : "";
1153
+ const batch = scenario.result.batch;
1154
+ const position = batch ? dim(` #${batch.index + 1}/${batch.size}`) : "";
1129
1155
  const details = [
1130
1156
  ...scenario.outcome === "failed" ? scenario.reasons : [],
1131
1157
  ...scenario.outcome === "cancelled" ? ["cancelled"] : [],
1132
1158
  ...scenario.skipped.map((name) => `${name} skipped`)
1133
1159
  ];
1134
- return ` ${mark} ${bold(scenario.label)}${parameters ? ` ${dim(parameters)}` : ""}${trialCount}${details.length ? ` ${dim(details.join(" · "))}` : ""}`;
1160
+ return ` ${mark} ${bold(scenario.label)}${parameters ? ` ${dim(parameters)}` : ""}${position}${details.length ? ` ${dim(details.join(" · "))}` : ""}`;
1135
1161
  }
1136
1162
  /**
1137
1163
  * Prints run progress. On a terminal, failures print as they happen and a live
@@ -1177,29 +1203,29 @@ function createRunReporter(options) {
1177
1203
  timer.unref?.();
1178
1204
  }
1179
1205
  },
1180
- onEvent(trial, event) {
1181
- if (event.source === "runner" && event.kind === "trial-started") {
1182
- running.set(trial.trialId, {
1183
- label: options.labelFor(trial),
1206
+ onEvent(run, event) {
1207
+ if (event.source === "runner" && event.kind === "run-started") {
1208
+ running.set(run.runId, {
1209
+ label: options.labelFor(run),
1184
1210
  startedAt: now()
1185
1211
  });
1186
1212
  return;
1187
1213
  }
1188
- const activity = running.get(trial.trialId);
1214
+ const activity = running.get(run.runId);
1189
1215
  if (!activity) return;
1190
- if (event.source === "runner" && (event.kind === "trial-completed" || event.kind === "error")) running.delete(trial.trialId);
1216
+ if (event.source === "runner" && (event.kind === "run-completed" || event.kind === "error")) running.delete(run.runId);
1191
1217
  else if (event.source === "aut" && event.kind === "tool-call") activity.doing = `tool ${event.name}`;
1192
1218
  else if (event.source === "runner" && event.kind === "scorer-started") activity.doing = `scoring ${event.scorer}`;
1193
1219
  else if (event.source === "runner" && event.kind === "checkpoint-started") activity.doing = `check ${event.name}`;
1194
1220
  },
1195
- onScenario(scenario) {
1221
+ onRun(scenario) {
1196
1222
  scenarios.push(scenario);
1197
- for (const trial of scenario.result.trials ?? [scenario.result]) running.delete(trial.trialId);
1198
- if (!options.interactive || options.verbose || scenario.outcome !== "passed") print(scenarioLine(scenario));
1223
+ running.delete(scenario.result.runId);
1224
+ if (!options.interactive || options.verbose || scenario.outcome !== "passed") print(runLine(scenario));
1199
1225
  },
1200
1226
  /** Called on the first Ctrl+C. */
1201
1227
  cancelling() {
1202
- print(`\n ${yellow("◌")} Cancelling: finishing running trials. Press Ctrl+C again to exit now.`);
1228
+ print(`\n ${yellow("◌")} Cancelling: finishing running runs. Press Ctrl+C again to exit now.`);
1203
1229
  },
1204
1230
  finish(durationMs) {
1205
1231
  if (timer) clearInterval(timer);
@@ -1208,11 +1234,12 @@ function createRunReporter(options) {
1208
1234
  passed: scenarios.filter((s) => s.outcome === "passed").length,
1209
1235
  failed: scenarios.filter((s) => s.outcome === "failed").length,
1210
1236
  cancelled: scenarios.filter((s) => s.outcome === "cancelled").length,
1211
- scenarios: scenarios.length,
1237
+ runs: scenarios.length,
1238
+ scenarios: new Set(scenarios.map((s) => `${s.label} ${parameterLabel(s.parameters)}`)).size,
1212
1239
  durationMs
1213
1240
  };
1214
1241
  },
1215
- scenarios: () => scenarios
1242
+ runs: () => scenarios
1216
1243
  };
1217
1244
  }
1218
1245
  function totalsLine(totals) {
@@ -1221,7 +1248,8 @@ function totalsLine(totals) {
1221
1248
  totals.failed ? red(`${totals.failed} failed`) : "",
1222
1249
  totals.cancelled ? yellow(`${totals.cancelled} cancelled`) : ""
1223
1250
  ].filter(Boolean);
1224
- return ` ${bold(parts.join(" · "))} ${dim(`· ${plural(totals.scenarios, "scenario")} in ${duration(totals.durationMs)}`)}`;
1251
+ const coverage = totals.runs === totals.scenarios ? plural(totals.runs, "run") : `${plural(totals.runs, "run")} of ${plural(totals.scenarios, "scenario")}`;
1252
+ return ` ${bold(parts.join(" · "))} ${dim(`· ${coverage} in ${duration(totals.durationMs)}`)}`;
1225
1253
  }
1226
1254
  //#endregion
1227
1255
  //#region ../cli/src/junit.ts
@@ -1231,8 +1259,8 @@ function escape(text) {
1231
1259
  const seconds = (ms) => ((ms ?? 0) / 1e3).toFixed(3);
1232
1260
  /**
1233
1261
  * A JUnit XML report: one test suite per EvalKit suite (`default` for
1234
- * scenarios without one) and one test case per scenario, per matrix cell
1235
- * when it has parameters.
1262
+ * scenarios without one) and one test case per run, named after its scenario
1263
+ * and parameters, and its place in a batch (` #2`) when repeated.
1236
1264
  */
1237
1265
  function junitReport(scenarios) {
1238
1266
  const bySuite = /* @__PURE__ */ new Map();
@@ -1246,7 +1274,8 @@ function junitReport(scenarios) {
1246
1274
  const time = cases.reduce((sum, c) => sum + (c.durationMs ?? 0), 0);
1247
1275
  const testcases = cases.map((scenario) => {
1248
1276
  const parameters = parameterLabel(scenario.parameters);
1249
- const attributes = `name="${escape(parameters ? `${scenario.evalId} [${parameters}]` : scenario.evalId)}" classname="${escape(suite)}" time="${seconds(scenario.durationMs)}"`;
1277
+ const batch = scenario.result.batch;
1278
+ const attributes = `name="${escape((parameters ? `${scenario.evalId} [${parameters}]` : scenario.evalId) + (batch ? ` #${batch.index + 1}` : ""))}" classname="${escape(suite)}" time="${seconds(scenario.durationMs)}"`;
1250
1279
  if (scenario.outcome === "failed") return ` <testcase ${attributes}>\n <failure message="${escape(scenario.reasons.join("; ") || "failed")}">${escape(scenario.reasons.join("\n"))}</failure>\n </testcase>`;
1251
1280
  if (scenario.outcome === "cancelled") return ` <testcase ${attributes}>\n <skipped message="cancelled"/>\n </testcase>`;
1252
1281
  if (allSkipped(scenario)) return ` <testcase ${attributes}>\n <skipped message="${escape(`all scorers skipped: ${scenario.skipped.join(", ")}`)}"/>\n </testcase>`;
@@ -1256,15 +1285,15 @@ function junitReport(scenarios) {
1256
1285
  });
1257
1286
  return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites name="evalkit" tests="${scenarios.length}" failures="${scenarios.filter((c) => c.outcome === "failed").length}">\n${suites.join("\n")}\n</testsuites>\n`;
1258
1287
  }
1259
- /** True when every score of every trial was skip()ped. */
1288
+ /** True when every score was skip()ped. */
1260
1289
  function allSkipped(scenario) {
1261
- const results = (scenario.result.trials ?? [scenario.result]).flatMap((trial) => trial.scoring?.results ?? []);
1290
+ const results = scenario.result.scoring?.results ?? [];
1262
1291
  return results.length > 0 && results.every((score) => score.skipped);
1263
1292
  }
1264
1293
  //#endregion
1265
1294
  //#region ../cli/src/version.ts
1266
1295
  /** The CLI version; kept in sync with package.json when releasing. */
1267
- const VERSION = "0.8.0";
1296
+ const VERSION = "0.10.0";
1268
1297
  //#endregion
1269
1298
  //#region ../cli/src/run-command.ts
1270
1299
  /** Validates `run` flags into a selection, parameter overrides and limits. */
@@ -1291,28 +1320,27 @@ function normalizeRunOptions(values, positionals) {
1291
1320
  const index = value.indexOf("=");
1292
1321
  if (index < 1) throw new Error("--select requires axis=value");
1293
1322
  const axis = value.slice(0, index);
1294
- let choice;
1323
+ const raw = value.slice(index + 1);
1324
+ let choices;
1295
1325
  try {
1296
- choice = JSON.parse(value.slice(index + 1));
1326
+ choices = [JSON.parse(raw)];
1297
1327
  } catch {
1298
- choice = value.slice(index + 1);
1328
+ choices = raw.split(",").filter(Boolean);
1299
1329
  }
1300
- (selection[axis] ??= []).push(choice);
1330
+ (selection[axis] ??= []).push(...choices);
1301
1331
  }
1302
1332
  for (const value of values.param ?? []) {
1303
1333
  const index = value.indexOf("=");
1304
1334
  if (index < 1) throw new Error("--param requires name=JSON");
1305
1335
  parameters[value.slice(0, index)] = JSON.parse(value.slice(index + 1));
1306
1336
  }
1307
- if (values.suite && values.matrix) throw new Error("--suite and --matrix cannot be used together");
1308
- if ((values.suite || values.matrix) && positionals.length) throw new Error("Use --eval to select evals with --suite or --matrix");
1309
1337
  return {
1310
1338
  values,
1311
1339
  positionals,
1312
1340
  parameters,
1313
1341
  selection,
1314
1342
  concurrency: integer("concurrency"),
1315
- trials: integer("trials")
1343
+ repeat: integer("repeat")
1316
1344
  };
1317
1345
  }
1318
1346
  /** Load errors that affect what was asked for, so the run must not pretend it covered them. */
@@ -1333,35 +1361,32 @@ async function runProjectCommand(cwd, parsed) {
1333
1361
  const project = await loadProject(cwd, values.config);
1334
1362
  process.chdir(project.root);
1335
1363
  const registry = project.registry;
1336
- const requestedEvals = values.eval?.flatMap((value) => value.split(",")) ?? [];
1364
+ const requestedEvals = [...values.eval ?? [], ...positionals].flatMap((value) => value.split(",").filter(Boolean));
1337
1365
  let matrix;
1338
1366
  let suiteId;
1339
1367
  const quiet = Boolean(values.json || values["dry-run"]);
1340
1368
  if (!quiet) banner("run", VERSION);
1369
+ const suite = values.suite ? registry.suites.find((item) => item.id === values.suite) : void 0;
1341
1370
  if (values.suite) {
1342
- const suite = registry.suites.find((item) => item.id === values.suite);
1343
1371
  const broken = project.loadErrors.filter((error) => error.suiteId === values.suite);
1344
1372
  if (!suite && !broken.length) throw new Error(`Unknown suite: ${values.suite}`);
1345
1373
  suiteId = values.suite;
1346
- matrix = defineEvalMatrix({
1347
- id: "default",
1348
- evals: suite?.evals ?? [],
1349
- parameters: project.config.matrix?.parameters ?? {},
1350
- defaults: project.config.matrix?.defaults,
1351
- cases: project.config.matrix?.cases,
1352
- exclude: project.config.matrix?.exclude
1353
- });
1354
- } else if (values.matrix) {
1355
- matrix = registry.matrices.find((item) => item.id === values.matrix);
1356
- if (!matrix) throw new Error(`Unknown matrix: ${values.matrix}`);
1357
- } else {
1358
- requestedEvals.push(...positionals.flatMap((value) => value.split(",")));
1359
- matrix = project.config.matrix ? registry.matrices.at(-1) : defineEvalMatrix({
1360
- id: "default",
1361
- evals: registry.evals,
1362
- parameters: {}
1363
- });
1364
1374
  }
1375
+ const configured = values.matrix ? registry.matrices.find((item) => item.id === values.matrix) : project.config.matrix ? registry.matrices.at(-1) : void 0;
1376
+ if (values.matrix && !configured) throw new Error(`Unknown matrix: ${values.matrix}`);
1377
+ if (configured && suite) {
1378
+ const inSuite = new Set(suite.evals.map(authoringId));
1379
+ const both = configured.evals.map(authoringId).filter((id) => inSuite.has(id));
1380
+ if (!both.length) throw new Error(`Matrix ${authoringId(configured)} has no scenarios from suite ${suite.id}`);
1381
+ const outside = requestedEvals.filter((id) => !both.includes(id));
1382
+ if (outside.length) throw new Error(`Not in both matrix ${authoringId(configured)} and suite ${suite.id}: ${outside.join(", ")}`);
1383
+ if (!requestedEvals.length) requestedEvals.push(...both);
1384
+ }
1385
+ matrix = configured ?? defineEvalMatrix({
1386
+ id: "default",
1387
+ evals: values.suite ? suite?.evals ?? [] : registry.evals,
1388
+ parameters: {}
1389
+ });
1365
1390
  const broken = brokenSelection(project.loadErrors, {
1366
1391
  ...suiteId ? { suiteId } : {},
1367
1392
  evalIds: requestedEvals,
@@ -1385,14 +1410,18 @@ async function runProjectCommand(cwd, parsed) {
1385
1410
  else throw new Error(`Unknown matrix axis: ${axis}`);
1386
1411
  const cells = matrix.count(selection);
1387
1412
  if (!cells) throw new Error("Selection contains no eligible cells; check cases and exclusions");
1388
- const trials = parsed.trials ?? project.config.execution?.trials;
1413
+ const repeat = parsed.repeat ?? project.config.execution?.repeat;
1414
+ let runs = 0;
1415
+ for (const cell of matrix.cells(selection)) runs += repeat ?? cell.eval.repeat ?? 1;
1389
1416
  const plan = {
1390
1417
  matrix: authoringId(matrix),
1391
1418
  evals: requestedEvals.length ? requestedEvals : matrix.evals.map((definition) => definition.id),
1392
1419
  cells,
1420
+ runs,
1393
1421
  parameters: selection.parameters,
1394
1422
  overrides: selection.overrides,
1395
- trials: trials ?? "eval policy"
1423
+ repeat: repeat ?? "scenario",
1424
+ scoring: Object.fromEntries(registry.catalog({ root: project.root }).filter((entry) => matrix.evals.some((definition) => definition.id === entry.id)).map((entry) => [entry.id, plannedChecks(entry)]))
1396
1425
  };
1397
1426
  if (values["dry-run"]) {
1398
1427
  console.log(JSON.stringify(plan, null, 2));
@@ -1401,7 +1430,9 @@ async function runProjectCommand(cwd, parsed) {
1401
1430
  const sweeps = Object.keys(matrix.parameters).length > 0 || Boolean(matrix.cases?.length);
1402
1431
  if (sweeps && cells > (project.config.execution?.maxCells ?? 100) && !values.all) throw new Error(`Selected ${cells} cells. Narrow the selection, inspect --dry-run, or pass --all.`);
1403
1432
  const reportDir = resolve(project.root, project.config.reportDir ?? "_evalkit-results");
1404
- const sandboxDir = resolve(project.root, project.config.sandboxDir ?? "_evalkit-sandbox");
1433
+ const sandbox = sandboxLocation(project);
1434
+ const sandboxDir = sandbox.path;
1435
+ if (sandbox.inside) console.error(` ${yellow("!")} ${SANDBOX_INSIDE_WARNING}`);
1405
1436
  const source = await detectSource(project.root, { outputDirs: [
1406
1437
  reportDir,
1407
1438
  sandboxDir,
@@ -1409,14 +1440,15 @@ async function runProjectCommand(cwd, parsed) {
1409
1440
  ] });
1410
1441
  const suiteOf = new Map(registry.suites.flatMap((suite) => suite.evals.map((evaluation) => [authoringId(evaluation), suite.id])));
1411
1442
  const suiteFor = (evalId) => suiteId ?? suiteOf.get(evalId);
1412
- if (!values.json) console.log(` Running ${plural(cells, "scenario")}${sweeps ? ` from matrix ${plan.matrix}` : ""}${suiteId ? ` from suite ${suiteId}` : ""}` + (source ? ` at ${shortSource(source)}${source.ref ? ` (${source.ref})` : ""}` : " (untracked: not in a git repository)") + "\n");
1443
+ if (!values.json) console.log(` Running ${runs === cells ? plural(cells, "scenario") : `${plural(runs, "run")} of ${plural(cells, "scenario")}`}${sweeps ? ` from matrix ${plan.matrix}` : ""}${suiteId ? ` from suite ${suiteId}` : ""}` + (source ? ` at ${shortSource(source)}${source.ref ? ` (${source.ref})` : ""}` : " (untracked: not in a git repository)") + "\n");
1413
1444
  const reporter = createRunReporter({
1414
1445
  interactive: interactive && !values.json,
1415
1446
  verbose: Boolean(values.verbose),
1416
- total: cells,
1417
- labelFor: (trial) => {
1418
- const suite = suiteFor(trial.evalId);
1419
- return suite ? `${suite}/${trial.evalId}` : trial.evalId;
1447
+ total: runs,
1448
+ labelFor: (run) => {
1449
+ const suite = suiteFor(run.evalId);
1450
+ const label = suite ? `${suite}/${run.evalId}` : run.evalId;
1451
+ return run.batch ? `${label} #${run.batch.index + 1}/${run.batch.size}` : label;
1420
1452
  },
1421
1453
  write: values.json ? () => {} : void 0
1422
1454
  });
@@ -1433,7 +1465,7 @@ async function runProjectCommand(cwd, parsed) {
1433
1465
  summary = await Effect.runPromise(runnerLogs(runMatrix(matrix, {
1434
1466
  selection,
1435
1467
  concurrency: parsed.concurrency ?? project.config.execution?.concurrency ?? 4,
1436
- trials,
1468
+ ...repeat ? { repeat } : {},
1437
1469
  ...suiteId ? { suiteId } : {},
1438
1470
  suiteFor: (cell) => suiteFor(authoringId(cell.eval)),
1439
1471
  ...values.local ? { runtime: "local" } : {},
@@ -1443,20 +1475,24 @@ async function runProjectCommand(cwd, parsed) {
1443
1475
  workspaceRoot: sandboxDir,
1444
1476
  keepWorkspaces: project.config.keepSandboxes,
1445
1477
  artifactExclude: project.config.artifacts?.exclude,
1478
+ attachmentLimits: project.config.artifacts?.attachments,
1479
+ judge: project.config.judge,
1446
1480
  signal: controller.signal,
1447
- onEvent: (trial, event) => reporter.onEvent(trial, event),
1448
- onResult: (cell, result) => {
1449
- const scenario = scenarioResult(cell, result, suiteFor(authoringId(cell.eval)));
1450
- if (values.json) console.log(JSON.stringify({
1451
- cell: {
1452
- key: cell.key,
1453
- eval: authoringId(cell.eval),
1454
- parameters: cell.parameters
1455
- },
1456
- outcome: scenario.outcome,
1457
- result
1458
- }));
1459
- reporter.onScenario(scenario);
1481
+ onEvent: (run, event) => reporter.onEvent(run, event),
1482
+ onResult: (cell, results) => {
1483
+ for (const result of results) {
1484
+ const report = runReport(cell, result, suiteFor(authoringId(cell.eval)));
1485
+ if (values.json) console.log(JSON.stringify({
1486
+ cell: {
1487
+ key: cell.key,
1488
+ eval: authoringId(cell.eval),
1489
+ parameters: cell.parameters
1490
+ },
1491
+ outcome: report.outcome,
1492
+ result
1493
+ }));
1494
+ reporter.onRun(report);
1495
+ }
1460
1496
  }
1461
1497
  })));
1462
1498
  } finally {
@@ -1466,7 +1502,7 @@ async function runProjectCommand(cwd, parsed) {
1466
1502
  if (values.junit) {
1467
1503
  const file = resolve(cwd, values.junit);
1468
1504
  await mkdir(dirname(file), { recursive: true });
1469
- await writeFile(file, junitReport(reporter.scenarios()));
1505
+ await writeFile(file, junitReport(reporter.runs()));
1470
1506
  }
1471
1507
  if (values.json) console.log(JSON.stringify({ summary: totals }));
1472
1508
  else {
@@ -1479,6 +1515,23 @@ async function runProjectCommand(cwd, parsed) {
1479
1515
  if (controller.signal.aborted) process.exitCode = 130;
1480
1516
  else if (summary.failed || broken.length) process.exitCode = 1;
1481
1517
  }
1518
+ /** A scenario's checkpoints and final scorers, as `--dry-run` lists them. */
1519
+ function plannedChecks(entry) {
1520
+ return [...entry.transcript.flatMap((step, index) => step.kind === "checkpoint" ? [{
1521
+ scorer: step.scorer,
1522
+ at: `step ${index}`
1523
+ }] : []), ...entry.scorers.map((scorer) => ({
1524
+ scorer,
1525
+ at: "final"
1526
+ }))].map(({ scorer, at }) => ({
1527
+ name: scorer.name,
1528
+ kind: scorer.kind,
1529
+ at,
1530
+ ...scorer.description ? { description: scorer.description } : {},
1531
+ passes: passMark(scorer.passAt, scorer.answer),
1532
+ ...scorer.agent ? { agent: scorer.agent.name ?? scorer.agent.id ?? scorer.agent.kind } : {}
1533
+ }));
1534
+ }
1482
1535
  //#endregion
1483
1536
  //#region ../cli/src/tidy.ts
1484
1537
  /**
@@ -1498,7 +1551,7 @@ function projectEvalIds(registry) {
1498
1551
  */
1499
1552
  async function tidyReports(options) {
1500
1553
  const known = projectEvalIds(options.registry);
1501
- const reports = createDashboardReportReader(options.reportRoot, options.registry);
1554
+ const reports = createDashboardReportReader(options.reportRoot);
1502
1555
  const result = {
1503
1556
  archived: [],
1504
1557
  restored: [],
@@ -1529,19 +1582,19 @@ async function tidyReports(options) {
1529
1582
  return result;
1530
1583
  }
1531
1584
  /**
1532
- * Remove the trial workspaces `keep` would not have kept: those of passed
1533
- * trials by default. Unfinished trials are never touched, so a run in
1534
- * progress keeps its workspaces; workspaces without a report are left alone.
1585
+ * Remove the run workspaces `keep` would not have kept: those of passed runs
1586
+ * by default. Unfinished runs are never touched, so a run in progress keeps
1587
+ * its workspace; workspaces without a report are left alone.
1535
1588
  */
1536
1589
  async function tidySandboxes(options) {
1537
1590
  const keep = options.keep ?? "failed";
1538
1591
  if (keep === "all") return { removed: 0 };
1539
- const reports = createDashboardReportReader(options.reportRoot, options.registry);
1592
+ const reports = createDashboardReportReader(options.reportRoot);
1540
1593
  let removed = 0;
1541
- for (const run of await reports.listRuns()) for (const trial of await reports.listTrials(run.id)) {
1542
- if (trial.status === "running") continue;
1543
- if (keep === "failed" && trial.status !== "passed") continue;
1544
- const workspace = trialWorkspacePath(options.sandboxRoot, trial.id);
1594
+ for (const run of await reports.listRuns()) {
1595
+ if (run.status === "running") continue;
1596
+ if (keep === "failed" && run.status !== "passed") continue;
1597
+ const workspace = runWorkspacePath(options.sandboxRoot, run.id);
1545
1598
  if (!await stat(workspace).catch(() => void 0)) continue;
1546
1599
  await rm(workspace, {
1547
1600
  recursive: true,
@@ -1601,11 +1654,22 @@ function noRunsMessage(selection, head, options) {
1601
1654
  if (selection.running) return `No finished runs for ${at}: ${plural(selection.running, "run")} still running. Share again when they finish.`;
1602
1655
  return `No runs for ${at}. Run \`evalkit run\` first${options.all ? "" : ", or pass --all to share runs from other commits"}.`;
1603
1656
  }
1604
- /** Where to look after sharing: the commit, or the repository if several commits were shared. */
1605
- function shareTarget(serverUrl, head, runs) {
1606
- const commits = new Set(runs.map((run) => run.source.commit));
1607
- const commit = commits.size === 1 ? `/-/${[...commits][0]}` : "";
1608
- return new URL(`/${repoKey(head)}${commit}`, serverUrl).href;
1657
+ /**
1658
+ * A share server API URL. The server may live below a path
1659
+ * (`https://example.com/evals`), so paths resolve below `share.url`.
1660
+ */
1661
+ function shareApiUrl(serverUrl, path) {
1662
+ return new URL(path, serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`);
1663
+ }
1664
+ /**
1665
+ * Where to look after sharing: the commit, or the repository if several
1666
+ * commits were shared. Both come from a run URL the server returned
1667
+ * (`<repo pages>/-/<sha>/runs/<id>`), since only the server knows its layout.
1668
+ */
1669
+ function shareTarget(serverUrl, runUrl, runs) {
1670
+ const commitUrl = runUrl?.replace(/\/runs\/[^/]+\/?$/, "");
1671
+ if (!commitUrl || commitUrl === runUrl) return serverUrl;
1672
+ return new Set(runs.map((run) => run.source.commit)).size === 1 ? commitUrl : commitUrl.replace(/\/-\/[0-9a-f]{40}$/, "") || serverUrl;
1609
1673
  }
1610
1674
  /** Build one run's upload bundle: run.json, catalog.json, and its snapshot. */
1611
1675
  async function runBundle(options) {
@@ -1613,7 +1677,10 @@ async function runBundle(options) {
1613
1677
  const encode = (value) => new TextEncoder().encode(`${JSON.stringify(value)}\n`);
1614
1678
  files.push({
1615
1679
  path: "run.json",
1616
- data: encode(options.run)
1680
+ data: encode({
1681
+ snapshotVersion: 2,
1682
+ ...options.run
1683
+ })
1617
1684
  });
1618
1685
  const saved = await readFile(resolve(options.reportRoot, options.run.id, "catalog.json")).catch(() => void 0);
1619
1686
  files.push({
@@ -1658,7 +1725,7 @@ async function resolveShareHeaders(headers) {
1658
1725
  }
1659
1726
  /** Check that the URL is an EvalKit share server and the headers get through. */
1660
1727
  async function checkShareServer(serverUrl, headers) {
1661
- const response = await fetch(new URL("/api/v1/repos", serverUrl), {
1728
+ const response = await fetch(shareApiUrl(serverUrl, "api/v1/repos"), {
1662
1729
  headers,
1663
1730
  redirect: "manual"
1664
1731
  });
@@ -1681,7 +1748,7 @@ async function uploadBundle(serverUrl, bundle, options = {}) {
1681
1748
  throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Upload failed: ${String(lastError)}`);
1682
1749
  }
1683
1750
  async function postBundle(serverUrl, bundle, headers) {
1684
- const response = await fetch(new URL("/api/v1/uploads", serverUrl), {
1751
+ const response = await fetch(shareApiUrl(serverUrl, "api/v1/uploads"), {
1685
1752
  method: "POST",
1686
1753
  headers: {
1687
1754
  ...headers,
@@ -1716,7 +1783,7 @@ async function expectShareServer(response, serverUrl) {
1716
1783
  if (denied || response.ok && !type.includes("json")) throw new ShareServerError(`${new URL(serverUrl).host} did not answer as an EvalKit share server (${response.status}${type ? `, ${type.split(";")[0]}` : ""}). ` + (denied ? "Check share.headers." : "Check share.url."));
1717
1784
  }
1718
1785
  async function shareRuns(options) {
1719
- const reports = createDashboardReportReader(options.reportRoot, options.registry);
1786
+ const reports = createDashboardReportReader(options.reportRoot);
1720
1787
  return options.step("Uploading", async (progress) => {
1721
1788
  const result = {
1722
1789
  created: 0,
@@ -1737,6 +1804,7 @@ async function shareRuns(options) {
1737
1804
  const uploaded = await uploadBundle(options.serverUrl, bundle, { headers: options.headers });
1738
1805
  result.bytes += bundle.byteLength;
1739
1806
  result[uploaded.status] += 1;
1807
+ result.url ??= uploaded.url;
1740
1808
  } catch (error) {
1741
1809
  if (error instanceof ShareServerError) throw error;
1742
1810
  result.failed.push({
@@ -1789,7 +1857,7 @@ export default defineConfig({
1789
1857
  "datasets/greeting/greeting.txt": "Hello\n",
1790
1858
  "datasets/greeting.dataset.ts": `import { defineDataset } from '@leostera/evalkit';
1791
1859
 
1792
- // Files the agent can read: copied into each trial's workspace under greeting/.
1860
+ // Files the agent can read: copied into each run's workspace under greeting/.
1793
1861
  // Paths are relative to the project root.
1794
1862
  export const greeting = defineDataset({
1795
1863
  id: 'greeting',
@@ -1865,18 +1933,17 @@ bun run evalkit build # write a read-only, shareable s
1865
1933
  Coding agents can use the EvalKit skills that ship with the package, in
1866
1934
  \`node_modules/@leostera/evalkit/dist/skills/\` (start with \`evalkit/SKILL.md\`).
1867
1935
 
1868
- EvalKit installs from npm. Reports, trial workspaces, and dashboard snapshots stay local
1936
+ EvalKit installs from npm. Reports, run workspaces, and dashboard snapshots stay local
1869
1937
  and are ignored by Git. A snapshot in \`_evalkit-dashboard/\` contains report messages,
1870
1938
  scores, evidence, and candidate files (never evaluator workspaces): review it before sharing.
1871
1939
  `;
1872
1940
  }
1873
1941
  //#endregion
1874
1942
  //#region ../cli/src/new-project.ts
1875
- const npmDependency = "^0.8.0";
1943
+ const npmDependency = "^0.10.0";
1876
1944
  const ignored = [
1877
1945
  "node_modules/",
1878
1946
  "_evalkit-results/",
1879
- "_evalkit-sandbox/",
1880
1947
  "_evalkit-dashboard/",
1881
1948
  "_evalkit-share/"
1882
1949
  ];
@@ -1996,24 +2063,59 @@ const FINISHED = /* @__PURE__ */ new Set([
1996
2063
  function unitKey(run) {
1997
2064
  return `${run.evalId}\0${canonicalParameters(run.parameters ?? {})}`;
1998
2065
  }
1999
- /** The run that represents each unit: the latest finished one, else the latest at all. */
2066
+ /**
2067
+ * What represents each unit: the latest finished run's batch (its finished
2068
+ * runs), or that run alone; else the latest run at all.
2069
+ */
2000
2070
  function latestByUnit(runs) {
2001
- const byUnit = /* @__PURE__ */ new Map();
2002
- let ignored = 0;
2003
- const ordered = [...runs].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2071
+ const latest = /* @__PURE__ */ new Map();
2072
+ const ordered = [...runs].filter((run) => run.status !== "running").sort((a, b) => b.startedAt.localeCompare(a.startedAt));
2004
2073
  for (const run of ordered) {
2005
- if (run.status === "running") continue;
2006
2074
  const key = unitKey(run);
2007
- const current = byUnit.get(key);
2008
- if (!current) byUnit.set(key, run);
2009
- else {
2010
- ignored += 1;
2011
- if (!FINISHED.has(current.status) && FINISHED.has(run.status)) byUnit.set(key, run);
2012
- }
2075
+ const current = latest.get(key);
2076
+ if (!current || !FINISHED.has(current.status) && FINISHED.has(run.status)) latest.set(key, run);
2077
+ }
2078
+ const byUnit = /* @__PURE__ */ new Map();
2079
+ let used = 0;
2080
+ for (const [key, run] of latest) {
2081
+ const members = run.batch ? ordered.filter((other) => other.batch?.id === run.batch.id && FINISHED.has(other.status)) : [run];
2082
+ used += members.length;
2083
+ byUnit.set(key, side(members));
2013
2084
  }
2014
2085
  return {
2015
2086
  byUnit,
2016
- ignored
2087
+ ignored: ordered.length - used
2088
+ };
2089
+ }
2090
+ function side(runs) {
2091
+ const scored = runs.filter((run) => run.status !== "errored");
2092
+ const passed = scored.filter((run) => run.status === "passed").length;
2093
+ const values = /* @__PURE__ */ new Map();
2094
+ for (const run of runs) for (const scorer of run.scorers ?? []) {
2095
+ const key = `${scorer.kind}\0${scorer.name}`;
2096
+ const entry = values.get(key) ?? {
2097
+ scorer,
2098
+ sum: 0,
2099
+ n: 0
2100
+ };
2101
+ if (scorer.value !== void 0 && run.status !== "errored") {
2102
+ entry.sum += scorer.value;
2103
+ entry.n += 1;
2104
+ }
2105
+ values.set(key, entry);
2106
+ }
2107
+ const first = runs[0];
2108
+ return {
2109
+ ...first,
2110
+ status: !scored.length ? first.status : passed === runs.length ? "passed" : "failed",
2111
+ scorers: [...values.values()].map(({ scorer, sum, n }) => ({
2112
+ name: scorer.name,
2113
+ kind: scorer.kind,
2114
+ ...n ? { value: sum / n } : {}
2115
+ })),
2116
+ passed,
2117
+ total: runs.length,
2118
+ runs: [...runs]
2017
2119
  };
2018
2120
  }
2019
2121
  function scorerSignature(run) {
@@ -2030,8 +2132,9 @@ function classify(base, head) {
2030
2132
  change: "not-comparable",
2031
2133
  reason: "different scorers"
2032
2134
  };
2033
- if (base.status === "passed" && head.status === "failed") return { change: "regressed" };
2034
- if (base.status === "failed" && head.status === "passed") return { change: "fixed" };
2135
+ const rate = (side) => side.passed / side.total;
2136
+ if (rate(head) < rate(base)) return { change: "regressed" };
2137
+ if (rate(head) > rate(base)) return { change: "fixed" };
2035
2138
  return { change: "unchanged" };
2036
2139
  }
2037
2140
  function label(run, suite) {
@@ -2056,8 +2159,8 @@ function suiteScorers(units) {
2056
2159
  name: scorer.name,
2057
2160
  kind: scorer.kind
2058
2161
  });
2059
- if (scorer.mean === void 0) continue;
2060
- sides[side].set(key, [...sides[side].get(key) ?? [], scorer.mean]);
2162
+ if (scorer.value === void 0) continue;
2163
+ sides[side].set(key, [...sides[side].get(key) ?? [], scorer.value]);
2061
2164
  }
2062
2165
  }
2063
2166
  for (const unit of units) {
@@ -2111,7 +2214,11 @@ function compareRuns(baseRuns, headRuns) {
2111
2214
  const changedDatasets = /* @__PURE__ */ new Set();
2112
2215
  for (const unit of units) {
2113
2216
  if (!unit.base || !unit.head) continue;
2114
- const digests = (run) => new Map((run.datasets ?? []).map((d) => [d.id, d.digests.join(",")]));
2217
+ const digests = (side) => {
2218
+ const byId = /* @__PURE__ */ new Map();
2219
+ for (const run of side.runs) for (const dataset of run.datasets ?? []) byId.set(dataset.id, (byId.get(dataset.id) ?? /* @__PURE__ */ new Set()).add(dataset.digest));
2220
+ return new Map([...byId].map(([id, set]) => [id, [...set].sort().join(",")]));
2221
+ };
2115
2222
  const before = digests(unit.base);
2116
2223
  const after = digests(unit.head);
2117
2224
  for (const id of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) if (before.get(id) !== after.get(id)) changedDatasets.add(id);
@@ -2120,10 +2227,10 @@ function compareRuns(baseRuns, headRuns) {
2120
2227
  for (const unit of units) bySuite.set(unit.suite, [...bySuite.get(unit.suite) ?? [], unit]);
2121
2228
  const suites = [...bySuite].map(([suite, members]) => {
2122
2229
  const side = (pick) => {
2123
- const runs = members.flatMap((unit) => unit[pick] ? [unit[pick]] : []);
2230
+ const sides = members.flatMap((unit) => unit[pick] ? [unit[pick]] : []);
2124
2231
  return {
2125
- passed: runs.filter((run) => run.status === "passed").length,
2126
- total: runs.length
2232
+ passed: sides.reduce((sum, entry) => sum + entry.passed, 0),
2233
+ total: sides.reduce((sum, entry) => sum + entry.total, 0)
2127
2234
  };
2128
2235
  };
2129
2236
  return {
@@ -2232,8 +2339,8 @@ function headRuns(runs, head, options) {
2232
2339
  return runs.filter((run) => run.source !== void 0 && run.source.commit === head.commit && repoKey(run.source) === repoKey(head) && (!run.source.dirty || options.allowDirty) && !run.archived && run.status !== "running");
2233
2340
  }
2234
2341
  /** Base runs from a results directory, e.g. an artifact from the base pipeline. Dirty runs are never a base. */
2235
- async function baseRunsFromDirectory(directory, registry) {
2236
- return (await createDashboardReportReader(directory, registry).listRuns()).filter((run) => !run.source?.dirty && !run.archived);
2342
+ async function baseRunsFromDirectory(directory) {
2343
+ return (await createDashboardReportReader(directory).listRuns()).filter((run) => !run.source?.dirty && !run.archived);
2237
2344
  }
2238
2345
  /** A full SHA, a unique prefix of at least 7 characters, or a ref's latest shared commit. */
2239
2346
  function resolveSharedRef(commits, ref) {
@@ -2260,7 +2367,7 @@ async function getJson(url, serverUrl, headers) {
2260
2367
  /** Base runs shared for `ref` of this repository; `undefined` when the base was never shared. */
2261
2368
  async function baseRunsFromServer(options) {
2262
2369
  const repo = repoKey(options.head);
2263
- const api = (path) => new URL(`/api/v1/repos/${repo}/-/${path}`, options.serverUrl);
2370
+ const api = (path) => shareApiUrl(options.serverUrl, `api/v1/repos/${repo}/-/${path}`);
2264
2371
  const listed = await getJson(api("commits"), options.serverUrl, options.headers);
2265
2372
  const commits = Array.isArray(listed?.commits) ? listed.commits : [];
2266
2373
  const commit = resolveSharedRef(commits, options.ref);
@@ -5257,11 +5364,12 @@ function createProgram(actions) {
5257
5364
  showGlobalOptions: false
5258
5365
  }).addHelpText("after", `\nRun from your eval project's directory. Docs: https://evalkit.leostera.dev/docs/\n`);
5259
5366
  program.command("new").description("create an eval project (never overwrites files)").argument("<directory>", "where to create it; use . for the current directory").addHelpText("after", examples(["bunx @leostera/evalkit new ./agent-evals", "evalkit new ."])).action((directory) => actions.create(directory));
5260
- program.command("run").description("run scenarios and save the results in _evalkit-results/").argument("[eval-ids...]", "scenarios to run (default: all)").addOption(configOption()).option("-e, --eval <id>", "run this scenario; repeatable, or comma-separated", collect).option("-s, --suite <id>", "run every scenario in this suite").option("-m, --matrix <id>", "run this matrix").option("--model <value>", "--select model=<value> for each comma-separated value", collect).option("--mode <value>", "--select mode=<value> for each comma-separated value", collect).option("--select <axis=value>", "select a value of any matrix axis; repeatable", collect).option("--param <name=json>", "set a parameter for every scenario; repeatable", collect).option("-n, --trials <n>", "trials per scenario, overriding config and policy").option("-j, --concurrency <n>", "matrix cells to run at once (default: 4)").option("--max-tokens <n>", "pass maxTokens to the agent in context.parameters").option("--turn-budget <n>", "pass turnBudget to the agent in context.parameters").option("--chat-timeout-ms <n>", "pass chatTimeoutMs to the agent in context.parameters").option("--local", "require the agent's local runtime").option("--all", "allow more matrix cells than execution.maxCells").option("--dry-run", "print the plan as JSON; run nothing").option("--json", "print one JSON result per matrix cell").option("--junit <file>", "write a JUnit XML report").option("--skip-broken", "run healthy scenarios when some files fail to load, then exit 1").option("-v, --verbose", "print every scenario, not only failures").addOption(logLevelOption()).addHelpText("after", examples([
5367
+ program.command("run").description("run scenarios and save the results in _evalkit-results/").argument("[eval-ids...]", "scenarios to run (default: all)").addOption(configOption()).option("-e, --eval <id>", "run this scenario; repeatable, or comma-separated", collect).option("-s, --suite <id>", "run the scenarios in this suite; with -m, only those in both").option("-m, --matrix <id>", "run this matrix").option("--model <value>", "--select model=<value> for each comma-separated value", collect).option("--mode <value>", "--select mode=<value> for each comma-separated value", collect).option("-a, --select <axis=value>", "keep only these values of a matrix axis (a for axis); repeatable, comma-separated", collect).option("--param <name=json>", "set a parameter for every scenario; repeatable", collect).option("-n, --repeat <n>", "runs of each scenario, as one batch; overrides config and scenario").option("-j, --concurrency <n>", "matrix cells to run at once (default: 4)").option("--max-tokens <n>", "pass maxTokens to the agent in context.parameters").option("--turn-budget <n>", "pass turnBudget to the agent in context.parameters").option("--chat-timeout-ms <n>", "pass chatTimeoutMs to the agent in context.parameters").option("--local", "require the agent's local runtime").option("--all", "allow more matrix cells than execution.maxCells").option("--dry-run", "print the plan as JSON; run nothing").option("--json", "print one JSON result per matrix cell").option("--junit <file>", "write a JUnit XML report").option("--skip-broken", "run healthy scenarios when some files fail to load, then exit 1").option("-v, --verbose", "print every scenario, not only failures").addOption(logLevelOption()).addHelpText("after", examples([
5261
5368
  "evalkit run",
5262
5369
  "evalkit run greeting -n 5",
5263
5370
  "evalkit run -s billing --junit _evalkit-results/junit.xml",
5264
- "evalkit run -m models --model model-a,model-b --dry-run"
5371
+ "evalkit run -m models --model model-a,model-b --dry-run",
5372
+ "evalkit run -m models -s testing -a model=luna -a toolchain=riot"
5265
5373
  ])).action((evalIds, options) => {
5266
5374
  setRunnerLogLevel(options.logLevel);
5267
5375
  return actions.run(runInput(evalIds, options));
@@ -5275,11 +5383,12 @@ function createProgram(actions) {
5275
5383
  return actions.build(options);
5276
5384
  });
5277
5385
  program.command("tidy").description("archive runs of scenarios no longer in the project, and remove sandboxes keepSandboxes would not keep").addOption(configOption()).action((options) => actions.tidy(options));
5386
+ program.command("upgrade").description("convert results saved by an older EvalKit to the current format, in place").addOption(configOption()).option("--dry-run", "show what would change without changing anything").action((options) => actions.upgrade(options));
5278
5387
  program.command("share").description("upload this commit's results to a share server").addOption(configOption()).option("--server <url>", "share server to use instead of share.url in the config").option("--all", "also upload runs from other commits").option("--allow-dirty", "include runs made with uncommitted changes").option("--no-workspace", "leave out candidate file contents").addHelpText("after", examples(["evalkit share", "evalkit share --server https://evals.example.com"])).action((options) => actions.share({
5279
5388
  ...options,
5280
5389
  all: Boolean(options.all),
5281
5390
  allowDirty: Boolean(options.allowDirty)
5282
- })).command("serve").description("run a share server locally, with no authentication").option("-d, --dir <dir>", "where to store uploads", "_evalkit-share").addOption(portOption(4318)).action((options) => actions.shareServe(options));
5391
+ })).command("serve").description("run a share server locally, with no authentication").option("-d, --dir <dir>", "where to store uploads", "_evalkit-share").addOption(portOption(4318)).option("--base-path <path>", "serve everything below this path, e.g. /evals").option("--repo <key>", "show and accept only this repository, e.g. github.com/acme/agents").action((options) => actions.shareServe(options));
5283
5392
  program.command("compare").description("compare this commit's results with a base commit's").addOption(configOption()).option("-b, --base <ref>", "base branch, tag or commit, from the share server").option("--base-dir <dir>", "base results from a directory instead").option("--server <url>", "share server to use instead of share.url in the config").option("--fail-on <condition>", "exit 1 on regression, or score-drop=<amount>; repeatable", collect).option("--markdown <file>", "write the comparison as Markdown").option("--json", "print the comparison as JSON").option("--require-base", "exit 1 when the base has no results").option("--allow-dirty", "include runs made with uncommitted changes").addHelpText("after", examples(["evalkit compare -b main", "evalkit compare -b main --fail-on regression --markdown compare.md"])).action((options) => actions.compare({
5284
5393
  ...options,
5285
5394
  failOn: options.failOn ?? [],
@@ -5303,7 +5412,7 @@ function runInput(evalIds, flags) {
5303
5412
  "chat-timeout-ms": flags.chatTimeoutMs,
5304
5413
  "turn-budget": flags.turnBudget,
5305
5414
  concurrency: flags.concurrency,
5306
- trials: flags.trials,
5415
+ repeat: flags.repeat,
5307
5416
  json: flags.json,
5308
5417
  "dry-run": flags.dryRun,
5309
5418
  all: flags.all,
@@ -5317,7 +5426,7 @@ function runInput(evalIds, flags) {
5317
5426
  //#region ../cli/src/index.ts
5318
5427
  let projectRoot = process.cwd();
5319
5428
  let reportRoot = resolve(projectRoot, "_evalkit-results");
5320
- let sandboxRoot = resolve(projectRoot, "_evalkit-sandbox");
5429
+ let sandboxRoot = defaultSandboxDir(projectRoot);
5321
5430
  let project;
5322
5431
  async function loadRegistry() {
5323
5432
  project ??= await loadProject(projectRoot);
@@ -5343,6 +5452,8 @@ async function runEvals(options) {
5343
5452
  workspaceRoot: sandboxRoot,
5344
5453
  keepWorkspaces: project?.config?.keepSandboxes,
5345
5454
  artifactExclude: project?.config?.artifacts?.exclude,
5455
+ attachmentLimits: project?.config?.artifacts?.attachments,
5456
+ judge: project?.config?.judge,
5346
5457
  suiteId: options.suiteId,
5347
5458
  runtime: runtimeFor(evaluation),
5348
5459
  concurrency: options.concurrency,
@@ -5362,14 +5473,14 @@ async function runProvenance(registry) {
5362
5473
  catalog: runCatalog(registry, projectRoot)
5363
5474
  };
5364
5475
  }
5365
- function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
5476
+ function registerMatrixRoutes(app, registry, dashboardMatrix, repeat) {
5366
5477
  let matrixRunning = false;
5367
5478
  app.get("/v1/matrix", (context) => context.json({ matrix: dashboardMatrix ? {
5368
5479
  id: dashboardMatrix.id,
5369
5480
  parameters: dashboardMatrix.parameters,
5370
5481
  ...dashboardMatrix.cases?.length || dashboardMatrix.exclude?.length ? { dimensions: [...Object.keys(dashboardMatrix.cases?.[0] ?? {}), ...Object.keys(dashboardMatrix.parameters)] } : {},
5371
5482
  ...dashboardMatrix.cases?.length || dashboardMatrix.exclude?.length ? { constrained: true } : {},
5372
- ...trials ? { trials } : {}
5483
+ ...repeat ? { repeat } : {}
5373
5484
  } : null }));
5374
5485
  app.get("/v1/matrix/cells", (context) => {
5375
5486
  if (!dashboardMatrix) return context.text("Project has no matrix", 404);
@@ -5409,12 +5520,14 @@ function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
5409
5520
  runProvenance(registry).then((provenance) => Effect.runPromise(runnerLogs(runMatrix(dashboardMatrix, {
5410
5521
  selection,
5411
5522
  concurrency: 1,
5412
- trials,
5523
+ ...repeat ? { repeat } : {},
5413
5524
  ...suiteId ? { suiteId } : {},
5414
5525
  report: localReportStore(reportRoot),
5415
5526
  workspaceRoot: sandboxRoot,
5416
5527
  keepWorkspaces: project?.config?.keepSandboxes,
5417
5528
  artifactExclude: project?.config?.artifacts?.exclude,
5529
+ attachmentLimits: project?.config?.artifacts?.attachments,
5530
+ judge: project?.config?.judge,
5418
5531
  ...provenance,
5419
5532
  onResult: (cell, result) => runLog.cell(cell, result, performance.now() - started)
5420
5533
  })))).catch((error) => runLog.failed(name, error)).finally(() => {
@@ -5465,7 +5578,7 @@ function registerReportRoutes(app, registry, reports) {
5465
5578
  if (!dataset) return context.text("Unknown dataset", 404);
5466
5579
  const file = context.req.param("path");
5467
5580
  try {
5468
- return new Response(await staticDatasetFile(dataset, projectRoot, file), { headers: { "content-type": fileContentType(file) } });
5581
+ return new Response(await staticDatasetFile(dataset, projectRoot, file), { headers: UNTRUSTED_FILE });
5469
5582
  } catch (error) {
5470
5583
  return context.text(error instanceof Error ? error.message : "Unable to read dataset file", 404);
5471
5584
  }
@@ -5477,44 +5590,44 @@ function registerReportRoutes(app, registry, reports) {
5477
5590
  app.get("/v1/evals", (context) => context.json({ evals: registry.metadata() }));
5478
5591
  app.get("/v1/suites", (context) => context.json({ suites: registry.suiteMetadata() }));
5479
5592
  app.get("/v1/runs", async (context) => context.json({ runs: await reports.listRuns() }));
5480
- app.get("/v1/runs/:runId/trials", async (context) => {
5593
+ app.get("/v1/runs/:runId", async (context) => {
5481
5594
  try {
5482
- return context.json({ trials: await reports.listTrials(context.req.param("runId")) });
5595
+ return context.json(await reports.runDetail(context.req.param("runId")));
5483
5596
  } catch (error) {
5484
- return context.text(error instanceof Error ? error.message : "Bad request", 400);
5597
+ return context.text(error instanceof Error ? error.message : "Not found", 404);
5485
5598
  }
5486
5599
  });
5487
- app.get("/v1/runs/:runId/trials/:trialId", async (context) => {
5600
+ app.get("/v1/runs/:runId/workspace", async (context) => {
5488
5601
  try {
5489
- return context.json(await reports.trialDetail(context.req.param("runId"), context.req.param("trialId")));
5602
+ return context.json(await reports.listCandidateWorkspace(context.req.param("runId")));
5490
5603
  } catch (error) {
5491
- return context.text(error instanceof Error ? error.message : "Not found", 404);
5604
+ return context.text(error instanceof Error ? error.message : "Unable to list workspace", 400);
5492
5605
  }
5493
5606
  });
5494
- app.get("/v1/runs/:runId/trials/:trialId/workspace", async (context) => {
5607
+ app.get("/v1/runs/:runId/workspace/:path{.+}", async (context) => {
5495
5608
  try {
5496
- return context.json(await reports.listCandidateWorkspace(context.req.param("runId"), context.req.param("trialId")));
5609
+ return await reports.candidateFile(context.req.param("runId"), context.req.param("path"));
5497
5610
  } catch (error) {
5498
- return context.text(error instanceof Error ? error.message : "Unable to list workspace", 400);
5611
+ return context.text(error instanceof Error ? error.message : "Unable to read workspace file", 400);
5499
5612
  }
5500
5613
  });
5501
- app.get("/v1/runs/:runId/trials/:trialId/workspace/:path{.+}", async (context) => {
5614
+ app.get("/v1/runs/:runId/attachments/:name{.+}", async (context) => {
5502
5615
  try {
5503
- return await reports.candidateFile(context.req.param("runId"), context.req.param("trialId"), context.req.param("path"));
5616
+ return await reports.attachmentFile(context.req.param("runId"), context.req.param("name"));
5504
5617
  } catch (error) {
5505
- return context.text(error instanceof Error ? error.message : "Unable to read workspace file", 400);
5618
+ return context.text(error instanceof Error ? error.message : "Unable to read attachment", 400);
5506
5619
  }
5507
5620
  });
5508
- app.get("/v1/runs/:runId/trials/:trialId/artifacts", async (context) => {
5621
+ app.get("/v1/runs/:runId/artifacts", async (context) => {
5509
5622
  try {
5510
- return context.json({ artifacts: await reports.listArtifacts(context.req.param("runId"), context.req.param("trialId")) });
5623
+ return context.json({ artifacts: await reports.listArtifacts(context.req.param("runId")) });
5511
5624
  } catch (error) {
5512
5625
  return context.text(error instanceof Error ? error.message : "Not found", 404);
5513
5626
  }
5514
5627
  });
5515
- app.get("/v1/runs/:runId/trials/:trialId/events", async (context) => {
5628
+ app.get("/v1/runs/:runId/events", async (context) => {
5516
5629
  try {
5517
- return context.json({ events: await reports.trajectory(context.req.param("runId"), context.req.param("trialId")) });
5630
+ return context.json({ events: await reports.trajectory(context.req.param("runId")) });
5518
5631
  } catch (error) {
5519
5632
  return context.text(error instanceof Error ? error.message : "Not found", 404);
5520
5633
  }
@@ -5547,7 +5660,7 @@ async function useProject(configPath) {
5547
5660
  project = await loadProject(projectRoot, configPath);
5548
5661
  projectRoot = project.root;
5549
5662
  reportRoot = resolve(projectRoot, project.config.reportDir ?? "_evalkit-results");
5550
- sandboxRoot = resolve(projectRoot, project.config.sandboxDir ?? "_evalkit-sandbox");
5663
+ sandboxRoot = sandboxLocation(project).path;
5551
5664
  }
5552
5665
  /**
5553
5666
  * `--port` is honoured exactly; without it the server takes the first free
@@ -5568,11 +5681,11 @@ async function serveDashboard({ port, open }) {
5568
5681
  const dashboardRoot = dashboardAssets();
5569
5682
  const app = new Hono();
5570
5683
  const dashboardMatrix = project?.config.matrix ? registry.matrices.at(-1) : void 0;
5571
- registerMatrixRoutes(app, registry, dashboardMatrix, project?.config.execution?.trials);
5572
- registerReportRoutes(app, registry, createDashboardReportReader(reportRoot, registry));
5684
+ registerMatrixRoutes(app, registry, dashboardMatrix, project?.config.execution?.repeat);
5685
+ registerReportRoutes(app, registry, createDashboardReportReader(reportRoot));
5573
5686
  registerDashboardAssets(app, dashboardRoot);
5574
5687
  const url = `http://localhost:${(await listen(app.fetch, port, 4317)).port}`;
5575
- const reader = createDashboardReportReader(reportRoot, registry);
5688
+ const reader = createDashboardReportReader(reportRoot);
5576
5689
  const runs = (await reader.listRuns()).length;
5577
5690
  const legacy = reader.legacyRunIds().length;
5578
5691
  const matrixCells = dashboardMatrix?.count({ parameters: {} });
@@ -5581,7 +5694,7 @@ async function serveDashboard({ port, open }) {
5581
5694
  printLoadErrors(project?.loadErrors ?? []);
5582
5695
  row("Local:", cyan(url));
5583
5696
  row("Project:", `${plural(registry.evals.length, "eval")} · ${plural(registry.suites.length, "suite")}` + (dashboardMatrix ? ` · matrix ${authoringId(dashboardMatrix)} (${plural(matrixCells, "cell")})` : ""));
5584
- row("Reports:", `${relative(process.cwd(), reportRoot) || "."} ${dim(`(${plural(runs, "run")}${legacy ? `, ${legacy} from an older EvalKit hidden` : ""})`)}`);
5697
+ row("Reports:", `${relative(process.cwd(), reportRoot) || "."} ${dim(`(${plural(runs, "run")}${legacy ? `, ${legacy} from an older EvalKit hidden; run \`evalkit upgrade\`` : ""})`)}`);
5585
5698
  console.log(`\n ${dim("Runs started from the dashboard are logged below. Press Ctrl+C to stop.")}\n`);
5586
5699
  if (open) openInBrowser(url);
5587
5700
  }
@@ -5639,7 +5752,7 @@ async function loadProjectStep(configPath) {
5639
5752
  */
5640
5753
  async function shouldRunEvals(run) {
5641
5754
  if (run !== void 0) return run;
5642
- const runs = (await createDashboardReportReader(reportRoot, project.registry).listRuns()).filter((entry) => !entry.archived).length;
5755
+ const runs = (await createDashboardReportReader(reportRoot).listRuns()).filter((entry) => !entry.archived).length;
5643
5756
  if (!runs) {
5644
5757
  console.log(` ${cyan("◇")} No results yet, so running the evals to have data to build.`);
5645
5758
  return true;
@@ -5672,7 +5785,7 @@ async function tidyAction(options) {
5672
5785
  }), (value) => {
5673
5786
  const keep = project.config.keepSandboxes ?? "failed";
5674
5787
  if (keep === "all") return "all kept (keepSandboxes: 'all')";
5675
- return `${plural(value.removed, "workspace")} of ${keep === "none" ? "finished" : "passed"} trials removed`;
5788
+ return `${plural(value.removed, "workspace")} of ${keep === "none" ? "finished" : "passed"} runs removed`;
5676
5789
  });
5677
5790
  const byEval = (entries) => [...entries.reduce((groups, entry) => groups.set(entry.evalId, (groups.get(entry.evalId) ?? 0) + 1), /* @__PURE__ */ new Map()).entries()].map(([id, total]) => ` ${bold(id)} ${dim(`(${plural(total, "run")})`)}`).join("\n");
5678
5791
  console.log("");
@@ -5683,16 +5796,34 @@ async function tidyAction(options) {
5683
5796
  if (totalArchived) console.log(`\n ${dim(`Archived runs (${totalArchived}) are hidden in \`evalkit dev\` (filter "Show archived")`)}\n ${dim("and left out of `evalkit build`. No reports were deleted.")}`);
5684
5797
  console.log("");
5685
5798
  }
5799
+ async function upgradeAction(options) {
5800
+ banner("upgrade", VERSION);
5801
+ const { root, config } = await loadConfig(projectRoot, options.config);
5802
+ reportRoot = resolve(root, config.reportDir ?? "_evalkit-results");
5803
+ const report = await step(options.dryRun ? "Checking results" : "Upgrading results", () => upgradeReports(reportRoot, { dryRun: options.dryRun }), (value) => `${plural(value.upgraded, "run")} from an older EvalKit ${options.dryRun ? "found" : "upgraded"}`);
5804
+ console.log("");
5805
+ if (!report.upgraded) console.log(` ${cyan("✓")} Every run is already in the current format.`);
5806
+ else {
5807
+ const into = `${plural(report.upgraded, "run")} from older EvalKit versions into ${plural(report.runs, "run")}`;
5808
+ console.log(options.dryRun ? ` ${cyan("◆")} Would upgrade ${into}. Nothing was changed.` : ` ${cyan("✓")} Upgraded ${into}.`);
5809
+ if (report.batches) console.log(` ${dim(`${plural(report.batches, "run")} had more than one trial; each trial is now a run in a batch.`)}`);
5810
+ }
5811
+ if (report.running.length) console.log(` ${yellow("◆")} Left ${plural(report.running.length, "run")} still running alone; upgrade again when they finish.`);
5812
+ console.log("");
5813
+ }
5686
5814
  async function shareServeAction(options) {
5687
5815
  const root = resolve(process.cwd(), options.dir);
5688
- const server = await listen((await createDirectoryShareApp({
5816
+ const url = `http://localhost:${(await listen((await createDirectoryShareApp({
5689
5817
  dir: root,
5690
- dashboardRoot: dashboardAssets()
5691
- })).fetch, options.port, 4318);
5818
+ dashboardRoot: dashboardAssets(),
5819
+ ...options.basePath ? { basePath: options.basePath } : {},
5820
+ ...options.repo ? { repo: options.repo } : {}
5821
+ })).fetch, options.port, 4318)).port}${normalizeBasePath(options.basePath)}`;
5692
5822
  banner("share serve", VERSION);
5693
- console.log(` ${cyan("➜")} ${bold("Local:".padEnd(9))}${cyan(`http://localhost:${server.port}`)}`);
5823
+ console.log(` ${cyan("➜")} ${bold("Local:".padEnd(9))}${cyan(url)}`);
5824
+ if (options.repo) console.log(` ${cyan("➜")} ${bold("Repo:".padEnd(9))}${options.repo}`);
5694
5825
  console.log(` ${cyan("➜")} ${bold("Storage:".padEnd(9))}${relative(process.cwd(), root) || "."}`);
5695
- console.log(`\n ${dim(`Upload with share.url = "http://localhost:${server.port}". No authentication: keep it private.`)}\n`);
5826
+ console.log(`\n ${dim(`Upload with share.url = "${url}". No authentication: keep it private.`)}\n`);
5696
5827
  }
5697
5828
  async function shareAction(options) {
5698
5829
  const { all, allowDirty } = options;
@@ -5708,7 +5839,7 @@ async function shareAction(options) {
5708
5839
  ] }), (source) => source ? `${repoKey(source)} @ ${shortSource(source)}${source.ref ? ` (${source.ref})` : ""}` : "not a git repository");
5709
5840
  if (!head) throw new Error("evalkit share needs to know the commit: run it inside a git repository or set EVALKIT_SOURCE_REPO and EVALKIT_SOURCE_COMMIT. `evalkit build` works without one.");
5710
5841
  const registry = project.registry;
5711
- const selection = await step("Runs", async () => selectRuns(await createDashboardReportReader(reportRoot, registry).listRuns(), head, {
5842
+ const selection = await step("Runs", async () => selectRuns(await createDashboardReportReader(reportRoot).listRuns(), head, {
5712
5843
  all,
5713
5844
  allowDirty
5714
5845
  }), (value) => selectionSummary(value, { all }));
@@ -5730,7 +5861,7 @@ async function shareAction(options) {
5730
5861
  step
5731
5862
  });
5732
5863
  for (const failure of result.failed) console.log(` ${yellow("✗")} ${failure.runId.slice(0, 8)} ${dim(failure.error)}`);
5733
- const target = shareTarget(serverUrl, head, selection.selected);
5864
+ const target = shareTarget(serverUrl, result.url, selection.selected);
5734
5865
  console.log(`\n ${bold(result.failed.length ? "Shared with errors" : "Shared")} in ${duration(performance.now() - started)}\n`);
5735
5866
  console.log(` ${cyan("➜")} ${cyan(target)}\n`);
5736
5867
  if (result.failed.length) process.exitCode = 1;
@@ -5741,17 +5872,16 @@ async function compareAction(options) {
5741
5872
  const say = options.json ? () => {} : (line = "") => console.log(line);
5742
5873
  if (!options.json) banner("compare", VERSION);
5743
5874
  await useProject(options.config);
5744
- const registry = project.registry;
5745
5875
  const head = await detectSource(projectRoot, { outputDirs: [
5746
5876
  reportRoot,
5747
5877
  sandboxRoot,
5748
5878
  resolve(projectRoot, "_evalkit-dashboard")
5749
5879
  ] });
5750
5880
  if (!head) throw new Error("evalkit compare needs to know the commit: run it inside a git repository or set EVALKIT_SOURCE_REPO and EVALKIT_SOURCE_COMMIT.");
5751
- const headSide = headRuns(await createDashboardReportReader(reportRoot, registry).listRuns(), head, { allowDirty: options.allowDirty });
5881
+ const headSide = headRuns(await createDashboardReportReader(reportRoot).listRuns(), head, { allowDirty: options.allowDirty });
5752
5882
  let base;
5753
5883
  if (options.baseDir) {
5754
- const runs = await baseRunsFromDirectory(resolve(process.cwd(), options.baseDir), registry);
5884
+ const runs = await baseRunsFromDirectory(resolve(process.cwd(), options.baseDir));
5755
5885
  base = runs.length ? {
5756
5886
  label: options.baseDir,
5757
5887
  runs
@@ -5825,6 +5955,7 @@ try {
5825
5955
  dev: devAction,
5826
5956
  build: buildAction,
5827
5957
  tidy: tidyAction,
5958
+ upgrade: upgradeAction,
5828
5959
  share: shareAction,
5829
5960
  shareServe: shareServeAction,
5830
5961
  compare: compareAction