@leostera/evalkit 0.8.0 → 0.9.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 +46 -2
  2. package/README.md +2 -2
  3. package/dist/cli.mjs +386 -365
  4. package/dist/dashboard/assets/index-By1SJ1on.js +66 -0
  5. package/dist/dashboard/assets/index-geXMXCx9.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-BbVblaHz.mjs} +333 -51
  9. package/dist/index.d.mts +220 -178
  10. package/dist/index.d.mts.map +1 -1
  11. package/dist/index.mjs +2 -2
  12. package/dist/runner.d.mts +226 -168
  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 +3 -3
  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 +5 -3
  26. package/dist/skills/evalkit-matrices/SKILL.md +2 -2
  27. package/dist/skills/evalkit-results/SKILL.md +6 -6
  28. package/dist/{src-CZnDXP1K.mjs → src-Bn6fnZnF.mjs} +8 -3
  29. package/dist/{src-LeGSyDD2.mjs → src-BrfIXfO2.mjs} +72 -54
  30. package/dist/{src-mm8L_IxM.mjs → src-CcdLZqFk.mjs} +422 -230
  31. package/dist/upgrade-60SIRius.mjs +110 -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
@@ -1,3 +1,4 @@
1
+ import { a as upgradeManifest, i as upgradeEvent, o as upgradeStatus, r as upgradeCatalog } from "./upgrade-60SIRius.mjs";
1
2
  import { Hono } from "hono";
2
3
  //#region ../share/src/bundle.ts
3
4
  /**
@@ -80,10 +81,127 @@ function escapeAttr(value) {
80
81
  return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;");
81
82
  }
82
83
  //#endregion
84
+ //#region ../share/src/legacy.ts
85
+ const decoder = new TextDecoder();
86
+ const encoder = new TextEncoder();
87
+ const parse = (data) => data ? JSON.parse(decoder.decode(data)) : void 0;
88
+ const encode = (value) => encoder.encode(`${JSON.stringify(value)}\n`);
89
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
90
+ /** True for a version 1 run.json: no `snapshotVersion`. */
91
+ function isLegacyRun(runJson) {
92
+ return isRecord(runJson) && runJson.snapshotVersion === void 0;
93
+ }
94
+ function upgradeSnapshotRun(files) {
95
+ const run = parse(files.get("run.json"));
96
+ if (!isRecord(run)) throw new Error("run.json must be an object");
97
+ const listed = parse(files.get("trials.json"));
98
+ const trials = (isRecord(listed) && Array.isArray(listed.trials) ? listed.trials : []).filter(isRecord);
99
+ const size = trials.length;
100
+ return trials.map((trial) => {
101
+ const id = size === 1 ? String(run.id) : String(trial.id);
102
+ const prefix = `trials/${String(trial.id)}/`;
103
+ const detail = parse(files.get(`${prefix}detail.json`));
104
+ const trialManifest = isRecord(detail) && isRecord(detail.manifest) ? detail.manifest : {};
105
+ const trialSummary = isRecord(detail) && isRecord(detail.summary) ? detail.summary : {};
106
+ const index = typeof trial.index === "number" ? trial.index : trials.indexOf(trial);
107
+ const batch = size > 1 ? {
108
+ id: String(run.id),
109
+ index,
110
+ size
111
+ } : void 0;
112
+ const scores = Array.isArray(trial.scores) ? trial.scores.filter(isRecord) : [];
113
+ const metrics = Array.isArray(trial.metrics) ? trial.metrics.filter(isRecord) : [];
114
+ const datasets = Array.isArray(trialManifest.datasets) ? trialManifest.datasets.filter(isRecord) : [];
115
+ const status = String(trial.status);
116
+ const pick = (keys) => Object.fromEntries(keys.flatMap((key) => run[key] === void 0 ? [] : [[key, run[key]]]));
117
+ const out = /* @__PURE__ */ new Map();
118
+ out.set("run.json", encode({
119
+ snapshotVersion: 2,
120
+ id,
121
+ ...pick([
122
+ "evalId",
123
+ "suiteId",
124
+ "agent",
125
+ "matrixId",
126
+ "cellKey",
127
+ "parameters",
128
+ "archived",
129
+ "source"
130
+ ]),
131
+ status,
132
+ startedAt: trial.startedAt ?? run.startedAt,
133
+ ...trial.completedAt ? { completedAt: trial.completedAt } : {},
134
+ ...trial.durationMs === void 0 ? {} : { durationMs: trial.durationMs },
135
+ ...batch ? { batch } : {},
136
+ ...trial.score === void 0 ? {} : { score: trial.score },
137
+ ...scores.length ? { scorers: scores.map((score) => ({
138
+ name: score.name,
139
+ kind: score.kind,
140
+ ...score.skipped ? { skipped: true } : {},
141
+ ...score.value === void 0 || score.skipped ? {} : { value: score.value }
142
+ })) } : {},
143
+ ...metrics.length ? { metrics: metrics.map((metric) => ({
144
+ name: metric.name,
145
+ ...metric.unit ? { unit: metric.unit } : {},
146
+ ...metric.better ? { better: metric.better } : {},
147
+ ...metric.builtIn ? { builtIn: true } : {},
148
+ ...metric.value === void 0 ? {} : { value: metric.value }
149
+ })) } : {},
150
+ ...datasets.length ? { datasets: datasets.map((dataset) => ({
151
+ id: dataset.id,
152
+ digest: dataset.digest
153
+ })) } : {}
154
+ }));
155
+ const passed = trialSummary.scoring?.passed;
156
+ out.set("detail.json", encode({
157
+ scores,
158
+ ...metrics.length ? { metrics } : {},
159
+ checkpoints: Array.isArray(trial.checkpoints) ? trial.checkpoints : [],
160
+ ...trial.skippedScorers ? { skippedScorers: trial.skippedScorers } : {},
161
+ ...trial.attachments ? { attachments: trial.attachments } : {},
162
+ manifest: upgradeManifest({
163
+ run: { ...pick([
164
+ "evalId",
165
+ "suiteId",
166
+ "parameters",
167
+ "source"
168
+ ]) },
169
+ trial: trialManifest,
170
+ runId: id,
171
+ ...batch ? { batch } : {},
172
+ status: upgradeStatus(trialSummary.status, passed)
173
+ }),
174
+ summary: {
175
+ ...trialSummary,
176
+ status: upgradeStatus(trialSummary.status, passed)
177
+ }
178
+ }));
179
+ for (const [path, data] of files) {
180
+ if (!path.startsWith(prefix)) continue;
181
+ const rest = path.slice(prefix.length);
182
+ if (rest === "detail.json") continue;
183
+ if (rest === "events.json") {
184
+ const events = parse(data);
185
+ out.set(rest, encode({ events: isRecord(events) && Array.isArray(events.events) ? events.events.map(upgradeEvent) : [] }));
186
+ } else out.set(rest, data);
187
+ }
188
+ return {
189
+ id,
190
+ files: out
191
+ };
192
+ });
193
+ }
194
+ /** A stored or uploaded version 1 catalog, as version 2 stores it. */
195
+ function upgradeCatalogFile(data) {
196
+ return encode(upgradeCatalog(parse(data)));
197
+ }
198
+ //#endregion
83
199
  //#region ../share/src/paths.ts
84
200
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
85
- const TRIAL_JSON = /^trials\/([^/]+)\/(detail|events|artifacts|workspace)\.json$/;
86
- const TRIAL_FILE = /^trials\/([^/]+)\/workspace\/(.+)$/;
201
+ const RUN_JSON = /^(detail|events|artifacts|workspace)\.json$/;
202
+ const RUN_FILE$1 = /^(?:workspace|attachments)\/(.+)$/;
203
+ const LEGACY_TRIAL_JSON = /^trials\/([^/]+)\/(detail|events|artifacts|workspace)\.json$/;
204
+ const LEGACY_TRIAL_FILE = /^trials\/([^/]+)\/(?:workspace|attachments)\/(.+)$/;
87
205
  const isUuid = (value) => UUID.test(value);
88
206
  const isSha = (value) => /^[0-9a-f]{40}$/.test(value);
89
207
  /** One path segment that cannot escape or alias another location. */
@@ -91,14 +209,21 @@ function safeSegment(segment) {
91
209
  return segment.length > 0 && segment.length <= 255 && segment !== "." && segment !== ".." && !/[\\/\0]/.test(segment);
92
210
  }
93
211
  /**
94
- * The only files a run bundle may contain (the snapshot format from
95
- * `evalkit build`, for one run). Never evaluator workspaces.
212
+ * The only files a run bundle may contain (snapshot format version 2, from
213
+ * `evalkit build`, for one run): its JSON, candidate workspace files and
214
+ * attachments. Never evaluator workspaces.
96
215
  */
97
216
  function allowedRunPath(path) {
217
+ if (path === "run.json" || path === "catalog.json" || RUN_JSON.test(path)) return true;
218
+ const file = RUN_FILE$1.exec(path);
219
+ return Boolean(file && file[1].split("/").every(safeSegment));
220
+ }
221
+ /** The same for snapshot format version 1 (EvalKit before 0.9): a run of trials. */
222
+ function allowedLegacyRunPath(path) {
98
223
  if (path === "run.json" || path === "catalog.json" || path === "trials.json") return true;
99
- const json = TRIAL_JSON.exec(path);
224
+ const json = LEGACY_TRIAL_JSON.exec(path);
100
225
  if (json) return isUuid(json[1]);
101
- const file = TRIAL_FILE.exec(path);
226
+ const file = LEGACY_TRIAL_FILE.exec(path);
102
227
  return Boolean(file && isUuid(file[1]) && file[2].split("/").every(safeSegment));
103
228
  }
104
229
  /**
@@ -116,26 +241,48 @@ function validRepoKey(key) {
116
241
  //#region ../share/src/upload.ts
117
242
  /** Rejected input: the client sent something the server will not store. */
118
243
  var Rejected = class extends Error {};
244
+ /**
245
+ * A run bundle's runs. A bundle from EvalKit 0.9 or later is one run; one
246
+ * from an older CLI (snapshot format version 1, a run of trials) is
247
+ * converted into one run per trial, as `POST /api/v1/upgrade` converts
248
+ * stored runs (RFD0008).
249
+ */
119
250
  function parseUpload(bundle) {
120
251
  const byPath = /* @__PURE__ */ new Map();
121
252
  for (const file of bundle) {
122
- if (!allowedRunPath(file.path)) throw new Rejected(`File not allowed in a run bundle: ${file.path}`);
123
253
  if (byPath.has(file.path)) throw new Rejected(`Duplicate file in bundle: ${file.path}`);
124
254
  byPath.set(file.path, file.data);
125
255
  }
126
256
  const runJson = byPath.get("run.json");
127
257
  if (!runJson) throw new Rejected("Bundle has no run.json");
128
- const catalog = byPath.get("catalog.json");
129
- if (catalog) parseCatalog(catalog);
258
+ const legacy = isLegacyRun(parseJson(runJson, "run.json"));
259
+ const allowed = legacy ? allowedLegacyRunPath : allowedRunPath;
260
+ for (const path of byPath.keys()) if (!allowed(path)) throw new Rejected(`File not allowed in a run bundle: ${path}`);
261
+ const storedCatalog = byPath.get("catalog.json");
262
+ if (storedCatalog) parseCatalog(storedCatalog);
263
+ const catalog = storedCatalog && legacy ? upgradeCatalogFile(storedCatalog) : storedCatalog;
264
+ if (!legacy) return [runUpload(byPath, catalog)];
265
+ parseRun(runJson, { legacy: true });
266
+ return upgradeSnapshotRun(byPath).filter(({ files }) => {
267
+ const status = JSON.parse(new TextDecoder().decode(files.get("run.json"))).status;
268
+ return status === "passed" || status === "failed" || status === "errored";
269
+ }).map(({ files }) => runUpload(files, catalog));
270
+ }
271
+ function runUpload(files, catalog) {
272
+ const runJson = files.get("run.json");
130
273
  return {
131
274
  run: parseRun(runJson),
132
275
  runJson,
133
276
  ...catalog ? { catalog } : {},
134
- files: bundle.filter((file) => file.path !== "run.json" && file.path !== "catalog.json")
277
+ files: [...files].filter(([path]) => path !== "run.json" && path !== "catalog.json").map(([path, data]) => ({
278
+ path,
279
+ data
280
+ }))
135
281
  };
136
282
  }
137
- function parseRun(data) {
283
+ function parseRun(data, options = {}) {
138
284
  const value = record(parseJson(data, "run.json"), "run.json");
285
+ if (!options.legacy && value.snapshotVersion !== 2) throw new Rejected("run.json must be snapshot format version 2");
139
286
  const source = record(value.source, "run.json source");
140
287
  const id = text(value.id, "id");
141
288
  if (!isUuid(id)) throw new Rejected("run.json id must be a UUID");
@@ -206,6 +353,12 @@ function optionalText(value, key, name) {
206
353
  }
207
354
  //#endregion
208
355
  //#region ../share/src/app.ts
356
+ /** `''` or `/a/b`: a path prefix with no trailing slash. */
357
+ function normalizeBasePath(basePath) {
358
+ const path = (basePath ?? "").replace(/\/+$/, "");
359
+ if (path && !/^(\/[A-Za-z0-9._~-]+)+$/.test(path)) throw new Error(`basePath must look like /evals (letters, digits, ".", "_", "~" and "-" between slashes), got ${JSON.stringify(basePath)}`);
360
+ return path;
361
+ }
209
362
  const IMMUTABLE = "public, max-age=31536000, immutable";
210
363
  const NO_CACHE = { "cache-control": "no-cache" };
211
364
  const RUN_FILE = /^repos\/(.+)\/commits\/([0-9a-f]{40})\/runs\/([^/]+)\/run\.json$/;
@@ -221,6 +374,7 @@ const jsonLine = (event) => console.log(JSON.stringify(event));
221
374
  *
222
375
  * POST /api/v1/uploads one run bundle
223
376
  * POST /api/v1/reindex rebuild the index from stored run.json files
377
+ * POST /api/v1/upgrade convert stored runs from before 0.9 (RFD0008)
224
378
  * GET /api/v1/repos indexed repositories
225
379
  * GET /api/v1/repos/<repo>/-/commits commits, newest first
226
380
  * GET /api/v1/repos/<repo>/-/<sha>/data/… snapshot data
@@ -229,13 +383,24 @@ const jsonLine = (event) => console.log(JSON.stringify(event));
229
383
  * GET /<repo>/-/<ref|prefix>/… → full SHA
230
384
  * GET everything else the dashboard app
231
385
  *
386
+ * All of it below `basePath` when set. With `repo` (single-repository mode)
387
+ * pages are `/` (→ latest commit) and `/-/<ref|prefix>/…`.
388
+ *
232
389
  * There is no authentication: run it on a private network.
233
390
  */
234
391
  function createShareApp(options) {
235
392
  const { files, index, assets } = options;
236
393
  const log = options.log ?? jsonLine;
237
394
  const maxBundleBytes = options.maxBundleBytes ?? 268435456;
238
- const app = new Hono();
395
+ const base = normalizeBasePath(options.basePath);
396
+ const single = options.repo;
397
+ if (single !== void 0 && !validRepoKey(single)) throw new Error(`repo must be a repository key such as github.com/acme/agents, got ${JSON.stringify(single)}`);
398
+ /** Where a repository's pages live: `<base>/<repo>`, or `<base>` for the single one. */
399
+ const pagesOf = (repo) => single ? base : `${base}/${repo}`;
400
+ const shown = (repo) => !single || repo === single;
401
+ /** The request path below `basePath`. */
402
+ const local = (path) => path.slice(base.length);
403
+ const app = base ? new Hono().basePath(base) : new Hono();
239
404
  app.onError((error, context) => {
240
405
  log({
241
406
  event: "share.error",
@@ -246,9 +411,12 @@ function createShareApp(options) {
246
411
  return context.json({ error: "Internal error" }, 500);
247
412
  });
248
413
  app.post("/api/v1/uploads", async (context) => {
249
- let upload;
414
+ let uploads;
250
415
  try {
251
- upload = parseUpload(await decodeBundle(new Uint8Array(await context.req.arrayBuffer()), { maxBytes: maxBundleBytes }));
416
+ uploads = parseUpload(await decodeBundle(new Uint8Array(await context.req.arrayBuffer()), { maxBytes: maxBundleBytes }));
417
+ if (!uploads.length) throw new Rejected("The bundle has no finished runs");
418
+ const other = uploads.find(({ run }) => !shown(run.repoKey));
419
+ if (other) throw new Rejected(`This share server only accepts results for ${single}, not ${other.run.repoKey}`);
252
420
  } catch (error) {
253
421
  if (!(error instanceof BundleError || error instanceof Rejected)) throw error;
254
422
  log({
@@ -258,25 +426,107 @@ function createShareApp(options) {
258
426
  });
259
427
  return context.json({ error: error.message }, 400);
260
428
  }
261
- const { run } = upload;
262
- const url = new URL(`/${run.repoKey}/-/${run.source.commit}/runs/${run.id}`, context.req.url).href;
263
- const exists = await index.hasRun(run.id);
264
- if (!exists) await storeRun(upload);
265
- const outcome = exists ? "exists" : "created";
266
- log({
267
- event: "share.upload",
268
- outcome,
269
- repo: run.repoKey,
270
- commit: run.source.commit,
271
- runId: run.id,
272
- files: upload.files.length,
273
- bytes: upload.files.reduce((sum, file) => sum + file.data.byteLength, 0)
274
- });
429
+ let created = false;
430
+ for (const upload of uploads) {
431
+ const { run } = upload;
432
+ const exists = await index.hasRun(run.id);
433
+ if (!exists) await storeRun(upload);
434
+ created ||= !exists;
435
+ log({
436
+ event: "share.upload",
437
+ outcome: exists ? "exists" : "created",
438
+ repo: run.repoKey,
439
+ commit: run.source.commit,
440
+ runId: run.id,
441
+ files: upload.files.length,
442
+ bytes: upload.files.reduce((sum, file) => sum + file.data.byteLength, 0)
443
+ });
444
+ }
445
+ const { run } = uploads[0];
446
+ const url = new URL(`${pagesOf(run.repoKey)}/-/${run.source.commit}/runs/${run.id}`, context.req.url).href;
275
447
  return context.json({
276
- status: outcome,
448
+ status: created ? "created" : "exists",
277
449
  url
278
- }, outcome === "created" ? 201 : 200);
450
+ }, created ? 201 : 200);
279
451
  });
452
+ /**
453
+ * Converts stored runs from before 0.9 (snapshot format version 1, a run of
454
+ * trials) into one run per trial, `limit` old runs per request (default
455
+ * 20), so it fits a Worker's limits. Call it until `remaining` is 0. New
456
+ * objects and index rows are written before old ones are removed, so an
457
+ * interrupted upgrade is finished by calling it again.
458
+ */
459
+ app.post("/api/v1/upgrade", async (context) => {
460
+ const limit = Math.min(Math.max(Number(context.req.query("limit") ?? 20) || 20, 1), 200);
461
+ const legacy = [];
462
+ for await (const key of files.list("repos/")) {
463
+ if (!RUN_FILE.test(key)) continue;
464
+ const data = await readBytes(await files.get(key));
465
+ if (!data) continue;
466
+ let value;
467
+ try {
468
+ value = JSON.parse(new TextDecoder().decode(data));
469
+ } catch {
470
+ continue;
471
+ }
472
+ if (isLegacyRun(value)) legacy.push(key);
473
+ }
474
+ let runs = 0;
475
+ const batch = legacy.slice(0, limit);
476
+ for (const key of batch) runs += await upgradeStoredRun(key);
477
+ const result = {
478
+ upgraded: batch.length,
479
+ runs,
480
+ remaining: legacy.length - batch.length
481
+ };
482
+ log({
483
+ event: "share.upgrade",
484
+ ...result
485
+ });
486
+ return context.json(result);
487
+ });
488
+ /** One stored version 1 run → version 2 runs; returns how many. */
489
+ async function upgradeStoredRun(runJsonKey) {
490
+ const [, repo, sha, oldRunId] = RUN_FILE.exec(runJsonKey);
491
+ const commitBase = `repos/${repo}/commits/${sha}`;
492
+ const runBase = `${commitBase}/runs/${oldRunId}`;
493
+ const stored = /* @__PURE__ */ new Map();
494
+ const keys = [];
495
+ for await (const key of files.list(`${runBase}/`)) keys.push(key);
496
+ for (const key of keys) {
497
+ const data = await readBytes(await files.get(key));
498
+ if (data) stored.set(key.slice(runBase.length + 1), data);
499
+ }
500
+ let count = 0;
501
+ const written = /* @__PURE__ */ new Set();
502
+ let newRunJson;
503
+ for (const upgraded of upgradeSnapshotRun(stored)) {
504
+ const runJson = upgraded.files.get("run.json");
505
+ const run = storedRun(runJson);
506
+ if (!run) continue;
507
+ count += 1;
508
+ const inPlace = run.id === oldRunId;
509
+ if (!inPlace && await index.hasRun(run.id)) continue;
510
+ const base = `${commitBase}/runs/${run.id}`;
511
+ for (const [path, data] of upgraded.files) {
512
+ if (path === "run.json") continue;
513
+ await files.put(`${base}/${path}`, data);
514
+ written.add(`${base}/${path}`);
515
+ }
516
+ if (inPlace) {
517
+ await index.forgetRun(run.id);
518
+ newRunJson = runJson;
519
+ } else await files.put(`${base}/run.json`, runJson);
520
+ await index.recordRun(run);
521
+ }
522
+ const catalog = await readBytes(await files.get(`${commitBase}/catalog.json`));
523
+ if (catalog) await files.put(`${commitBase}/catalog.json`, upgradeCatalogFile(catalog));
524
+ if (!newRunJson) await index.forgetRun(oldRunId);
525
+ for (const key of keys) if (key !== runJsonKey && !written.has(key)) await files.delete(key);
526
+ if (newRunJson) await files.put(runJsonKey, newRunJson);
527
+ else await files.delete(runJsonKey);
528
+ return count;
529
+ }
280
530
  /** Files first, index last: a run becomes visible only once it is indexed. */
281
531
  async function storeRun({ run, runJson, catalog, files: runFiles }) {
282
532
  const commitBase = `repos/${run.repoKey}/commits/${run.source.commit}`;
@@ -307,12 +557,12 @@ function createShareApp(options) {
307
557
  indexed
308
558
  });
309
559
  });
310
- app.get("/api/v1/repos", async (context) => context.json({ repos: await index.repos() }, 200, NO_CACHE));
560
+ app.get("/api/v1/repos", async (context) => context.json({ repos: (await index.repos()).filter((repo) => shown(repo.key)) }, 200, NO_CACHE));
311
561
  app.get("/api/v1/repos/*", async (context) => {
312
- const path = context.req.path.slice(14);
562
+ const path = local(context.req.path).slice(14);
313
563
  const marker = path.indexOf("/-/");
314
564
  const key = marker === -1 ? "" : path.slice(0, marker);
315
- if (!validRepoKey(key)) return context.json({ error: "Unknown repository" }, 404);
565
+ if (!validRepoKey(key) || !shown(key)) return context.json({ error: "Unknown repository" }, 404);
316
566
  const [sha = "", kind, ...segments] = path.slice(marker + 3).split("/");
317
567
  if (sha === "commits" && kind === void 0) return context.json({
318
568
  repo: key,
@@ -322,7 +572,7 @@ function createShareApp(options) {
322
572
  return serveData(context, key, sha, segments.map(decodeURIComponent));
323
573
  });
324
574
  app.get("/assets/*", async (context) => {
325
- const path = context.req.path.slice(8);
575
+ const path = local(context.req.path).slice(8);
326
576
  if (!path.split("/").every(safeSegment)) return context.notFound();
327
577
  const response = await assets.get(path);
328
578
  if (!response) return context.notFound();
@@ -336,29 +586,47 @@ function createShareApp(options) {
336
586
  app.all("/api/*", (context) => context.json({ error: "Not found" }, 404));
337
587
  app.get("*", async (context) => {
338
588
  const url = new URL(context.req.url);
339
- const path = url.pathname.replace(/\/+$/, "");
589
+ const pathname = local(url.pathname);
590
+ const path = pathname.replace(/\/+$/, "");
591
+ if (single) {
592
+ if (path === `/${single}` || path.startsWith(`/${single}/`)) return context.redirect(`${base}${path.slice(single.length + 1) || "/"}${url.search}`);
593
+ if (!path) return latestPage(context, single);
594
+ if (!path.startsWith("/-/")) return dashboardPage(context, {
595
+ kind: "missing",
596
+ repo: single
597
+ });
598
+ return commitPage(context, single, pathname.slice(3), url.search);
599
+ }
340
600
  const marker = path.indexOf("/-/");
341
- if (marker === -1) return repositoryPage(context, path.slice(1));
342
- const repo = path.slice(1, marker);
343
- const [ref = "", ...rest] = url.pathname.slice(marker + 3).split("/");
601
+ if (marker === -1) {
602
+ const repo = path.slice(1);
603
+ return repo ? latestPage(context, repo) : dashboardPage(context, { kind: "repositories" });
604
+ }
605
+ return commitPage(context, path.slice(1, marker), pathname.slice(marker + 3), url.search);
606
+ });
607
+ /**
608
+ * `<pages>/-/<ref>/…`: a short ref resolves to its full SHA first, so every
609
+ * commit page URL names one immutable commit.
610
+ */
611
+ async function commitPage(context, repo, afterMarker, search) {
612
+ const [ref = "", ...rest] = afterMarker.split("/");
344
613
  const sha = validRepoKey(repo) ? await index.resolve(repo, decodeURIComponent(ref)) : void 0;
345
614
  if (!sha) return dashboardPage(context, {
346
615
  kind: "missing",
347
616
  repo,
348
617
  ref
349
618
  });
350
- if (sha !== ref) return context.redirect(`/${repo}/-/${sha}/${rest.join("/")}${url.search}`);
619
+ if (sha !== ref) return context.redirect(`${pagesOf(repo)}/-/${sha}/${rest.join("/")}${search}`);
351
620
  return dashboardPage(context, {
352
621
  kind: "commit",
353
622
  repo,
354
623
  sha
355
624
  });
356
- });
357
- /** `/` lists repositories; `/<repo>` opens its latest commit. */
358
- async function repositoryPage(context, repo) {
359
- if (!repo) return dashboardPage(context, { kind: "repositories" });
625
+ }
626
+ /** A repository's own page opens its latest commit. */
627
+ async function latestPage(context, repo) {
360
628
  const latest = validRepoKey(repo) ? await index.latest(repo) : void 0;
361
- if (latest) return context.redirect(`/${repo}/-/${latest}/`);
629
+ if (latest) return context.redirect(`${pagesOf(repo)}/-/${latest}/`);
362
630
  return dashboardPage(context, {
363
631
  kind: "missing",
364
632
  repo
@@ -366,14 +634,17 @@ function createShareApp(options) {
366
634
  }
367
635
  /** The dashboard's index.html, told where this page's data lives. */
368
636
  async function dashboardPage(context, page) {
369
- const api = page.kind === "repositories" ? void 0 : `/api/v1/repos/${page.repo}/-/`;
637
+ const api = page.kind === "repositories" ? void 0 : `${base}/api/v1/repos/${page.repo}/-/`;
370
638
  return context.html(dashboardIndex(await assets.index(), {
371
- base: page.kind === "commit" ? `/${page.repo}/-/${page.sha}/` : "/",
372
- assets: "/assets/",
639
+ base: page.kind === "commit" ? `${pagesOf(page.repo)}/-/${page.sha}/` : `${base}/`,
640
+ assets: `${base}/assets/`,
373
641
  share: {
374
- repos: "/api/v1/repos",
642
+ repos: `${base}/api/v1/repos`,
643
+ home: `${base}/`,
644
+ ...single ? { single: true } : {},
375
645
  ...page.kind === "repositories" ? {} : {
376
646
  repo: page.repo,
647
+ pages: pagesOf(page.repo),
377
648
  commits: `${api}commits`
378
649
  },
379
650
  ...page.kind === "commit" ? { data: `${api}${page.sha}/` } : {},
@@ -395,11 +666,11 @@ function createShareApp(options) {
395
666
  const catalog = data ? parseCatalog(data) : EMPTY_CATALOG;
396
667
  return context.json(path === "catalog.json" ? { evals: catalog.catalog } : path === "evals.json" ? { evals: catalog.evals } : { suites: catalog.suites }, 200, NO_CACHE);
397
668
  }
398
- const runFile = /^runs\/([^/]+)\/(trials\.json|trials\/.+)$/.exec(path);
669
+ const runFile = /^runs\/([^/]+)\/((?:detail|events|artifacts|workspace)\.json|(?:workspace|attachments)\/.+)$/.exec(path);
399
670
  if (!runFile || !isUuid(runFile[1])) return context.notFound();
400
671
  const body = await files.get(`${commitBase}/${path}`);
401
672
  if (!body) return context.notFound();
402
- const json = path.endsWith(".json") && !path.includes("/workspace/");
673
+ const json = path.endsWith(".json") && !path.includes("/workspace/") && !path.includes("/attachments/");
403
674
  return new Response(body, { headers: json ? {
404
675
  "content-type": "application/json",
405
676
  "cache-control": IMMUTABLE
@@ -500,6 +771,16 @@ var ShareIndexDb = class {
500
771
  WHERE repo = ? AND sha = ?`, run.status === "passed" ? 1 : 0, run.status === "failed" || run.status === "errored" ? 1 : 0, run.repoKey, run.source.commit);
501
772
  });
502
773
  }
774
+ forgetRun(runId) {
775
+ this.sql.transaction(() => {
776
+ const [row] = this.sql.all("SELECT repo, sha, status, dirty FROM runs WHERE id = ?", runId);
777
+ if (!row) return;
778
+ this.sql.run("DELETE FROM runs WHERE id = ?", runId);
779
+ if (!row.dirty) this.sql.run(`UPDATE commits SET runs = runs - 1,
780
+ passed = passed - ?, failed = failed - ?
781
+ WHERE repo = ? AND sha = ?`, row.status === "passed" ? 1 : 0, row.status === "failed" || row.status === "errored" ? 1 : 0, row.repo, row.sha);
782
+ });
783
+ }
503
784
  repos() {
504
785
  return this.sql.all(`SELECT key, url, project, first_seen_at AS firstSeenAt,
505
786
  last_upload_at AS lastUploadAt,
@@ -535,6 +816,7 @@ function asyncIndex(db) {
535
816
  return {
536
817
  hasRun: async (runId) => db.hasRun(runId),
537
818
  recordRun: async (run) => db.recordRun(run),
819
+ forgetRun: async (runId) => db.forgetRun(runId),
538
820
  repos: async () => db.repos(),
539
821
  commits: async (repo) => db.commits(repo),
540
822
  runs: async (repo, sha) => db.runs(repo, sha),
@@ -543,4 +825,4 @@ function asyncIndex(db) {
543
825
  };
544
826
  }
545
827
  //#endregion
546
- export { allowedRunPath as a, dashboardIndex as c, encodeBundle as d, parseUpload as i, BUNDLE_FORMAT as l, asyncIndex as n, repoKey as o, createShareApp as r, validRepoKey as s, ShareIndexDb as t, decodeBundle as u };
828
+ export { parseUpload as a, validRepoKey as c, decodeBundle as d, encodeBundle as f, normalizeBasePath as i, dashboardIndex as l, asyncIndex as n, allowedRunPath as o, createShareApp as r, repoKey as s, ShareIndexDb as t, BUNDLE_FORMAT as u };