@leostera/evalkit 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -6
- package/README.md +10 -8
- package/dist/cli.mjs +1227 -258
- package/dist/dashboard/assets/index-CTJpd8q8.js +34 -0
- package/dist/dashboard/assets/index-YAabTN3X.css +2 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/files-C-U0-PWW.mjs +0 -2
- package/dist/index-db-vbqEGFaU.mjs +0 -2
- package/dist/index.d.mts +1346 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -4
- package/dist/runner.d.mts +809 -11
- package/dist/runner.d.mts.map +1 -1
- package/dist/runner.mjs +2 -2
- package/dist/share-worker.d.mts +76 -1
- package/dist/share-worker.d.mts.map +1 -1
- package/dist/share-worker.mjs +0 -2
- package/dist/share.d.mts +149 -6
- package/dist/share.d.mts.map +1 -1
- package/dist/share.mjs +1 -1
- package/dist/skills/evalkit/SKILL.md +32 -0
- package/dist/skills/evalkit-agents/SKILL.md +16 -0
- package/dist/skills/evalkit-datasets/SKILL.md +17 -0
- package/dist/skills/evalkit-evals/SKILL.md +19 -0
- package/dist/skills/evalkit-judges/SKILL.md +17 -0
- package/dist/skills/evalkit-matrices/SKILL.md +14 -0
- package/dist/skills/evalkit-results/SKILL.md +14 -0
- package/dist/{src-DoPs9Yug.mjs → src-CKqOzeBi.mjs} +448 -116
- package/dist/{src-ButXPY8P.mjs → src-CZPufWN_.mjs} +419 -94
- package/dist/{src-DzZueFn3.mjs → src-CZnDXP1K.mjs} +46 -10
- package/package.json +3 -2
- package/dist/cli.mjs.map +0 -1
- package/dist/dashboard/assets/index-D9NtA46z.css +0 -2
- package/dist/dashboard/assets/index-DrBwLKJD.js +0 -30
- package/dist/files-C-U0-PWW.mjs.map +0 -1
- package/dist/index-db-HdZXelOS.d.mts +0 -142
- package/dist/index-db-HdZXelOS.d.mts.map +0 -1
- package/dist/index-db-vbqEGFaU.mjs.map +0 -1
- package/dist/index-lOw0fmak.d.mts +0 -1028
- package/dist/index-lOw0fmak.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/share-worker.mjs.map +0 -1
- package/dist/src-ButXPY8P.mjs.map +0 -1
- package/dist/src-DoPs9Yug.mjs.map +0 -1
- package/dist/src-DzZueFn3.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
#!/
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
':' //; exec "${EVALKIT_RUNTIME:-$(command -v bun || command -v node)}" "$0" "$@"
|
|
3
|
+
import { L as defineEvalMatrix, R as canonicalParameters, d as recordError, f as registerEvals, i as authoringId, r as agentRuntimes, s as defineSuite } from "./src-CZPufWN_.mjs";
|
|
4
|
+
import { a as readRunSummary, d as staticDatasetFile, f as staticDatasetFiles, i as readRunManifest, l as readTrialSummary, n as runMatrix, o as readTrialEvents, r as ownerIsGone, s as readTrialManifest, t as runEval, u as localReportStore } from "./src-CKqOzeBi.mjs";
|
|
4
5
|
import { c as dashboardIndex, d as encodeBundle, o as repoKey } from "./index-db-vbqEGFaU.mjs";
|
|
5
|
-
import { t as createDirectoryShareApp } from "./src-
|
|
6
|
+
import { t as createDirectoryShareApp } from "./src-CZnDXP1K.mjs";
|
|
6
7
|
import * as Schema from "effect/Schema";
|
|
7
8
|
import { Effect, LogLevel, Logger, Option } from "effect";
|
|
9
|
+
import * as OS from "node:os";
|
|
8
10
|
import { cp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
9
11
|
import * as Path from "node:path";
|
|
10
12
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
11
|
-
import * as OS from "node:os";
|
|
12
13
|
import * as Crypto from "node:crypto";
|
|
13
14
|
import * as NFS from "node:fs";
|
|
14
15
|
import { existsSync } from "node:fs";
|
|
@@ -67,9 +68,10 @@ import * as FiberSet from "effect/FiberSet";
|
|
|
67
68
|
import "effect/Fiber";
|
|
68
69
|
import "effect/Pool";
|
|
69
70
|
import * as MutableRef from "effect/MutableRef";
|
|
71
|
+
import { createServer } from "node:http";
|
|
70
72
|
import { parseArgs, promisify } from "node:util";
|
|
71
73
|
import * as ChildProcess from "node:child_process";
|
|
72
|
-
import { execFile } from "node:child_process";
|
|
74
|
+
import { execFile, spawn } from "node:child_process";
|
|
73
75
|
import * as RcRef from "effect/RcRef";
|
|
74
76
|
import * as readline from "node:readline";
|
|
75
77
|
import { Hono } from "hono";
|
|
@@ -5792,7 +5794,7 @@ const merge = (map1, map2) => {
|
|
|
5792
5794
|
}
|
|
5793
5795
|
return map1;
|
|
5794
5796
|
};
|
|
5795
|
-
const escape = (string) => escapeSingleQuoted(string.replaceAll("\\", "\\\\")).replaceAll("[", "\\[").replaceAll("]", "\\]").replaceAll(":", "\\:").replaceAll("$", "\\$").replaceAll("`", "\\`").replaceAll("(", "\\(").replaceAll(")", "\\)");
|
|
5797
|
+
const escape$1 = (string) => escapeSingleQuoted(string.replaceAll("\\", "\\\\")).replaceAll("[", "\\[").replaceAll("]", "\\]").replaceAll(":", "\\:").replaceAll("$", "\\$").replaceAll("`", "\\`").replaceAll("(", "\\(").replaceAll(")", "\\)");
|
|
5796
5798
|
const getShortDescription$1 = (self) => {
|
|
5797
5799
|
switch (self._tag) {
|
|
5798
5800
|
case "Empty":
|
|
@@ -5860,7 +5862,7 @@ const getZshCompletions$1 = (self, state = {
|
|
|
5860
5862
|
const possibleValues = getZshCompletions$3(self.primitiveType);
|
|
5861
5863
|
const multiple = state.multiple ? "*" : "";
|
|
5862
5864
|
const conflicts = Arr.isNonEmptyReadonlyArray(state.conflicts) ? `(${Arr.join(state.conflicts, " ")})` : "";
|
|
5863
|
-
return Arr.map(names, (name) => `${conflicts}${multiple}${name}[${escape(description)}]${possibleValues}`);
|
|
5865
|
+
return Arr.map(names, (name) => `${conflicts}${multiple}${name}[${escape$1(description)}]${possibleValues}`);
|
|
5864
5866
|
}
|
|
5865
5867
|
case "KeyValueMap": return getZshCompletions$1(self.argumentOption, {
|
|
5866
5868
|
...state,
|
|
@@ -8069,8 +8071,193 @@ const layerManager = /* @__PURE__ */ Layer.provide(layerManager$2, layerWorker);
|
|
|
8069
8071
|
*/
|
|
8070
8072
|
const layer = /*#__PURE__*/ pipe(/*#__PURE__*/ Layer.mergeAll(layer$3, layer$5, layer$1, layerManager), /*#__PURE__*/ Layer.provideMerge(layer$7));
|
|
8071
8073
|
//#endregion
|
|
8074
|
+
//#region ../cli/src/dataset-files.ts
|
|
8075
|
+
/** The project's datasets by ID; the first definition wins when IDs repeat. */
|
|
8076
|
+
function projectDatasets(registry) {
|
|
8077
|
+
const byId = /* @__PURE__ */ new Map();
|
|
8078
|
+
for (const evaluation of registry.evals) for (const dataset of evaluation.datasets ?? []) if (!byId.has(dataset.id)) byId.set(dataset.id, dataset);
|
|
8079
|
+
return byId;
|
|
8080
|
+
}
|
|
8081
|
+
async function datasetListing(dataset, root) {
|
|
8082
|
+
const files = await staticDatasetFiles(dataset, root);
|
|
8083
|
+
return files ? {
|
|
8084
|
+
kind: "static",
|
|
8085
|
+
mount: dataset.mount,
|
|
8086
|
+
files
|
|
8087
|
+
} : {
|
|
8088
|
+
kind: "computed",
|
|
8089
|
+
mount: dataset.mount
|
|
8090
|
+
};
|
|
8091
|
+
}
|
|
8092
|
+
//#endregion
|
|
8093
|
+
//#region ../cli/src/source.ts
|
|
8094
|
+
const exec = promisify(execFile);
|
|
8095
|
+
/**
|
|
8096
|
+
* Normalize a git remote to a stable repository key: lowercase host, no
|
|
8097
|
+
* credentials, port, scheme, `.git`, or trailing slash.
|
|
8098
|
+
*
|
|
8099
|
+
* git@GitLab.example.com:group/sub/repo.git → gitlab.example.com/group/sub/repo
|
|
8100
|
+
* https://user:token@github.com/o/r/ → github.com/o/r
|
|
8101
|
+
* /srv/repos/thing.git (no host) → local/thing
|
|
8102
|
+
*/
|
|
8103
|
+
function normalizeRepoUrl(url) {
|
|
8104
|
+
const trimmed = url.trim().replace(/\/+$/, "").replace(/\.git$/, "");
|
|
8105
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/.exec(trimmed);
|
|
8106
|
+
if (scp && !/^[a-z]+:\/\//i.test(trimmed)) return `${scp[1].toLowerCase()}/${scp[2].replace(/^\/+/, "")}`;
|
|
8107
|
+
try {
|
|
8108
|
+
const parsed = new URL(trimmed);
|
|
8109
|
+
if (parsed.protocol !== "file:" && parsed.hostname) return `${parsed.hostname.toLowerCase()}${parsed.pathname.replace(/\/+$/, "")}`;
|
|
8110
|
+
} catch {}
|
|
8111
|
+
return `local/${basename(trimmed)}`;
|
|
8112
|
+
}
|
|
8113
|
+
async function git(cwd, ...args) {
|
|
8114
|
+
try {
|
|
8115
|
+
const { stdout } = await exec("git", [
|
|
8116
|
+
"-C",
|
|
8117
|
+
cwd,
|
|
8118
|
+
...args
|
|
8119
|
+
], { timeout: 1e4 });
|
|
8120
|
+
return stdout.trim() || void 0;
|
|
8121
|
+
} catch {
|
|
8122
|
+
return;
|
|
8123
|
+
}
|
|
8124
|
+
}
|
|
8125
|
+
const posix = (path) => path.split(sep).join("/");
|
|
8126
|
+
const projectPath = (root, projectRoot) => root ? posix(relative(root, projectRoot)) : "";
|
|
8127
|
+
function ciSource(env, projectRoot) {
|
|
8128
|
+
if (env.EVALKIT_SOURCE_REPO && env.EVALKIT_SOURCE_COMMIT) return {
|
|
8129
|
+
repo: normalizeRepoUrl(env.EVALKIT_SOURCE_REPO),
|
|
8130
|
+
project: env.EVALKIT_SOURCE_PROJECT ?? "",
|
|
8131
|
+
commit: env.EVALKIT_SOURCE_COMMIT,
|
|
8132
|
+
...env.EVALKIT_SOURCE_REF ? { ref: env.EVALKIT_SOURCE_REF } : {},
|
|
8133
|
+
...env.EVALKIT_SOURCE_COMMITTED_AT ? { committedAt: env.EVALKIT_SOURCE_COMMITTED_AT } : {}
|
|
8134
|
+
};
|
|
8135
|
+
if (env.GITLAB_CI === "true" && env.CI_PROJECT_URL && env.CI_COMMIT_SHA) return {
|
|
8136
|
+
repo: normalizeRepoUrl(env.CI_PROJECT_URL),
|
|
8137
|
+
project: projectPath(env.CI_PROJECT_DIR, projectRoot),
|
|
8138
|
+
commit: env.CI_COMMIT_SHA,
|
|
8139
|
+
...env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME ? { ref: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME } : {},
|
|
8140
|
+
...env.CI_COMMIT_TIMESTAMP ? { committedAt: env.CI_COMMIT_TIMESTAMP } : {},
|
|
8141
|
+
ci: {
|
|
8142
|
+
provider: "gitlab",
|
|
8143
|
+
...env.CI_PIPELINE_ID ? { pipeline: env.CI_PIPELINE_ID } : {},
|
|
8144
|
+
...env.CI_JOB_ID ? { job: env.CI_JOB_ID } : {},
|
|
8145
|
+
...env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}
|
|
8146
|
+
}
|
|
8147
|
+
};
|
|
8148
|
+
if (env.GITHUB_ACTIONS === "true" && env.GITHUB_REPOSITORY && env.GITHUB_SHA) {
|
|
8149
|
+
const server = env.GITHUB_SERVER_URL ?? "https://github.com";
|
|
8150
|
+
return {
|
|
8151
|
+
repo: normalizeRepoUrl(`${server}/${env.GITHUB_REPOSITORY}`),
|
|
8152
|
+
project: projectPath(env.GITHUB_WORKSPACE, projectRoot),
|
|
8153
|
+
commit: env.GITHUB_SHA,
|
|
8154
|
+
...env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME ? { ref: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME } : {},
|
|
8155
|
+
ci: {
|
|
8156
|
+
provider: "github",
|
|
8157
|
+
...env.GITHUB_RUN_ID ? { pipeline: env.GITHUB_RUN_ID } : {},
|
|
8158
|
+
...env.GITHUB_JOB ? { job: env.GITHUB_JOB } : {},
|
|
8159
|
+
...env.GITHUB_RUN_ID ? { url: `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` } : {}
|
|
8160
|
+
}
|
|
8161
|
+
};
|
|
8162
|
+
}
|
|
8163
|
+
}
|
|
8164
|
+
/**
|
|
8165
|
+
* Determine which repository and commit this project's eval code comes from.
|
|
8166
|
+
* Order: EVALKIT_SOURCE_* variables, GitLab/GitHub CI variables, then git.
|
|
8167
|
+
* Returns undefined outside git with no CI variables ("untracked" results).
|
|
8168
|
+
*
|
|
8169
|
+
* `outputDirs` (results, sandbox, snapshot) never make the tree dirty, even
|
|
8170
|
+
* when a project forgot to ignore them.
|
|
8171
|
+
*/
|
|
8172
|
+
async function detectSource(givenRoot, options = {}) {
|
|
8173
|
+
const env = options.env ?? process.env;
|
|
8174
|
+
const projectRoot = await realpath(givenRoot).catch(() => givenRoot);
|
|
8175
|
+
const top = await git(projectRoot, "rev-parse", "--show-toplevel");
|
|
8176
|
+
const excludes = top ? (options.outputDirs ?? []).map((dir) => join(projectRoot, relative(givenRoot, dir))).map((dir) => posix(relative(top, dir))).filter((dir) => dir && !dir.startsWith("..")).map((dir) => `:(exclude)${dir}`) : [];
|
|
8177
|
+
const dirty = top ? Boolean(await git(top, "status", "--porcelain", "--", ".", ...excludes)) : false;
|
|
8178
|
+
const fromCi = ciSource(env, projectRoot);
|
|
8179
|
+
if (fromCi) return {
|
|
8180
|
+
...fromCi,
|
|
8181
|
+
...top && !env.EVALKIT_SOURCE_PROJECT ? { project: projectPath(top, projectRoot) } : {},
|
|
8182
|
+
dirty
|
|
8183
|
+
};
|
|
8184
|
+
if (!top) return void 0;
|
|
8185
|
+
const commit = await git(top, "rev-parse", "HEAD");
|
|
8186
|
+
if (!commit) return void 0;
|
|
8187
|
+
const remote = await git(top, "config", "--get", "remote.origin.url") ?? (await git(top, "remote"))?.split("\n").map((name) => name.trim()).find(Boolean);
|
|
8188
|
+
const remoteUrl = remote && !remote.includes("/") && !remote.includes(":") ? await git(top, "config", "--get", `remote.${remote}.url`) : remote;
|
|
8189
|
+
const ref = await git(top, "symbolic-ref", "--short", "-q", "HEAD");
|
|
8190
|
+
const committedAt = await git(top, "show", "-s", "--format=%cI", "HEAD");
|
|
8191
|
+
return {
|
|
8192
|
+
repo: normalizeRepoUrl(remoteUrl ?? top),
|
|
8193
|
+
project: projectPath(top, projectRoot),
|
|
8194
|
+
commit,
|
|
8195
|
+
...ref ? { ref } : {},
|
|
8196
|
+
...committedAt ? { committedAt } : {},
|
|
8197
|
+
dirty
|
|
8198
|
+
};
|
|
8199
|
+
}
|
|
8200
|
+
/** What the dashboard's header shows: repository, commit, branch, dirty. */
|
|
8201
|
+
function projectInfo(source) {
|
|
8202
|
+
return {
|
|
8203
|
+
repo: source.repo,
|
|
8204
|
+
...source.project ? { project: source.project } : {},
|
|
8205
|
+
commit: source.commit,
|
|
8206
|
+
...source.ref ? { ref: source.ref } : {},
|
|
8207
|
+
dirty: source.dirty
|
|
8208
|
+
};
|
|
8209
|
+
}
|
|
8210
|
+
/** The project's evals and suites, saved with each run (`catalog.json`). */
|
|
8211
|
+
function runCatalog(registry, root) {
|
|
8212
|
+
return {
|
|
8213
|
+
catalog: registry.catalog({ root }),
|
|
8214
|
+
evals: registry.metadata(),
|
|
8215
|
+
suites: registry.suiteMetadata()
|
|
8216
|
+
};
|
|
8217
|
+
}
|
|
8218
|
+
/** `abc1234` or `abc1234+dirty`. */
|
|
8219
|
+
function shortSource(source) {
|
|
8220
|
+
return `${source.commit.slice(0, 7)}${source.dirty ? "+dirty" : ""}`;
|
|
8221
|
+
}
|
|
8222
|
+
//#endregion
|
|
8072
8223
|
//#region ../cli/src/dashboard-reports.ts
|
|
8073
8224
|
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;
|
|
8225
|
+
function runScorers(trials) {
|
|
8226
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
8227
|
+
for (const trial of trials) for (const score of trial?.scoring?.results ?? []) {
|
|
8228
|
+
const kind = score.kind;
|
|
8229
|
+
const key = `${kind}\0${score.name}`;
|
|
8230
|
+
const entry = byKey.get(key) ?? {
|
|
8231
|
+
name: score.name,
|
|
8232
|
+
kind,
|
|
8233
|
+
count: 0,
|
|
8234
|
+
skipped: 0,
|
|
8235
|
+
sum: 0
|
|
8236
|
+
};
|
|
8237
|
+
if (score.skipped) entry.skipped += 1;
|
|
8238
|
+
else if (score.value !== void 0) {
|
|
8239
|
+
entry.count += 1;
|
|
8240
|
+
entry.sum += score.value;
|
|
8241
|
+
}
|
|
8242
|
+
byKey.set(key, entry);
|
|
8243
|
+
}
|
|
8244
|
+
return [...byKey.values()].map(({ sum, ...scorer }) => ({
|
|
8245
|
+
...scorer,
|
|
8246
|
+
...scorer.count ? { mean: sum / scorer.count } : {}
|
|
8247
|
+
}));
|
|
8248
|
+
}
|
|
8249
|
+
function runDatasets(manifests) {
|
|
8250
|
+
const byId = /* @__PURE__ */ new Map();
|
|
8251
|
+
for (const manifest of manifests) for (const dataset of manifest?.datasets ?? []) {
|
|
8252
|
+
const digests = byId.get(dataset.id) ?? /* @__PURE__ */ new Set();
|
|
8253
|
+
digests.add(dataset.digest);
|
|
8254
|
+
byId.set(dataset.id, digests);
|
|
8255
|
+
}
|
|
8256
|
+
return [...byId].map(([id, digests]) => ({
|
|
8257
|
+
id,
|
|
8258
|
+
digests: [...digests].sort()
|
|
8259
|
+
}));
|
|
8260
|
+
}
|
|
8074
8261
|
/** Written into a run directory by `evalkit tidy`; deleting it restores the run. */
|
|
8075
8262
|
const ARCHIVE_MARKER = "archived.json";
|
|
8076
8263
|
function isMissing(error) {
|
|
@@ -8094,11 +8281,13 @@ function trialPath(root, runId, trialId) {
|
|
|
8094
8281
|
}
|
|
8095
8282
|
function runStatus(summary) {
|
|
8096
8283
|
if (summary.status === "running") return "running";
|
|
8284
|
+
if (summary.status === "cancelled") return "cancelled";
|
|
8097
8285
|
if (summary.status !== "completed") return "errored";
|
|
8098
8286
|
return summary.failed === 0 && summary.passed === summary.trialCount ? "passed" : "failed";
|
|
8099
8287
|
}
|
|
8100
8288
|
function trialStatus(summary) {
|
|
8101
8289
|
if (summary.status === "running") return "running";
|
|
8290
|
+
if (summary.status === "cancelled") return "cancelled";
|
|
8102
8291
|
if (summary.status !== "completed") return "errored";
|
|
8103
8292
|
return summary.scoring?.passed === true ? "passed" : "failed";
|
|
8104
8293
|
}
|
|
@@ -8143,10 +8332,15 @@ function createDashboardReportReader(root, registry) {
|
|
|
8143
8332
|
const summary = await ifPresent(() => readRunSummary(root, id));
|
|
8144
8333
|
const trialIds = await ifPresent(() => readdir(resolve(root, id, "trials")));
|
|
8145
8334
|
const trials = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialSummary(root, id, trialId))));
|
|
8335
|
+
const trialManifests = await Promise.all((trialIds ?? []).map((trialId) => ifPresent(() => readTrialManifest(root, id, trialId))));
|
|
8146
8336
|
const scores = trials.flatMap((trial) => trial?.scoring?.overall === void 0 ? [] : [trial.scoring.overall]);
|
|
8337
|
+
const scorers = runScorers(trials);
|
|
8338
|
+
const datasets = runDatasets(trialManifests);
|
|
8339
|
+
const archived = await ifPresent(() => stat(resolve(root, id, ARCHIVE_MARKER)));
|
|
8340
|
+
const interrupted = !summary && ownerIsGone(manifest.process);
|
|
8147
8341
|
return {
|
|
8148
8342
|
id,
|
|
8149
|
-
...
|
|
8343
|
+
...archived ? { archived: true } : {},
|
|
8150
8344
|
...manifest.source ? { source: manifest.source } : {},
|
|
8151
8345
|
evalId: manifest.evalId,
|
|
8152
8346
|
...manifest.suiteId ? { suiteId: manifest.suiteId } : {},
|
|
@@ -8160,26 +8354,31 @@ function createDashboardReportReader(root, registry) {
|
|
|
8160
8354
|
manifest.aut.id,
|
|
8161
8355
|
manifest.aut.version
|
|
8162
8356
|
].filter(Boolean).join(" / ") } : {},
|
|
8163
|
-
status: summary ? runStatus(summary) : "running",
|
|
8357
|
+
status: summary ? runStatus(summary) : interrupted ? "cancelled" : "running",
|
|
8358
|
+
...interrupted ? { interrupted: true } : {},
|
|
8164
8359
|
startedAt: manifest.startedAt,
|
|
8165
8360
|
...summary?.endedAt ? { completedAt: summary.endedAt } : {},
|
|
8166
8361
|
...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
|
|
8167
8362
|
completedTrials: summary?.trialCount ?? trials.filter(Boolean).length,
|
|
8168
8363
|
requestedTrials: summary?.trialCount ?? registry.get(manifest.evalId)?.policy?.trials ?? 1,
|
|
8169
|
-
...scores.length ? { score: scores.reduce((sum, score) => sum + score, 0) / scores.length } : {}
|
|
8364
|
+
...scores.length ? { score: scores.reduce((sum, score) => sum + score, 0) / scores.length } : {},
|
|
8365
|
+
...scorers.length ? { scorers } : {},
|
|
8366
|
+
...datasets.length ? { datasets } : {}
|
|
8170
8367
|
};
|
|
8171
8368
|
}
|
|
8172
8369
|
async function listTrials(runId) {
|
|
8173
8370
|
assertUuid(runId, "run ID");
|
|
8174
8371
|
const ids = await ifPresent(() => readdir(resolve(root, runId, "trials")));
|
|
8175
8372
|
if (!ids) return [];
|
|
8373
|
+
const runManifest = await ifPresent(() => readRunManifest(root, runId));
|
|
8374
|
+
const ownerGone = ownerIsGone(runManifest?.process);
|
|
8176
8375
|
return (await Promise.all(ids.map(async (id) => {
|
|
8177
8376
|
const manifest = await readTrialManifest(root, runId, id);
|
|
8178
8377
|
const summary = await ifPresent(() => readTrialSummary(root, runId, id));
|
|
8179
8378
|
return {
|
|
8180
8379
|
id,
|
|
8181
8380
|
index: manifest.trialIndex,
|
|
8182
|
-
status: summary ? trialStatus(summary) : "running",
|
|
8381
|
+
status: summary ? trialStatus(summary) : ownerGone ? "cancelled" : "running",
|
|
8183
8382
|
startedAt: manifest.startedAt,
|
|
8184
8383
|
...summary?.endedAt ? { completedAt: summary.endedAt } : {},
|
|
8185
8384
|
...summary?.durationMs !== void 0 ? { durationMs: summary.durationMs } : {},
|
|
@@ -8208,7 +8407,7 @@ function createDashboardReportReader(root, registry) {
|
|
|
8208
8407
|
const file = resolve(candidateRoot, relativePath);
|
|
8209
8408
|
if (file !== candidateRoot && !file.startsWith(`${candidateRoot}${sep}`)) throw new Error("Invalid workspace path");
|
|
8210
8409
|
if (!(await stat(file)).isFile()) throw new Error("Workspace path is not a file");
|
|
8211
|
-
return new Response(await
|
|
8410
|
+
return new Response(await readFile(file), { headers: { "content-type": fileContentType(file) } });
|
|
8212
8411
|
}
|
|
8213
8412
|
async function trajectory(runId, trialId) {
|
|
8214
8413
|
trialPath(root, runId, trialId);
|
|
@@ -8241,7 +8440,7 @@ async function walkArtifacts(root) {
|
|
|
8241
8440
|
} else result.push({
|
|
8242
8441
|
path,
|
|
8243
8442
|
kind: "file",
|
|
8244
|
-
size:
|
|
8443
|
+
size: (await stat(resolve(directory, entry.name))).size
|
|
8245
8444
|
});
|
|
8246
8445
|
}
|
|
8247
8446
|
}
|
|
@@ -8254,7 +8453,7 @@ async function walkArtifacts(root) {
|
|
|
8254
8453
|
const MARKER = ".evalkit-dashboard";
|
|
8255
8454
|
const quietStep = (_label, work) => work(() => {});
|
|
8256
8455
|
async function buildDashboard(options, step = quietStep) {
|
|
8257
|
-
const { registry, reportRoot, dashboardRoot, outDir } = options;
|
|
8456
|
+
const { registry, root, reportRoot, dashboardRoot, outDir } = options;
|
|
8258
8457
|
const template = await readFile(resolve(dashboardRoot, "index.html"), "utf8");
|
|
8259
8458
|
const data = resolve(outDir, "data");
|
|
8260
8459
|
const write = async (path, value) => {
|
|
@@ -8269,7 +8468,8 @@ async function buildDashboard(options, step = quietStep) {
|
|
|
8269
8468
|
await writeFile(resolve(outDir, "index.html"), dashboardIndex(template, { base: "./" }));
|
|
8270
8469
|
}, () => "read-only UI with relative assets");
|
|
8271
8470
|
await step("Eval catalog", async () => {
|
|
8272
|
-
await write("catalog.json", { evals: registry.catalog() });
|
|
8471
|
+
await write("catalog.json", { evals: registry.catalog({ root }) });
|
|
8472
|
+
await write("project.json", { project: options.source ? projectInfo(options.source) : null });
|
|
8273
8473
|
await write("evals.json", { evals: registry.metadata() });
|
|
8274
8474
|
await write("suites.json", { suites: registry.suiteMetadata() });
|
|
8275
8475
|
return {
|
|
@@ -8277,6 +8477,21 @@ async function buildDashboard(options, step = quietStep) {
|
|
|
8277
8477
|
suites: registry.suites.length
|
|
8278
8478
|
};
|
|
8279
8479
|
}, ({ evals, suites }) => `${count(evals, "eval")} · ${count(suites, "suite")}`);
|
|
8480
|
+
await step("Datasets", async () => {
|
|
8481
|
+
let files = 0;
|
|
8482
|
+
for (const [id, dataset] of projectDatasets(registry)) {
|
|
8483
|
+
const listing = await datasetListing(dataset, root ?? process.cwd());
|
|
8484
|
+
await write(`datasets/${id}/files.json`, listing);
|
|
8485
|
+
if (listing.kind !== "static" || dataset.visibility === "evaluator") continue;
|
|
8486
|
+
for (const entry of listing.files) {
|
|
8487
|
+
const file = resolve(data, "datasets", id, "files", entry.path);
|
|
8488
|
+
await mkdir(dirname(file), { recursive: true });
|
|
8489
|
+
await writeFile(file, await staticDatasetFile(dataset, root ?? process.cwd(), entry.path));
|
|
8490
|
+
files++;
|
|
8491
|
+
}
|
|
8492
|
+
}
|
|
8493
|
+
return files;
|
|
8494
|
+
}, (files) => count(files, "dataset file"));
|
|
8280
8495
|
const reports = createDashboardReportReader(reportRoot, registry);
|
|
8281
8496
|
const snapshot = await step("Run reports", async (progress) => {
|
|
8282
8497
|
const all = await reports.listRuns();
|
|
@@ -8284,13 +8499,16 @@ async function buildDashboard(options, step = quietStep) {
|
|
|
8284
8499
|
await write("runs.json", { runs });
|
|
8285
8500
|
let trials = 0;
|
|
8286
8501
|
let files = 0;
|
|
8287
|
-
const
|
|
8502
|
+
const runTrials = [];
|
|
8288
8503
|
for (const [index, run] of runs.entries()) {
|
|
8289
8504
|
progress(`run ${index + 1}/${runs.length} · ${run.id.slice(0, 8)}`);
|
|
8290
8505
|
const written = await snapshotRun(reports, run.id, directorySink(resolve(data, "runs", run.id)));
|
|
8291
8506
|
trials += written.trialIds.length;
|
|
8292
8507
|
files += written.files;
|
|
8293
|
-
|
|
8508
|
+
runTrials.push({
|
|
8509
|
+
id: run.id,
|
|
8510
|
+
trials: written.trialIds
|
|
8511
|
+
});
|
|
8294
8512
|
}
|
|
8295
8513
|
return {
|
|
8296
8514
|
runs: runs.length,
|
|
@@ -8298,16 +8516,16 @@ async function buildDashboard(options, step = quietStep) {
|
|
|
8298
8516
|
files,
|
|
8299
8517
|
skipped: reports.legacyRunIds().length,
|
|
8300
8518
|
archived: all.length - runs.length,
|
|
8301
|
-
|
|
8302
|
-
trialIds
|
|
8519
|
+
runTrials
|
|
8303
8520
|
};
|
|
8304
8521
|
}, ({ runs, trials, files, skipped, archived }) => `${count(runs, "run")} · ${count(trials, "trial")} · ${count(files, "candidate file")}` + (archived ? ` · ${archived} archived excluded` : "") + (skipped ? ` · ${skipped} older-format skipped` : ""));
|
|
8305
8522
|
await step("Route pages", async () => {
|
|
8523
|
+
const catalog = registry.catalog();
|
|
8306
8524
|
const routes = staticRoutes({
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8525
|
+
scenarios: catalog.map((entry) => entry.id),
|
|
8526
|
+
agents: catalog.flatMap((entry) => entry.agent.id ? [entry.agent.id] : []),
|
|
8527
|
+
datasets: catalog.flatMap((entry) => entry.datasets.map((dataset) => dataset.id)),
|
|
8528
|
+
runs: snapshot.runTrials
|
|
8311
8529
|
});
|
|
8312
8530
|
for (const route of routes) {
|
|
8313
8531
|
const file = resolve(outDir, `${route}.html`);
|
|
@@ -8317,7 +8535,7 @@ async function buildDashboard(options, step = quietStep) {
|
|
|
8317
8535
|
await writeFile(resolve(outDir, "404.html"), dashboardIndex(template, { base: "/" }));
|
|
8318
8536
|
return routes.length;
|
|
8319
8537
|
}, (pages) => `${count(pages, "page")} with clean /path URLs`);
|
|
8320
|
-
const {
|
|
8538
|
+
const { runTrials: _runs, ...summary } = snapshot;
|
|
8321
8539
|
return {
|
|
8322
8540
|
outDir,
|
|
8323
8541
|
...summary
|
|
@@ -8330,18 +8548,21 @@ async function buildDashboard(options, step = quietStep) {
|
|
|
8330
8548
|
* skipped; those pages still open by navigating inside the app.
|
|
8331
8549
|
*/
|
|
8332
8550
|
function staticRoutes(ids) {
|
|
8333
|
-
const safe = (id) => id.length > 0 && !/[/\\\0]/.test(id) && id !== "." && id !== "..";
|
|
8334
|
-
const under = (prefix, values) => values.filter(safe).map((id) => `${prefix}/${id}`);
|
|
8551
|
+
const safe = (id) => id.length > 0 && !/[/\\\0#?%]/.test(id) && id !== "." && id !== "..";
|
|
8552
|
+
const under = (prefix, values = []) => [...new Set(values)].filter(safe).map((id) => `${prefix}/${id}`);
|
|
8335
8553
|
return [
|
|
8336
|
-
"
|
|
8337
|
-
"agents",
|
|
8338
|
-
"fixtures",
|
|
8554
|
+
"scoreboard",
|
|
8339
8555
|
"runs",
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
|
|
8556
|
+
"trials",
|
|
8557
|
+
"workspaces",
|
|
8558
|
+
"library/agents",
|
|
8559
|
+
"library/judges",
|
|
8560
|
+
"library/scenarios",
|
|
8561
|
+
"library/datasets",
|
|
8562
|
+
...under("library/scenarios", ids.scenarios),
|
|
8563
|
+
...under("library/agents", ids.agents),
|
|
8564
|
+
...under("library/datasets", ids.datasets),
|
|
8565
|
+
...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`])])
|
|
8345
8566
|
];
|
|
8346
8567
|
}
|
|
8347
8568
|
/** `runs/abc.html` is served at `<mount>/runs/abc`; its base is one level up. */
|
|
@@ -8516,6 +8737,124 @@ function selectDashboardCell(matrix, evalId, parameters) {
|
|
|
8516
8737
|
return selection;
|
|
8517
8738
|
}
|
|
8518
8739
|
//#endregion
|
|
8740
|
+
//#region ../cli/src/runtime.ts
|
|
8741
|
+
/**
|
|
8742
|
+
* The few platform services the CLI needs, on Bun or Node. Everything else
|
|
8743
|
+
* uses `node:` APIs, which both runtimes provide.
|
|
8744
|
+
*/
|
|
8745
|
+
const onBun = typeof globalThis.Bun !== "undefined";
|
|
8746
|
+
/** Node 22.18 is the first 22.x that runs the CLI's own code unflagged. */
|
|
8747
|
+
function assertSupportedRuntime(version = process.versions.node) {
|
|
8748
|
+
if (onBun) return;
|
|
8749
|
+
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
8750
|
+
if (major < 22 || major === 22 && minor < 18) throw new Error(`EvalKit needs Node 22.18 or later, or Bun; this is Node ${version}.`);
|
|
8751
|
+
}
|
|
8752
|
+
/** Serves a fetch handler on `port` (0 picks a free one). */
|
|
8753
|
+
async function serve(fetch, port) {
|
|
8754
|
+
if (onBun) {
|
|
8755
|
+
const server = globalThis.Bun.serve({
|
|
8756
|
+
port,
|
|
8757
|
+
fetch
|
|
8758
|
+
});
|
|
8759
|
+
return {
|
|
8760
|
+
port: server.port,
|
|
8761
|
+
stop: async () => server.stop(true)
|
|
8762
|
+
};
|
|
8763
|
+
}
|
|
8764
|
+
const server = createServer((incoming, outgoing) => {
|
|
8765
|
+
(async () => {
|
|
8766
|
+
try {
|
|
8767
|
+
const response = await fetch(toRequest(incoming, port));
|
|
8768
|
+
outgoing.writeHead(response.status, [...response.headers]);
|
|
8769
|
+
if (response.body) for await (const chunk of response.body) outgoing.write(chunk);
|
|
8770
|
+
outgoing.end();
|
|
8771
|
+
} catch (error) {
|
|
8772
|
+
outgoing.statusCode = 500;
|
|
8773
|
+
outgoing.end(error instanceof Error ? error.message : "Internal error");
|
|
8774
|
+
}
|
|
8775
|
+
})();
|
|
8776
|
+
});
|
|
8777
|
+
await new Promise((resolvePromise, reject) => {
|
|
8778
|
+
server.once("error", reject);
|
|
8779
|
+
server.listen(port, () => resolvePromise());
|
|
8780
|
+
});
|
|
8781
|
+
const address = server.address();
|
|
8782
|
+
return {
|
|
8783
|
+
port: typeof address === "object" && address ? address.port : port,
|
|
8784
|
+
stop: () => new Promise((resolvePromise) => server.close(() => resolvePromise()))
|
|
8785
|
+
};
|
|
8786
|
+
}
|
|
8787
|
+
function toRequest(incoming, port) {
|
|
8788
|
+
const host = incoming.headers.host ?? `localhost:${port}`;
|
|
8789
|
+
const url = new URL(incoming.url ?? "/", `http://${host}`);
|
|
8790
|
+
const headers = new Headers();
|
|
8791
|
+
for (const [name, value] of Object.entries(incoming.headers)) if (value !== void 0) for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item);
|
|
8792
|
+
const method = incoming.method ?? "GET";
|
|
8793
|
+
return new Request(url, {
|
|
8794
|
+
method,
|
|
8795
|
+
headers,
|
|
8796
|
+
...method === "GET" || method === "HEAD" ? {} : {
|
|
8797
|
+
body: incoming,
|
|
8798
|
+
duplex: "half"
|
|
8799
|
+
}
|
|
8800
|
+
});
|
|
8801
|
+
}
|
|
8802
|
+
const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
8803
|
+
/** Best effort; the printed URL stays authoritative. */
|
|
8804
|
+
function openInBrowser(url) {
|
|
8805
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
8806
|
+
try {
|
|
8807
|
+
const child = spawn(opener, [url], {
|
|
8808
|
+
stdio: "ignore",
|
|
8809
|
+
detached: true
|
|
8810
|
+
});
|
|
8811
|
+
child.on("error", () => {});
|
|
8812
|
+
child.unref();
|
|
8813
|
+
} catch {}
|
|
8814
|
+
}
|
|
8815
|
+
let jiti;
|
|
8816
|
+
/**
|
|
8817
|
+
* Imports a project's TypeScript or JavaScript module. Bun loads TypeScript
|
|
8818
|
+
* natively; on Node, jiti handles TypeScript, including `.js` specifiers that
|
|
8819
|
+
* point at `.ts` files.
|
|
8820
|
+
*/
|
|
8821
|
+
async function importProjectModule(file) {
|
|
8822
|
+
if (onBun || /\.(m?js|cjs)$/.test(file)) return import(pathToFileURL(file).href);
|
|
8823
|
+
if (!jiti) {
|
|
8824
|
+
const { createJiti } = await import("jiti");
|
|
8825
|
+
jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
8826
|
+
}
|
|
8827
|
+
return jiti.import(file);
|
|
8828
|
+
}
|
|
8829
|
+
/**
|
|
8830
|
+
* A glob matcher for discovery: `*` and `?` stay within a path segment, `**`
|
|
8831
|
+
* spans segments, and `{a,b}` is an alternation.
|
|
8832
|
+
*/
|
|
8833
|
+
function globMatcher(pattern) {
|
|
8834
|
+
let source = "";
|
|
8835
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
8836
|
+
const char = pattern[index];
|
|
8837
|
+
if (char === "*" && pattern[index + 1] === "*") {
|
|
8838
|
+
index++;
|
|
8839
|
+
if (pattern[index + 1] === "/") {
|
|
8840
|
+
index++;
|
|
8841
|
+
source += "(?:.*/)?";
|
|
8842
|
+
} else source += ".*";
|
|
8843
|
+
} else if (char === "*") source += "[^/]*";
|
|
8844
|
+
else if (char === "?") source += "[^/]";
|
|
8845
|
+
else if (char === "{") {
|
|
8846
|
+
const end = pattern.indexOf("}", index);
|
|
8847
|
+
if (end === -1) source += "\\{";
|
|
8848
|
+
else {
|
|
8849
|
+
source += `(?:${pattern.slice(index + 1, end).split(",").map((part) => part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|")})`;
|
|
8850
|
+
index = end;
|
|
8851
|
+
}
|
|
8852
|
+
} else source += char.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
8853
|
+
}
|
|
8854
|
+
const expression = new RegExp(`^${source}$`);
|
|
8855
|
+
return (path) => expression.test(path);
|
|
8856
|
+
}
|
|
8857
|
+
//#endregion
|
|
8519
8858
|
//#region ../cli/src/project.ts
|
|
8520
8859
|
const defaultInclude = ["**/*.eval.ts", "**/*.eval.js"];
|
|
8521
8860
|
const defaultExclude = [
|
|
@@ -8538,10 +8877,16 @@ function evaluation(value, source) {
|
|
|
8538
8877
|
if (typeof candidate.agent?.start !== "function" || !Array.isArray(candidate.transcript) || !Array.isArray(candidate.scoring)) throw new Error(`Invalid eval exported by ${source}`);
|
|
8539
8878
|
return candidate;
|
|
8540
8879
|
}
|
|
8880
|
+
/** The suite a discovered file belongs to: the first directory below the discovery root. */
|
|
8881
|
+
function directorySuite(relativePath) {
|
|
8882
|
+
const parts = relativePath.split("/");
|
|
8883
|
+
return parts.length > 1 ? parts[0] : void 0;
|
|
8884
|
+
}
|
|
8541
8885
|
async function discover(root, config) {
|
|
8542
|
-
const
|
|
8543
|
-
const
|
|
8544
|
-
const
|
|
8886
|
+
const testDir = config.testDir ?? "scenarios";
|
|
8887
|
+
const directory = resolve(root, testDir);
|
|
8888
|
+
const includes = (config.include ?? defaultInclude).map((pattern) => ({ match: globMatcher(pattern) }));
|
|
8889
|
+
const excludes = [...defaultExclude, ...config.exclude ?? []].map((pattern) => ({ match: globMatcher(pattern) }));
|
|
8545
8890
|
const paths = [];
|
|
8546
8891
|
async function walk(dir) {
|
|
8547
8892
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -8557,16 +8902,42 @@ async function discover(root, config) {
|
|
|
8557
8902
|
try {
|
|
8558
8903
|
await walk(directory);
|
|
8559
8904
|
} catch (error) {
|
|
8560
|
-
if (error.code
|
|
8561
|
-
|
|
8905
|
+
if (error.code !== "ENOENT") throw error;
|
|
8906
|
+
const hint = config.testDir === void 0 && await isDirectory(resolve(root, "evals")) ? ` Scenarios now live in scenarios/: move evals/ there, or set testDir: 'evals' in evalkit.config.ts.` : "";
|
|
8907
|
+
throw new Error(`Eval directory not found: ${directory}.${hint}`);
|
|
8562
8908
|
}
|
|
8563
|
-
const
|
|
8909
|
+
const directorySuites = config.discovery?.suites ?? true;
|
|
8910
|
+
const standalone = [];
|
|
8911
|
+
const bySuite = /* @__PURE__ */ new Map();
|
|
8912
|
+
const loadErrors = [];
|
|
8564
8913
|
for (const file of paths.sort()) {
|
|
8565
|
-
const
|
|
8566
|
-
|
|
8914
|
+
const fromRoot = relative(root, file).split(sep).join("/");
|
|
8915
|
+
const suiteId = directorySuites ? directorySuite(relative(directory, file).split(sep).join("/")) : void 0;
|
|
8916
|
+
try {
|
|
8917
|
+
if (suiteId !== void 0 && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(suiteId)) throw new Error(`Directory ${testDir}/${suiteId} names a suite, so it must be lowercase kebab-case (or set discovery.suites: false)`);
|
|
8918
|
+
const exported = await (await importProjectModule(file)).default;
|
|
8919
|
+
const evals = (Array.isArray(exported) ? exported : [exported]).map((value) => evaluation(value, fromRoot));
|
|
8920
|
+
if (suiteId === void 0) standalone.push(...evals);
|
|
8921
|
+
else bySuite.set(suiteId, [...bySuite.get(suiteId) ?? [], ...evals]);
|
|
8922
|
+
} catch (error) {
|
|
8923
|
+
loadErrors.push({
|
|
8924
|
+
file: fromRoot,
|
|
8925
|
+
...suiteId === void 0 ? {} : { suiteId },
|
|
8926
|
+
error: recordError(error)
|
|
8927
|
+
});
|
|
8928
|
+
}
|
|
8567
8929
|
}
|
|
8568
|
-
if (!
|
|
8569
|
-
return
|
|
8930
|
+
if (!paths.length) throw new Error(`No eval files found in ${directory}`);
|
|
8931
|
+
return {
|
|
8932
|
+
registrations: [...standalone, ...[...bySuite].map(([id, evals]) => defineSuite({
|
|
8933
|
+
id,
|
|
8934
|
+
evals
|
|
8935
|
+
}))],
|
|
8936
|
+
loadErrors
|
|
8937
|
+
};
|
|
8938
|
+
}
|
|
8939
|
+
async function isDirectory(path) {
|
|
8940
|
+
return stat(path).then((info) => info.isDirectory(), () => false);
|
|
8570
8941
|
}
|
|
8571
8942
|
async function loadProject(cwd, configPath) {
|
|
8572
8943
|
const candidates = configPath ? [resolve(cwd, configPath)] : [
|
|
@@ -8580,12 +8951,18 @@ async function loadProject(cwd, configPath) {
|
|
|
8580
8951
|
if (present.length > 1) throw new Error("Multiple EvalKit configs found; select one with --config");
|
|
8581
8952
|
const file = present[0];
|
|
8582
8953
|
const root = file ? dirname(file) : resolve(cwd);
|
|
8583
|
-
const config = file ? (await
|
|
8954
|
+
const config = file ? (await importProjectModule(file)).default : {};
|
|
8584
8955
|
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Config must default-export defineConfig({...})");
|
|
8585
8956
|
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`);
|
|
8586
8957
|
let registry = config.registry;
|
|
8587
|
-
if (!file && !configPath && await exists$1(resolve(root, "src/registry.ts"))) registry = (await
|
|
8588
|
-
|
|
8958
|
+
if (!file && !configPath && await exists$1(resolve(root, "src/registry.ts"))) registry = (await importProjectModule(resolve(root, "src/registry.ts"))).default;
|
|
8959
|
+
let loadErrors = [];
|
|
8960
|
+
if (!registry && config.evals) registry = registerEvals(config.evals);
|
|
8961
|
+
if (!registry) {
|
|
8962
|
+
const discovered = await discover(root, config);
|
|
8963
|
+
loadErrors = discovered.loadErrors;
|
|
8964
|
+
registry = registerEvals(discovered.registrations);
|
|
8965
|
+
}
|
|
8589
8966
|
if (!registry?.evals || !registry.get) throw new Error("Invalid explicit eval registry");
|
|
8590
8967
|
const ids = /* @__PURE__ */ new Set();
|
|
8591
8968
|
for (const e of registry.evals) {
|
|
@@ -8608,7 +8985,8 @@ async function loadProject(cwd, configPath) {
|
|
|
8608
8985
|
return {
|
|
8609
8986
|
root,
|
|
8610
8987
|
config,
|
|
8611
|
-
registry
|
|
8988
|
+
registry,
|
|
8989
|
+
loadErrors
|
|
8612
8990
|
};
|
|
8613
8991
|
}
|
|
8614
8992
|
//#endregion
|
|
@@ -8717,126 +9095,207 @@ async function confirm(question) {
|
|
|
8717
9095
|
}
|
|
8718
9096
|
}
|
|
8719
9097
|
//#endregion
|
|
8720
|
-
//#region ../cli/src/
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
if (
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
return `
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
9098
|
+
//#region ../cli/src/run-reporter.ts
|
|
9099
|
+
function scenarioOutcome(result) {
|
|
9100
|
+
if (result.status === "cancelled") return "cancelled";
|
|
9101
|
+
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";
|
|
9102
|
+
}
|
|
9103
|
+
function passAtFor(definition, name) {
|
|
9104
|
+
const rule = [...definition.transcript, ...definition.scoring].find((step) => step.kind === "predicate" && step.name === name);
|
|
9105
|
+
return rule?.kind === "predicate" ? rule.passAt : void 0;
|
|
9106
|
+
}
|
|
9107
|
+
function value(score) {
|
|
9108
|
+
return score.value === void 0 ? "" : score.value.toFixed(1);
|
|
9109
|
+
}
|
|
9110
|
+
function scoreReason(definition, score) {
|
|
9111
|
+
if (score.error) return `${score.name} error: ${score.error.message}`;
|
|
9112
|
+
const passAt = passAtFor(definition, score.name);
|
|
9113
|
+
return `${score.name} ${value(score)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
|
|
9114
|
+
}
|
|
9115
|
+
function checkpointReason(definition, checkpoint) {
|
|
9116
|
+
if (checkpoint.status === "error") return `step ${checkpoint.step} ${checkpoint.name} error: ${checkpoint.error?.message ?? ""}`;
|
|
9117
|
+
const passAt = passAtFor(definition, checkpoint.name);
|
|
9118
|
+
return `step ${checkpoint.step} ${checkpoint.name} ${checkpoint.value === void 0 ? checkpoint.status : value(checkpoint)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
|
|
9119
|
+
}
|
|
9120
|
+
/** What went wrong in a scenario, deduplicated across its trials. */
|
|
9121
|
+
function reasonsFor(definition, trials) {
|
|
9122
|
+
const reasons = /* @__PURE__ */ new Set();
|
|
9123
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
9124
|
+
for (const trial of trials) {
|
|
9125
|
+
if (trial.status === "failed" && trial.error) reasons.add(`error: ${trial.error.message}`);
|
|
9126
|
+
for (const checkpoint of trial.scoring?.checkpoints ?? []) if (checkpoint.status === "failed" || checkpoint.status === "error") reasons.add(checkpointReason(definition, checkpoint));
|
|
9127
|
+
for (const score of trial.scoring?.results ?? []) if (score.skipped) skipped.add(score.name);
|
|
9128
|
+
else if (!score.passed) reasons.add(scoreReason(definition, score));
|
|
9129
|
+
for (const name of trial.scoring?.skippedScorers ?? []) if (trial.status !== "cancelled") reasons.add(`${name} not run`);
|
|
8750
9130
|
}
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
function ciSource(env, projectRoot) {
|
|
8755
|
-
if (env.EVALKIT_SOURCE_REPO && env.EVALKIT_SOURCE_COMMIT) return {
|
|
8756
|
-
repo: normalizeRepoUrl(env.EVALKIT_SOURCE_REPO),
|
|
8757
|
-
project: env.EVALKIT_SOURCE_PROJECT ?? "",
|
|
8758
|
-
commit: env.EVALKIT_SOURCE_COMMIT,
|
|
8759
|
-
...env.EVALKIT_SOURCE_REF ? { ref: env.EVALKIT_SOURCE_REF } : {},
|
|
8760
|
-
...env.EVALKIT_SOURCE_COMMITTED_AT ? { committedAt: env.EVALKIT_SOURCE_COMMITTED_AT } : {}
|
|
8761
|
-
};
|
|
8762
|
-
if (env.GITLAB_CI === "true" && env.CI_PROJECT_URL && env.CI_COMMIT_SHA) return {
|
|
8763
|
-
repo: normalizeRepoUrl(env.CI_PROJECT_URL),
|
|
8764
|
-
project: projectPath(env.CI_PROJECT_DIR, projectRoot),
|
|
8765
|
-
commit: env.CI_COMMIT_SHA,
|
|
8766
|
-
...env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME ? { ref: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? env.CI_COMMIT_REF_NAME } : {},
|
|
8767
|
-
...env.CI_COMMIT_TIMESTAMP ? { committedAt: env.CI_COMMIT_TIMESTAMP } : {},
|
|
8768
|
-
ci: {
|
|
8769
|
-
provider: "gitlab",
|
|
8770
|
-
...env.CI_PIPELINE_ID ? { pipeline: env.CI_PIPELINE_ID } : {},
|
|
8771
|
-
...env.CI_JOB_ID ? { job: env.CI_JOB_ID } : {},
|
|
8772
|
-
...env.CI_JOB_URL ? { url: env.CI_JOB_URL } : {}
|
|
8773
|
-
}
|
|
9131
|
+
return {
|
|
9132
|
+
reasons: [...reasons],
|
|
9133
|
+
skipped: [...skipped]
|
|
8774
9134
|
};
|
|
8775
|
-
if (env.GITHUB_ACTIONS === "true" && env.GITHUB_REPOSITORY && env.GITHUB_SHA) {
|
|
8776
|
-
const server = env.GITHUB_SERVER_URL ?? "https://github.com";
|
|
8777
|
-
return {
|
|
8778
|
-
repo: normalizeRepoUrl(`${server}/${env.GITHUB_REPOSITORY}`),
|
|
8779
|
-
project: projectPath(env.GITHUB_WORKSPACE, projectRoot),
|
|
8780
|
-
commit: env.GITHUB_SHA,
|
|
8781
|
-
...env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME ? { ref: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME } : {},
|
|
8782
|
-
ci: {
|
|
8783
|
-
provider: "github",
|
|
8784
|
-
...env.GITHUB_RUN_ID ? { pipeline: env.GITHUB_RUN_ID } : {},
|
|
8785
|
-
...env.GITHUB_JOB ? { job: env.GITHUB_JOB } : {},
|
|
8786
|
-
...env.GITHUB_RUN_ID ? { url: `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` } : {}
|
|
8787
|
-
}
|
|
8788
|
-
};
|
|
8789
|
-
}
|
|
8790
9135
|
}
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
|
|
8794
|
-
* Returns undefined outside git with no CI variables ("untracked" results).
|
|
8795
|
-
*
|
|
8796
|
-
* `outputDirs` (results, sandbox, snapshot) never make the tree dirty, even
|
|
8797
|
-
* when a project forgot to ignore them.
|
|
8798
|
-
*/
|
|
8799
|
-
async function detectSource(givenRoot, options = {}) {
|
|
8800
|
-
const env = options.env ?? process.env;
|
|
8801
|
-
const projectRoot = await realpath(givenRoot).catch(() => givenRoot);
|
|
8802
|
-
const top = await git(projectRoot, "rev-parse", "--show-toplevel");
|
|
8803
|
-
const excludes = top ? (options.outputDirs ?? []).map((dir) => join(projectRoot, relative(givenRoot, dir))).map((dir) => posix(relative(top, dir))).filter((dir) => dir && !dir.startsWith("..")).map((dir) => `:(exclude)${dir}`) : [];
|
|
8804
|
-
const dirty = top ? Boolean(await git(top, "status", "--porcelain", "--", ".", ...excludes)) : false;
|
|
8805
|
-
const fromCi = ciSource(env, projectRoot);
|
|
8806
|
-
if (fromCi) return {
|
|
8807
|
-
...fromCi,
|
|
8808
|
-
...top && !env.EVALKIT_SOURCE_PROJECT ? { project: projectPath(top, projectRoot) } : {},
|
|
8809
|
-
dirty
|
|
8810
|
-
};
|
|
8811
|
-
if (!top) return void 0;
|
|
8812
|
-
const commit = await git(top, "rev-parse", "HEAD");
|
|
8813
|
-
if (!commit) return void 0;
|
|
8814
|
-
const remote = await git(top, "config", "--get", "remote.origin.url") ?? (await git(top, "remote"))?.split("\n").map((name) => name.trim()).find(Boolean);
|
|
8815
|
-
const remoteUrl = remote && !remote.includes("/") && !remote.includes(":") ? await git(top, "config", "--get", `remote.${remote}.url`) : remote;
|
|
8816
|
-
const ref = await git(top, "symbolic-ref", "--short", "-q", "HEAD");
|
|
8817
|
-
const committedAt = await git(top, "show", "-s", "--format=%cI", "HEAD");
|
|
9136
|
+
function scenarioResult(cell, result, suiteId) {
|
|
9137
|
+
const evalId = authoringId(cell.eval);
|
|
9138
|
+
const trials = result.trials ?? [result];
|
|
8818
9139
|
return {
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
|
|
9140
|
+
label: suiteId ? `${suiteId}/${evalId}` : evalId,
|
|
9141
|
+
evalId,
|
|
9142
|
+
...suiteId ? { suiteId } : {},
|
|
9143
|
+
parameters: cell.parameters,
|
|
9144
|
+
outcome: scenarioOutcome(result),
|
|
9145
|
+
...result.durationMs !== void 0 ? { durationMs: result.durationMs } : {},
|
|
9146
|
+
result,
|
|
9147
|
+
...reasonsFor(cell.eval, trials)
|
|
8825
9148
|
};
|
|
8826
9149
|
}
|
|
8827
|
-
|
|
8828
|
-
|
|
9150
|
+
function parameterLabel(parameters) {
|
|
9151
|
+
return Object.entries(parameters).map(([key, item]) => `${key}=${typeof item === "string" ? item : JSON.stringify(item)}`).join(" ");
|
|
9152
|
+
}
|
|
9153
|
+
/** One printed line for a finished scenario; failing reasons on the same line. */
|
|
9154
|
+
function scenarioLine(scenario) {
|
|
9155
|
+
const mark = scenario.outcome === "passed" ? green("✓") : scenario.outcome === "cancelled" ? yellow("◌") : red("✗");
|
|
9156
|
+
const parameters = parameterLabel(scenario.parameters);
|
|
9157
|
+
const trials = scenario.result.trials;
|
|
9158
|
+
const trialCount = trials && trials.length > 1 ? dim(` ${trials.filter((trial) => trial.status === "completed" && trial.scoring?.passed).length}/${trials.length} trials`) : "";
|
|
9159
|
+
const details = [
|
|
9160
|
+
...scenario.outcome === "failed" ? scenario.reasons : [],
|
|
9161
|
+
...scenario.outcome === "cancelled" ? ["cancelled"] : [],
|
|
9162
|
+
...scenario.skipped.map((name) => `${name} skipped`)
|
|
9163
|
+
];
|
|
9164
|
+
return ` ${mark} ${bold(scenario.label)}${parameters ? ` ${dim(parameters)}` : ""}${trialCount}${details.length ? ` ${dim(details.join(" · "))}` : ""}`;
|
|
9165
|
+
}
|
|
9166
|
+
/**
|
|
9167
|
+
* Prints run progress. On a terminal, failures print as they happen and a live
|
|
9168
|
+
* line shows counts and what is running. Elsewhere (CI), every finished
|
|
9169
|
+
* scenario prints one plain line.
|
|
9170
|
+
*/
|
|
9171
|
+
function createRunReporter(options) {
|
|
9172
|
+
const write = options.write ?? ((text) => process.stdout.write(text));
|
|
9173
|
+
const now = options.now ?? (() => performance.now());
|
|
9174
|
+
const running = /* @__PURE__ */ new Map();
|
|
9175
|
+
const scenarios = [];
|
|
9176
|
+
let liveShown = false;
|
|
9177
|
+
let timer;
|
|
9178
|
+
function clearLive() {
|
|
9179
|
+
if (liveShown) write("\r\x1B[2K");
|
|
9180
|
+
liveShown = false;
|
|
9181
|
+
}
|
|
9182
|
+
function liveLine() {
|
|
9183
|
+
const failed = scenarios.filter((s) => s.outcome === "failed").length;
|
|
9184
|
+
const active = [...running.values()].slice(0, 2).map((activity) => {
|
|
9185
|
+
const seconds = Math.round((now() - activity.startedAt) / 1e3);
|
|
9186
|
+
return `${activity.label} (${activity.doing ? `${activity.doing}, ` : ""}${seconds}s)`;
|
|
9187
|
+
});
|
|
9188
|
+
const more = running.size > 2 ? ", …" : "";
|
|
9189
|
+
return ` ${cyan("◇")} ${scenarios.length}/${options.total}${failed ? ` · ${red(`${failed} failed`)}` : ""}${active.length ? dim(` · running: ${active.join(", ")}${more}`) : ""}`;
|
|
9190
|
+
}
|
|
9191
|
+
function drawLive() {
|
|
9192
|
+
if (!options.interactive) return;
|
|
9193
|
+
const line = liveLine();
|
|
9194
|
+
const width = process.stdout.columns ?? 120;
|
|
9195
|
+
write(`\r\x1b[2K${line.length > width + 40 ? line.slice(0, width + 40) : line}`);
|
|
9196
|
+
liveShown = true;
|
|
9197
|
+
}
|
|
9198
|
+
function print(line) {
|
|
9199
|
+
clearLive();
|
|
9200
|
+
write(`${line}\n`);
|
|
9201
|
+
drawLive();
|
|
9202
|
+
}
|
|
8829
9203
|
return {
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
9204
|
+
start() {
|
|
9205
|
+
if (options.interactive) {
|
|
9206
|
+
timer = setInterval(drawLive, 100);
|
|
9207
|
+
timer.unref?.();
|
|
9208
|
+
}
|
|
9209
|
+
},
|
|
9210
|
+
onEvent(trial, event) {
|
|
9211
|
+
if (event.source === "runner" && event.kind === "trial-started") {
|
|
9212
|
+
running.set(trial.trialId, {
|
|
9213
|
+
label: options.labelFor(trial),
|
|
9214
|
+
startedAt: now()
|
|
9215
|
+
});
|
|
9216
|
+
return;
|
|
9217
|
+
}
|
|
9218
|
+
const activity = running.get(trial.trialId);
|
|
9219
|
+
if (!activity) return;
|
|
9220
|
+
if (event.source === "runner" && (event.kind === "trial-completed" || event.kind === "error")) running.delete(trial.trialId);
|
|
9221
|
+
else if (event.source === "aut" && event.kind === "tool-call") activity.doing = `tool ${event.name}`;
|
|
9222
|
+
else if (event.source === "runner" && event.kind === "scorer-started") activity.doing = `scoring ${event.scorer}`;
|
|
9223
|
+
else if (event.source === "runner" && event.kind === "checkpoint-started") activity.doing = `check ${event.name}`;
|
|
9224
|
+
},
|
|
9225
|
+
onScenario(scenario) {
|
|
9226
|
+
scenarios.push(scenario);
|
|
9227
|
+
for (const trial of scenario.result.trials ?? [scenario.result]) running.delete(trial.trialId);
|
|
9228
|
+
if (!options.interactive || options.verbose || scenario.outcome !== "passed") print(scenarioLine(scenario));
|
|
9229
|
+
},
|
|
9230
|
+
/** Called on the first Ctrl+C. */
|
|
9231
|
+
cancelling() {
|
|
9232
|
+
print(`\n ${yellow("◌")} Cancelling: finishing running trials. Press Ctrl+C again to exit now.`);
|
|
9233
|
+
},
|
|
9234
|
+
finish(durationMs) {
|
|
9235
|
+
if (timer) clearInterval(timer);
|
|
9236
|
+
clearLive();
|
|
9237
|
+
return {
|
|
9238
|
+
passed: scenarios.filter((s) => s.outcome === "passed").length,
|
|
9239
|
+
failed: scenarios.filter((s) => s.outcome === "failed").length,
|
|
9240
|
+
cancelled: scenarios.filter((s) => s.outcome === "cancelled").length,
|
|
9241
|
+
scenarios: scenarios.length,
|
|
9242
|
+
durationMs
|
|
9243
|
+
};
|
|
9244
|
+
},
|
|
9245
|
+
scenarios: () => scenarios
|
|
8833
9246
|
};
|
|
8834
9247
|
}
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
9248
|
+
function totalsLine(totals) {
|
|
9249
|
+
const parts = [
|
|
9250
|
+
green(`${totals.passed} passed`),
|
|
9251
|
+
totals.failed ? red(`${totals.failed} failed`) : "",
|
|
9252
|
+
totals.cancelled ? yellow(`${totals.cancelled} cancelled`) : ""
|
|
9253
|
+
].filter(Boolean);
|
|
9254
|
+
return ` ${bold(parts.join(" · "))} ${dim(`· ${plural(totals.scenarios, "scenario")} in ${duration(totals.durationMs)}`)}`;
|
|
8838
9255
|
}
|
|
8839
9256
|
//#endregion
|
|
9257
|
+
//#region ../cli/src/junit.ts
|
|
9258
|
+
function escape(text) {
|
|
9259
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
|
|
9260
|
+
}
|
|
9261
|
+
const seconds = (ms) => ((ms ?? 0) / 1e3).toFixed(3);
|
|
9262
|
+
/**
|
|
9263
|
+
* A JUnit XML report: one test suite per EvalKit suite (`default` for
|
|
9264
|
+
* scenarios without one) and one test case per scenario, per matrix cell
|
|
9265
|
+
* when it has parameters.
|
|
9266
|
+
*/
|
|
9267
|
+
function junitReport(scenarios) {
|
|
9268
|
+
const bySuite = /* @__PURE__ */ new Map();
|
|
9269
|
+
for (const scenario of scenarios) {
|
|
9270
|
+
const suite = scenario.suiteId ?? "default";
|
|
9271
|
+
bySuite.set(suite, [...bySuite.get(suite) ?? [], scenario]);
|
|
9272
|
+
}
|
|
9273
|
+
const suites = [...bySuite].map(([suite, cases]) => {
|
|
9274
|
+
const failures = cases.filter((c) => c.outcome === "failed").length;
|
|
9275
|
+
const skipped = cases.filter((c) => c.outcome === "cancelled" || c.outcome === "passed" && c.skipped.length > 0 && allSkipped(c)).length;
|
|
9276
|
+
const time = cases.reduce((sum, c) => sum + (c.durationMs ?? 0), 0);
|
|
9277
|
+
const testcases = cases.map((scenario) => {
|
|
9278
|
+
const parameters = parameterLabel(scenario.parameters);
|
|
9279
|
+
const attributes = `name="${escape(parameters ? `${scenario.evalId} [${parameters}]` : scenario.evalId)}" classname="${escape(suite)}" time="${seconds(scenario.durationMs)}"`;
|
|
9280
|
+
if (scenario.outcome === "failed") return ` <testcase ${attributes}>\n <failure message="${escape(scenario.reasons.join("; ") || "failed")}">${escape(scenario.reasons.join("\n"))}</failure>\n </testcase>`;
|
|
9281
|
+
if (scenario.outcome === "cancelled") return ` <testcase ${attributes}>\n <skipped message="cancelled"/>\n </testcase>`;
|
|
9282
|
+
if (allSkipped(scenario)) return ` <testcase ${attributes}>\n <skipped message="${escape(`all scorers skipped: ${scenario.skipped.join(", ")}`)}"/>\n </testcase>`;
|
|
9283
|
+
return ` <testcase ${attributes}/>`;
|
|
9284
|
+
});
|
|
9285
|
+
return ` <testsuite name="${escape(suite)}" tests="${cases.length}" failures="${failures}" errors="0" skipped="${skipped}" time="${seconds(time)}">\n${testcases.join("\n")}\n </testsuite>`;
|
|
9286
|
+
});
|
|
9287
|
+
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`;
|
|
9288
|
+
}
|
|
9289
|
+
/** True when every score of every trial was skip()ped. */
|
|
9290
|
+
function allSkipped(scenario) {
|
|
9291
|
+
const results = (scenario.result.trials ?? [scenario.result]).flatMap((trial) => trial.scoring?.results ?? []);
|
|
9292
|
+
return results.length > 0 && results.every((score) => score.skipped);
|
|
9293
|
+
}
|
|
9294
|
+
//#endregion
|
|
9295
|
+
//#region ../cli/src/version.ts
|
|
9296
|
+
/** The CLI version; kept in sync with package.json when releasing. */
|
|
9297
|
+
const VERSION = "0.6.1";
|
|
9298
|
+
//#endregion
|
|
8840
9299
|
//#region ../cli/src/run-command.ts
|
|
8841
9300
|
function parseRunArgs(args) {
|
|
8842
9301
|
const { values, positionals } = parseArgs({
|
|
@@ -8875,7 +9334,10 @@ function parseRunArgs(args) {
|
|
|
8875
9334
|
json: { type: "boolean" },
|
|
8876
9335
|
"dry-run": { type: "boolean" },
|
|
8877
9336
|
all: { type: "boolean" },
|
|
8878
|
-
local: { type: "boolean" }
|
|
9337
|
+
local: { type: "boolean" },
|
|
9338
|
+
junit: { type: "string" },
|
|
9339
|
+
verbose: { type: "boolean" },
|
|
9340
|
+
"skip-broken": { type: "boolean" }
|
|
8879
9341
|
}
|
|
8880
9342
|
});
|
|
8881
9343
|
return normalizeRunOptions(values, positionals);
|
|
@@ -8928,21 +9390,37 @@ function normalizeRunOptions(values, positionals) {
|
|
|
8928
9390
|
trials: integer("trials")
|
|
8929
9391
|
};
|
|
8930
9392
|
}
|
|
9393
|
+
/** Load errors that affect what was asked for, so the run must not pretend it covered them. */
|
|
9394
|
+
function brokenSelection(loadErrors, request) {
|
|
9395
|
+
if (!loadErrors.length) return [];
|
|
9396
|
+
if (request.suiteId) return loadErrors.filter((error) => error.suiteId === request.suiteId);
|
|
9397
|
+
if (request.evalIds.length) return request.evalIds.some((id) => !request.registry.get(id)) ? loadErrors : [];
|
|
9398
|
+
return loadErrors;
|
|
9399
|
+
}
|
|
9400
|
+
/** Load errors are warnings; `print` is stderr when stdout must stay machine-readable. */
|
|
9401
|
+
function printLoadErrors(loadErrors, print = console.log) {
|
|
9402
|
+
for (const { file, error } of loadErrors) print(` ${yellow("✗")} ${bold(file)} failed to load:\n ${dim(error.message)}`);
|
|
9403
|
+
if (loadErrors.length) print("");
|
|
9404
|
+
}
|
|
8931
9405
|
async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArgs(args)) {
|
|
8932
9406
|
const { values, positionals, parameters } = parsed;
|
|
9407
|
+
const started = performance.now();
|
|
8933
9408
|
const project = await loadProject(cwd, values.config);
|
|
8934
9409
|
process.chdir(project.root);
|
|
8935
9410
|
const registry = project.registry;
|
|
8936
9411
|
const requestedEvals = values.eval?.flatMap((value) => value.split(",")) ?? [];
|
|
8937
9412
|
let matrix;
|
|
8938
9413
|
let suiteId;
|
|
9414
|
+
const quiet = Boolean(values.json || values["dry-run"]);
|
|
9415
|
+
if (!quiet) banner("run", VERSION);
|
|
8939
9416
|
if (values.suite) {
|
|
8940
9417
|
const suite = registry.suites.find((item) => item.id === values.suite);
|
|
8941
|
-
|
|
8942
|
-
|
|
9418
|
+
const broken = project.loadErrors.filter((error) => error.suiteId === values.suite);
|
|
9419
|
+
if (!suite && !broken.length) throw new Error(`Unknown suite: ${values.suite}`);
|
|
9420
|
+
suiteId = values.suite;
|
|
8943
9421
|
matrix = defineEvalMatrix({
|
|
8944
9422
|
id: "default",
|
|
8945
|
-
evals: suite
|
|
9423
|
+
evals: suite?.evals ?? [],
|
|
8946
9424
|
parameters: project.config.matrix?.parameters ?? {},
|
|
8947
9425
|
defaults: project.config.matrix?.defaults,
|
|
8948
9426
|
cases: project.config.matrix?.cases,
|
|
@@ -8959,6 +9437,19 @@ async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArg
|
|
|
8959
9437
|
parameters: {}
|
|
8960
9438
|
});
|
|
8961
9439
|
}
|
|
9440
|
+
const broken = brokenSelection(project.loadErrors, {
|
|
9441
|
+
...suiteId ? { suiteId } : {},
|
|
9442
|
+
evalIds: requestedEvals,
|
|
9443
|
+
registry
|
|
9444
|
+
});
|
|
9445
|
+
printLoadErrors(project.loadErrors, quiet ? console.error : console.log);
|
|
9446
|
+
if (broken.length && !values["skip-broken"] && !values["dry-run"]) throw new Error(`${plural(broken.length, "eval file")} in this selection failed to load: ${broken.map((error) => error.file).join(", ")}. Fix ${broken.length === 1 ? "it" : "them"}, or pass --skip-broken to run the rest.`);
|
|
9447
|
+
const knownEvals = requestedEvals.filter((id) => registry.get(id));
|
|
9448
|
+
if (values["skip-broken"] && knownEvals.length < requestedEvals.length) {
|
|
9449
|
+
requestedEvals.length = 0;
|
|
9450
|
+
requestedEvals.push(...knownEvals);
|
|
9451
|
+
if (!requestedEvals.length) throw new Error("Every selected eval is in a file that failed to load.");
|
|
9452
|
+
}
|
|
8962
9453
|
const selection = {
|
|
8963
9454
|
parameters: {},
|
|
8964
9455
|
overrides: { ...parameters },
|
|
@@ -8982,7 +9473,8 @@ async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArg
|
|
|
8982
9473
|
console.log(JSON.stringify(plan, null, 2));
|
|
8983
9474
|
return;
|
|
8984
9475
|
}
|
|
8985
|
-
|
|
9476
|
+
const sweeps = Object.keys(matrix.parameters).length > 0 || Boolean(matrix.cases?.length);
|
|
9477
|
+
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.`);
|
|
8986
9478
|
const reportDir = resolve(project.root, project.config.reportDir ?? "_evalkit-results");
|
|
8987
9479
|
const sandboxDir = resolve(project.root, project.config.sandboxDir ?? "_evalkit-sandbox");
|
|
8988
9480
|
const source = await detectSource(project.root, { outputDirs: [
|
|
@@ -8990,36 +9482,75 @@ async function runProjectCommand(args, cwd = process.cwd(), parsed = parseRunArg
|
|
|
8990
9482
|
sandboxDir,
|
|
8991
9483
|
resolve(project.root, "_evalkit-dashboard")
|
|
8992
9484
|
] });
|
|
8993
|
-
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
|
|
9019
|
-
|
|
9485
|
+
const suiteOf = new Map(registry.suites.flatMap((suite) => suite.evals.map((evaluation) => [authoringId(evaluation), suite.id])));
|
|
9486
|
+
const suiteFor = (evalId) => suiteId ?? suiteOf.get(evalId);
|
|
9487
|
+
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");
|
|
9488
|
+
const reporter = createRunReporter({
|
|
9489
|
+
interactive: interactive && !values.json,
|
|
9490
|
+
verbose: Boolean(values.verbose),
|
|
9491
|
+
total: cells,
|
|
9492
|
+
labelFor: (trial) => {
|
|
9493
|
+
const suite = suiteFor(trial.evalId);
|
|
9494
|
+
return suite ? `${suite}/${trial.evalId}` : trial.evalId;
|
|
9495
|
+
},
|
|
9496
|
+
write: values.json ? () => {} : void 0
|
|
9497
|
+
});
|
|
9498
|
+
const controller = new AbortController();
|
|
9499
|
+
const onInterrupt = () => {
|
|
9500
|
+
if (controller.signal.aborted) process.exit(130);
|
|
9501
|
+
controller.abort();
|
|
9502
|
+
if (!values.json) reporter.cancelling();
|
|
9503
|
+
};
|
|
9504
|
+
process.on("SIGINT", onInterrupt);
|
|
9505
|
+
reporter.start();
|
|
9506
|
+
let summary;
|
|
9507
|
+
try {
|
|
9508
|
+
summary = await Effect.runPromise(runnerLogs(runMatrix(matrix, {
|
|
9509
|
+
selection,
|
|
9510
|
+
concurrency: parsed.concurrency ?? project.config.execution?.concurrency ?? 4,
|
|
9511
|
+
trials,
|
|
9512
|
+
...suiteId ? { suiteId } : {},
|
|
9513
|
+
suiteFor: (cell) => suiteFor(authoringId(cell.eval)),
|
|
9514
|
+
...values.local ? { runtime: "local" } : {},
|
|
9515
|
+
...source ? { source } : {},
|
|
9516
|
+
catalog: runCatalog(registry, project.root),
|
|
9517
|
+
report: localReportStore(reportDir),
|
|
9518
|
+
workspaceRoot: sandboxDir,
|
|
9519
|
+
signal: controller.signal,
|
|
9520
|
+
onEvent: (trial, event) => reporter.onEvent(trial, event),
|
|
9521
|
+
onResult: (cell, result) => {
|
|
9522
|
+
const scenario = scenarioResult(cell, result, suiteFor(authoringId(cell.eval)));
|
|
9523
|
+
if (values.json) console.log(JSON.stringify({
|
|
9524
|
+
cell: {
|
|
9525
|
+
key: cell.key,
|
|
9526
|
+
eval: authoringId(cell.eval),
|
|
9527
|
+
parameters: cell.parameters
|
|
9528
|
+
},
|
|
9529
|
+
outcome: scenario.outcome,
|
|
9530
|
+
result
|
|
9531
|
+
}));
|
|
9532
|
+
reporter.onScenario(scenario);
|
|
9020
9533
|
}
|
|
9021
|
-
}
|
|
9022
|
-
}
|
|
9534
|
+
})));
|
|
9535
|
+
} finally {
|
|
9536
|
+
process.off("SIGINT", onInterrupt);
|
|
9537
|
+
}
|
|
9538
|
+
const totals = reporter.finish(performance.now() - started);
|
|
9539
|
+
if (values.junit) {
|
|
9540
|
+
const file = resolve(cwd, values.junit);
|
|
9541
|
+
await mkdir(dirname(file), { recursive: true });
|
|
9542
|
+
await writeFile(file, junitReport(reporter.scenarios()));
|
|
9543
|
+
}
|
|
9544
|
+
if (values.json) console.log(JSON.stringify({ summary: totals }));
|
|
9545
|
+
else {
|
|
9546
|
+
console.log(`\n${totalsLine(totals)}`);
|
|
9547
|
+
if (values.junit) console.log(` ${dim(`JUnit report: ${values.junit}`)}`);
|
|
9548
|
+
console.log(` ${cyan("➜")} bun run evalkit dev ${dim("to inspect")}`);
|
|
9549
|
+
if (project.config.share) console.log(` ${cyan("➜")} bun run evalkit share ${dim("to publish")}`);
|
|
9550
|
+
console.log("");
|
|
9551
|
+
}
|
|
9552
|
+
if (controller.signal.aborted) process.exitCode = 130;
|
|
9553
|
+
else if (summary.failed || broken.length) process.exitCode = 1;
|
|
9023
9554
|
}
|
|
9024
9555
|
//#endregion
|
|
9025
9556
|
//#region ../cli/src/tidy.ts
|
|
@@ -9083,7 +9614,8 @@ function selectRuns(runs, head, options) {
|
|
|
9083
9614
|
otherCommits: 0,
|
|
9084
9615
|
dirty: 0,
|
|
9085
9616
|
archived: 0,
|
|
9086
|
-
running: 0
|
|
9617
|
+
running: 0,
|
|
9618
|
+
cancelled: 0
|
|
9087
9619
|
};
|
|
9088
9620
|
for (const run of runs) {
|
|
9089
9621
|
const { source } = run;
|
|
@@ -9092,6 +9624,7 @@ function selectRuns(runs, head, options) {
|
|
|
9092
9624
|
else if (source.dirty && !options.allowDirty) result.dirty += 1;
|
|
9093
9625
|
else if (run.archived) result.archived += 1;
|
|
9094
9626
|
else if (run.status === "running") result.running += 1;
|
|
9627
|
+
else if (run.status === "cancelled") result.cancelled += 1;
|
|
9095
9628
|
else result.selected.push({
|
|
9096
9629
|
...run,
|
|
9097
9630
|
source
|
|
@@ -9105,6 +9638,7 @@ function selectionSummary(selection, options) {
|
|
|
9105
9638
|
`${plural(selection.selected.length, "run")} to share`,
|
|
9106
9639
|
selection.otherCommits && !options.all ? `${selection.otherCommits} from other commits` : "",
|
|
9107
9640
|
selection.running ? `${selection.running} still running skipped` : "",
|
|
9641
|
+
selection.cancelled ? `${selection.cancelled} cancelled skipped` : "",
|
|
9108
9642
|
selection.dirty ? `${selection.dirty} dirty skipped` : "",
|
|
9109
9643
|
selection.untracked ? `${selection.untracked} untracked skipped` : "",
|
|
9110
9644
|
selection.archived ? `${selection.archived} archived skipped` : ""
|
|
@@ -9192,7 +9726,7 @@ async function uploadBundle(serverUrl, bundle, options = {}) {
|
|
|
9192
9726
|
if (error instanceof UploadRejected || error instanceof ShareServerError) throw error;
|
|
9193
9727
|
lastError = error;
|
|
9194
9728
|
}
|
|
9195
|
-
if (attempt < attempts) await
|
|
9729
|
+
if (attempt < attempts) await sleep((options.delayMs ?? 500) * 2 ** (attempt - 1));
|
|
9196
9730
|
}
|
|
9197
9731
|
throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`Upload failed: ${String(lastError)}`);
|
|
9198
9732
|
}
|
|
@@ -9271,7 +9805,7 @@ function formatBytes(size) {
|
|
|
9271
9805
|
}
|
|
9272
9806
|
//#endregion
|
|
9273
9807
|
//#region ../cli/src/new-project-template.ts
|
|
9274
|
-
/** Provider-free example: one
|
|
9808
|
+
/** Provider-free example: one dataset, agent, scorer, and a two-cell matrix. */
|
|
9275
9809
|
const starterFiles = {
|
|
9276
9810
|
"tsconfig.json": `${JSON.stringify({
|
|
9277
9811
|
compilerOptions: {
|
|
@@ -9284,15 +9818,17 @@ const starterFiles = {
|
|
|
9284
9818
|
},
|
|
9285
9819
|
include: [
|
|
9286
9820
|
"agents/**/*.ts",
|
|
9287
|
-
"
|
|
9821
|
+
"datasets/**/*.ts",
|
|
9288
9822
|
"judges/**/*.ts",
|
|
9823
|
+
"scenarios/**/*.ts",
|
|
9289
9824
|
"evalkit.config.ts"
|
|
9290
9825
|
]
|
|
9291
9826
|
}, null, 2)}\n`,
|
|
9292
9827
|
"evalkit.config.ts": `import { defineConfig } from '@leostera/evalkit';
|
|
9293
9828
|
|
|
9294
9829
|
export default defineConfig({
|
|
9295
|
-
// Default-exported evals in
|
|
9830
|
+
// Default-exported evals in scenarios/**/*.eval.ts are discovered automatically;
|
|
9831
|
+
// each directory below scenarios/ is a suite.
|
|
9296
9832
|
matrix: {
|
|
9297
9833
|
id: 'styles',
|
|
9298
9834
|
parameters: { style: ['plain', 'shout'] },
|
|
@@ -9300,7 +9836,16 @@ export default defineConfig({
|
|
|
9300
9836
|
execution: { concurrency: 2, maxCells: 20 },
|
|
9301
9837
|
});
|
|
9302
9838
|
`,
|
|
9303
|
-
"
|
|
9839
|
+
"datasets/greeting/greeting.txt": "Hello\n",
|
|
9840
|
+
"datasets/greeting.dataset.ts": `import { defineDataset } from '@leostera/evalkit';
|
|
9841
|
+
|
|
9842
|
+
// Files the agent can read: copied into each trial's workspace under greeting/.
|
|
9843
|
+
// Paths are relative to the project root.
|
|
9844
|
+
export const greeting = defineDataset({
|
|
9845
|
+
id: 'greeting',
|
|
9846
|
+
files: 'datasets/greeting',
|
|
9847
|
+
});
|
|
9848
|
+
`,
|
|
9304
9849
|
"agents/greeting-agent.ts": `import { readFile } from 'node:fs/promises';
|
|
9305
9850
|
import { join } from 'node:path';
|
|
9306
9851
|
import { defineAgent } from '@leostera/evalkit';
|
|
@@ -9308,23 +9853,18 @@ import { defineAgent } from '@leostera/evalkit';
|
|
|
9308
9853
|
// Replace this in-process example with an adapter for your own agent.
|
|
9309
9854
|
export const greetingAgent = defineAgent({
|
|
9310
9855
|
identity: { id: 'greeting-agent', kind: 'in-process' },
|
|
9311
|
-
|
|
9312
|
-
async start({ context, onEvent }) {
|
|
9856
|
+
async start({ context }) {
|
|
9313
9857
|
return {
|
|
9858
|
+
// Return the reply; EvalKit records it. Call onEvent instead to stream events.
|
|
9314
9859
|
async send(message: string) {
|
|
9315
|
-
|
|
9316
|
-
const prefix = (await readFile(
|
|
9860
|
+
const file = join(context.workspace.root, 'greeting', 'greeting.txt');
|
|
9861
|
+
const prefix = (await readFile(file, 'utf8')).trim();
|
|
9317
9862
|
const style = context.parameters?.style;
|
|
9318
9863
|
if (style !== 'plain' && style !== 'shout')
|
|
9319
9864
|
throw new Error(\`Unknown style: \${String(style)}\`);
|
|
9320
9865
|
const greeting = \`\${prefix}, \${message}!\`;
|
|
9321
|
-
|
|
9322
|
-
kind: 'message', role: 'assistant',
|
|
9323
|
-
content: style === 'shout' ? greeting.toUpperCase() : greeting,
|
|
9324
|
-
timestamp: new Date().toISOString(),
|
|
9325
|
-
});
|
|
9866
|
+
return style === 'shout' ? greeting.toUpperCase() : greeting;
|
|
9326
9867
|
},
|
|
9327
|
-
async close() {},
|
|
9328
9868
|
};
|
|
9329
9869
|
},
|
|
9330
9870
|
});
|
|
@@ -9332,25 +9872,20 @@ export const greetingAgent = defineAgent({
|
|
|
9332
9872
|
"judges/matches-greeting.ts": `import { predicate } from '@leostera/evalkit';
|
|
9333
9873
|
|
|
9334
9874
|
// Deterministic scorers need no judge model; add a separate judge agent for judge(...) rules.
|
|
9335
|
-
export const matchesGreeting = predicate('matches greeting and style', ({ context,
|
|
9336
|
-
const reply = trajectory.events.filter(
|
|
9337
|
-
(event) => event.source === 'aut' && event.kind === 'message' && event.role === 'assistant',
|
|
9338
|
-
).at(-1);
|
|
9875
|
+
export const matchesGreeting = predicate('matches greeting and style', ({ context, output }) => {
|
|
9339
9876
|
const expected = context.parameters?.style === 'shout' ? 'HELLO, ADA!' : 'Hello, Ada!';
|
|
9340
|
-
return {
|
|
9341
|
-
value: reply?.kind === 'message' && reply.content === expected ? 1 : 0,
|
|
9342
|
-
explanation: \`Expected \${expected}\`,
|
|
9343
|
-
};
|
|
9877
|
+
return { value: output === expected ? 1 : 0, explanation: \`Expected \${expected}\` };
|
|
9344
9878
|
});
|
|
9345
9879
|
`,
|
|
9346
|
-
"
|
|
9880
|
+
"scenarios/greeting.eval.ts": `import { defineEval, user } from '@leostera/evalkit';
|
|
9347
9881
|
import { greetingAgent } from '../agents/greeting-agent.js';
|
|
9882
|
+
import { greeting } from '../datasets/greeting.dataset.js';
|
|
9348
9883
|
import { matchesGreeting } from '../judges/matches-greeting.js';
|
|
9349
9884
|
|
|
9350
9885
|
export default defineEval({
|
|
9351
9886
|
id: 'greeting',
|
|
9352
9887
|
agent: greetingAgent,
|
|
9353
|
-
|
|
9888
|
+
datasets: [greeting],
|
|
9354
9889
|
transcript: [user('Ada')],
|
|
9355
9890
|
scoring: [matchesGreeting],
|
|
9356
9891
|
});
|
|
@@ -9359,10 +9894,11 @@ export default defineEval({
|
|
|
9359
9894
|
function starterReadme(name) {
|
|
9360
9895
|
return `# ${name}
|
|
9361
9896
|
|
|
9362
|
-
A local EvalKit project. Each default-exported eval in \`
|
|
9363
|
-
|
|
9364
|
-
|
|
9365
|
-
|
|
9897
|
+
A local EvalKit project. Each default-exported eval in \`scenarios/\` is discovered
|
|
9898
|
+
automatically, and each directory below \`scenarios/\` is a suite. Start with
|
|
9899
|
+
\`scenarios/greeting.eval.ts\`: it imports an in-process agent from \`agents/\`, a
|
|
9900
|
+
dataset of files from \`datasets/\`, and a deterministic scorer from \`judges/\`.
|
|
9901
|
+
Keep each scenario small and concrete; share agents, datasets and judges between them.
|
|
9366
9902
|
|
|
9367
9903
|
\`evalkit.config.ts\` defines a provider-free \`styles\` matrix; the example agent reads
|
|
9368
9904
|
\`context.parameters.style\`. Run:
|
|
@@ -9376,6 +9912,9 @@ bun run evalkit dev # inspect local reports and laun
|
|
|
9376
9912
|
bun run evalkit build # write a read-only, shareable snapshot
|
|
9377
9913
|
\`\`\`
|
|
9378
9914
|
|
|
9915
|
+
Coding agents can use the EvalKit skills that ship with the package, in
|
|
9916
|
+
\`node_modules/@leostera/evalkit/dist/skills/\` (start with \`evalkit/SKILL.md\`).
|
|
9917
|
+
|
|
9379
9918
|
EvalKit installs from npm. Reports, trial workspaces, and dashboard snapshots stay local
|
|
9380
9919
|
and are ignored by Git. A snapshot in \`_evalkit-dashboard/\` contains report messages,
|
|
9381
9920
|
scores, evidence, and candidate files (never evaluator workspaces): review it before sharing.
|
|
@@ -9383,7 +9922,7 @@ scores, evidence, and candidate files (never evaluator workspaces): review it be
|
|
|
9383
9922
|
}
|
|
9384
9923
|
//#endregion
|
|
9385
9924
|
//#region ../cli/src/new-project.ts
|
|
9386
|
-
const npmDependency = "^0.
|
|
9925
|
+
const npmDependency = "^0.6.1";
|
|
9387
9926
|
const ignored = [
|
|
9388
9927
|
"node_modules/",
|
|
9389
9928
|
"_evalkit-results/",
|
|
@@ -9447,9 +9986,9 @@ async function checkExistingScaffold(root) {
|
|
|
9447
9986
|
for (const extension of ["js", "mjs"]) if (await exists(resolve(root, `evalkit.config.${extension}`))) throw new Error(`EvalKit config already exists: evalkit.config.${extension}`);
|
|
9448
9987
|
for (const directory of [
|
|
9449
9988
|
"agents",
|
|
9450
|
-
"
|
|
9451
|
-
"
|
|
9452
|
-
"
|
|
9989
|
+
"datasets",
|
|
9990
|
+
"judges",
|
|
9991
|
+
"scenarios"
|
|
9453
9992
|
]) {
|
|
9454
9993
|
const path = resolve(root, directory);
|
|
9455
9994
|
if (await exists(path) && !(await stat(path)).isDirectory()) throw new Error(`Scaffold directory is not a directory: ${path}`);
|
|
@@ -9495,8 +10034,300 @@ async function writeStarterReadme(root, name, inPlace) {
|
|
|
9495
10034
|
if (!inPlace && !await exists(readmePath)) await writeFile(readmePath, starterReadme(name));
|
|
9496
10035
|
}
|
|
9497
10036
|
//#endregion
|
|
10037
|
+
//#region ../cli/src/compare.ts
|
|
10038
|
+
const FINISHED = /* @__PURE__ */ new Set([
|
|
10039
|
+
"passed",
|
|
10040
|
+
"failed",
|
|
10041
|
+
"errored"
|
|
10042
|
+
]);
|
|
10043
|
+
function unitKey(run) {
|
|
10044
|
+
return `${run.evalId}\0${canonicalParameters(run.parameters ?? {})}`;
|
|
10045
|
+
}
|
|
10046
|
+
/** The run that represents each unit: the latest finished one, else the latest at all. */
|
|
10047
|
+
function latestByUnit(runs) {
|
|
10048
|
+
const byUnit = /* @__PURE__ */ new Map();
|
|
10049
|
+
let ignored = 0;
|
|
10050
|
+
const ordered = [...runs].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
10051
|
+
for (const run of ordered) {
|
|
10052
|
+
if (run.status === "running") continue;
|
|
10053
|
+
const key = unitKey(run);
|
|
10054
|
+
const current = byUnit.get(key);
|
|
10055
|
+
if (!current) byUnit.set(key, run);
|
|
10056
|
+
else {
|
|
10057
|
+
ignored += 1;
|
|
10058
|
+
if (!FINISHED.has(current.status) && FINISHED.has(run.status)) byUnit.set(key, run);
|
|
10059
|
+
}
|
|
10060
|
+
}
|
|
10061
|
+
return {
|
|
10062
|
+
byUnit,
|
|
10063
|
+
ignored
|
|
10064
|
+
};
|
|
10065
|
+
}
|
|
10066
|
+
function scorerSignature(run) {
|
|
10067
|
+
return (run.scorers ?? []).map((scorer) => `${scorer.kind}:${scorer.name}`).sort().join("\n");
|
|
10068
|
+
}
|
|
10069
|
+
function classify(base, head) {
|
|
10070
|
+
if (!base) return { change: "new" };
|
|
10071
|
+
if (!head) return { change: "removed" };
|
|
10072
|
+
for (const [side, run] of [["base", base], ["head", head]]) if (!FINISHED.has(run.status) || run.status === "errored") return {
|
|
10073
|
+
change: "not-comparable",
|
|
10074
|
+
reason: `${run.status} on ${side}`
|
|
10075
|
+
};
|
|
10076
|
+
if (scorerSignature(base) !== scorerSignature(head)) return {
|
|
10077
|
+
change: "not-comparable",
|
|
10078
|
+
reason: "different scorers"
|
|
10079
|
+
};
|
|
10080
|
+
if (base.status === "passed" && head.status === "failed") return { change: "regressed" };
|
|
10081
|
+
if (base.status === "failed" && head.status === "passed") return { change: "fixed" };
|
|
10082
|
+
return { change: "unchanged" };
|
|
10083
|
+
}
|
|
10084
|
+
function label(run, suite) {
|
|
10085
|
+
const parameters = Object.entries(run.parameters ?? {}).map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`).join(" ");
|
|
10086
|
+
const name = suite === "default" ? run.evalId : `${suite}/${run.evalId}`;
|
|
10087
|
+
return parameters ? `${name} [${parameters}]` : name;
|
|
10088
|
+
}
|
|
10089
|
+
function mean(values) {
|
|
10090
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : void 0;
|
|
10091
|
+
}
|
|
10092
|
+
function suiteScorers(units) {
|
|
10093
|
+
const sides = {
|
|
10094
|
+
base: /* @__PURE__ */ new Map(),
|
|
10095
|
+
head: /* @__PURE__ */ new Map()
|
|
10096
|
+
};
|
|
10097
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
10098
|
+
for (const unit of units) {
|
|
10099
|
+
if (unit.change === "not-comparable" || !unit.base || !unit.head) continue;
|
|
10100
|
+
for (const side of ["base", "head"]) for (const scorer of unit[side].scorers ?? []) {
|
|
10101
|
+
const key = `${scorer.kind}\0${scorer.name}`;
|
|
10102
|
+
kinds.set(key, {
|
|
10103
|
+
name: scorer.name,
|
|
10104
|
+
kind: scorer.kind
|
|
10105
|
+
});
|
|
10106
|
+
if (scorer.mean === void 0) continue;
|
|
10107
|
+
sides[side].set(key, [...sides[side].get(key) ?? [], scorer.mean]);
|
|
10108
|
+
}
|
|
10109
|
+
}
|
|
10110
|
+
for (const unit of units) {
|
|
10111
|
+
if (!unit.base || !unit.head) continue;
|
|
10112
|
+
const names = (run) => new Set((run.scorers ?? []).map((s) => `${s.kind}\0${s.name}`));
|
|
10113
|
+
const baseNames = names(unit.base);
|
|
10114
|
+
const headNames = names(unit.head);
|
|
10115
|
+
for (const scorer of unit.base.scorers ?? []) if (!headNames.has(`${scorer.kind}\0${scorer.name}`)) kinds.set(`${scorer.kind}\0${scorer.name}`, {
|
|
10116
|
+
name: scorer.name,
|
|
10117
|
+
kind: scorer.kind,
|
|
10118
|
+
onlyOn: "base"
|
|
10119
|
+
});
|
|
10120
|
+
for (const scorer of unit.head.scorers ?? []) if (!baseNames.has(`${scorer.kind}\0${scorer.name}`)) kinds.set(`${scorer.kind}\0${scorer.name}`, {
|
|
10121
|
+
name: scorer.name,
|
|
10122
|
+
kind: scorer.kind,
|
|
10123
|
+
onlyOn: "head"
|
|
10124
|
+
});
|
|
10125
|
+
}
|
|
10126
|
+
return [...kinds].map(([key, scorer]) => {
|
|
10127
|
+
const base = mean(sides.base.get(key) ?? []);
|
|
10128
|
+
const head = mean(sides.head.get(key) ?? []);
|
|
10129
|
+
return {
|
|
10130
|
+
...scorer,
|
|
10131
|
+
...base === void 0 || scorer.onlyOn ? {} : { base },
|
|
10132
|
+
...head === void 0 || scorer.onlyOn ? {} : { head }
|
|
10133
|
+
};
|
|
10134
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
10135
|
+
}
|
|
10136
|
+
/**
|
|
10137
|
+
* Matches base and head runs by eval ID and parameters and classifies each
|
|
10138
|
+
* unit. It reads recorded verdicts and means; it never re-scores.
|
|
10139
|
+
*/
|
|
10140
|
+
function compareRuns(baseRuns, headRuns) {
|
|
10141
|
+
const base = latestByUnit(baseRuns);
|
|
10142
|
+
const head = latestByUnit(headRuns);
|
|
10143
|
+
const units = [.../* @__PURE__ */ new Set([...base.byUnit.keys(), ...head.byUnit.keys()])].map((key) => {
|
|
10144
|
+
const baseRun = base.byUnit.get(key);
|
|
10145
|
+
const headRun = head.byUnit.get(key);
|
|
10146
|
+
const run = headRun ?? baseRun;
|
|
10147
|
+
const suite = headRun?.suiteId ?? baseRun?.suiteId ?? "default";
|
|
10148
|
+
return {
|
|
10149
|
+
label: label(run, suite),
|
|
10150
|
+
evalId: run.evalId,
|
|
10151
|
+
suite,
|
|
10152
|
+
parameters: run.parameters ?? {},
|
|
10153
|
+
...classify(baseRun, headRun),
|
|
10154
|
+
...baseRun ? { base: baseRun } : {},
|
|
10155
|
+
...headRun ? { head: headRun } : {}
|
|
10156
|
+
};
|
|
10157
|
+
}).sort((a, b) => a.label.localeCompare(b.label));
|
|
10158
|
+
const changedDatasets = /* @__PURE__ */ new Set();
|
|
10159
|
+
for (const unit of units) {
|
|
10160
|
+
if (!unit.base || !unit.head) continue;
|
|
10161
|
+
const digests = (run) => new Map((run.datasets ?? []).map((d) => [d.id, d.digests.join(",")]));
|
|
10162
|
+
const before = digests(unit.base);
|
|
10163
|
+
const after = digests(unit.head);
|
|
10164
|
+
for (const id of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) if (before.get(id) !== after.get(id)) changedDatasets.add(id);
|
|
10165
|
+
}
|
|
10166
|
+
const bySuite = /* @__PURE__ */ new Map();
|
|
10167
|
+
for (const unit of units) bySuite.set(unit.suite, [...bySuite.get(unit.suite) ?? [], unit]);
|
|
10168
|
+
const suites = [...bySuite].map(([suite, members]) => {
|
|
10169
|
+
const side = (pick) => {
|
|
10170
|
+
const runs = members.flatMap((unit) => unit[pick] ? [unit[pick]] : []);
|
|
10171
|
+
return {
|
|
10172
|
+
passed: runs.filter((run) => run.status === "passed").length,
|
|
10173
|
+
total: runs.length
|
|
10174
|
+
};
|
|
10175
|
+
};
|
|
10176
|
+
return {
|
|
10177
|
+
suite,
|
|
10178
|
+
base: side("base"),
|
|
10179
|
+
head: side("head"),
|
|
10180
|
+
scorers: suiteScorers(members)
|
|
10181
|
+
};
|
|
10182
|
+
}).sort((a, b) => a.suite.localeCompare(b.suite));
|
|
10183
|
+
const counts = {
|
|
10184
|
+
regressed: 0,
|
|
10185
|
+
fixed: 0,
|
|
10186
|
+
new: 0,
|
|
10187
|
+
removed: 0,
|
|
10188
|
+
unchanged: 0,
|
|
10189
|
+
"not-comparable": 0
|
|
10190
|
+
};
|
|
10191
|
+
for (const unit of units) counts[unit.change] += 1;
|
|
10192
|
+
return {
|
|
10193
|
+
units,
|
|
10194
|
+
suites,
|
|
10195
|
+
changedDatasets: [...changedDatasets].sort(),
|
|
10196
|
+
ignoredRuns: {
|
|
10197
|
+
base: base.ignored,
|
|
10198
|
+
head: head.ignored
|
|
10199
|
+
},
|
|
10200
|
+
counts
|
|
10201
|
+
};
|
|
10202
|
+
}
|
|
10203
|
+
function parseFailOn(values) {
|
|
10204
|
+
return values.flatMap((value) => value.split(",")).map((value) => {
|
|
10205
|
+
if (value === "regression") return { kind: "regression" };
|
|
10206
|
+
const drop = /^score-drop=(\d*\.?\d+)$/.exec(value);
|
|
10207
|
+
if (drop) {
|
|
10208
|
+
const threshold = Number(drop[1]);
|
|
10209
|
+
if (threshold >= 0 && threshold <= 1) return {
|
|
10210
|
+
kind: "score-drop",
|
|
10211
|
+
threshold
|
|
10212
|
+
};
|
|
10213
|
+
}
|
|
10214
|
+
throw new Error(`Unknown --fail-on ${JSON.stringify(value)}; use regression or score-drop=0.05`);
|
|
10215
|
+
});
|
|
10216
|
+
}
|
|
10217
|
+
/** Human-readable reasons the comparison fails the given conditions; empty when it passes. */
|
|
10218
|
+
function gateFailures(comparison, conditions) {
|
|
10219
|
+
const failures = [];
|
|
10220
|
+
for (const condition of conditions) {
|
|
10221
|
+
if (condition.kind === "regression" && comparison.counts.regressed) failures.push(`${comparison.counts.regressed} regressed: ${comparison.units.filter((unit) => unit.change === "regressed").map((unit) => unit.label).join(", ")}`);
|
|
10222
|
+
if (condition.kind === "score-drop") {
|
|
10223
|
+
for (const suite of comparison.suites) for (const scorer of suite.scorers) if (scorer.base !== void 0 && scorer.head !== void 0 && scorer.base - scorer.head > condition.threshold) failures.push(`${suite.suite} ${scorer.name} dropped ${scorer.base.toFixed(2)} → ${scorer.head.toFixed(2)} (more than ${condition.threshold})`);
|
|
10224
|
+
}
|
|
10225
|
+
}
|
|
10226
|
+
return failures;
|
|
10227
|
+
}
|
|
10228
|
+
const fixed2 = (value) => value === void 0 ? "–" : value.toFixed(2);
|
|
10229
|
+
function scorerText(scorer) {
|
|
10230
|
+
if (scorer.onlyOn) return `${scorer.name} only on ${scorer.onlyOn}`;
|
|
10231
|
+
return `${scorer.name} ${fixed2(scorer.base)} → ${fixed2(scorer.head)}`;
|
|
10232
|
+
}
|
|
10233
|
+
/** A merge request comment. */
|
|
10234
|
+
function comparisonMarkdown(comparison, heading) {
|
|
10235
|
+
const { counts } = comparison;
|
|
10236
|
+
const list = (change) => comparison.units.filter((unit) => unit.change === change).map((unit) => `- \`${unit.label}\`${unit.reason ? ` (${unit.reason})` : ""}`);
|
|
10237
|
+
const lines = [
|
|
10238
|
+
`### EvalKit: ${heading.base} → ${heading.head}`,
|
|
10239
|
+
"",
|
|
10240
|
+
`**${counts.regressed} regressed** · ${counts.fixed} fixed · ${counts.new} new · ${counts.removed} removed · ${counts.unchanged} unchanged${counts["not-comparable"] ? ` · ${counts["not-comparable"]} not comparable` : ""}`,
|
|
10241
|
+
""
|
|
10242
|
+
];
|
|
10243
|
+
for (const [title, change] of [
|
|
10244
|
+
["Regressed", "regressed"],
|
|
10245
|
+
["Fixed", "fixed"],
|
|
10246
|
+
["New", "new"],
|
|
10247
|
+
["Removed", "removed"],
|
|
10248
|
+
["Not comparable", "not-comparable"]
|
|
10249
|
+
]) {
|
|
10250
|
+
const items = list(change);
|
|
10251
|
+
if (items.length) lines.push(`**${title}**`, "", ...items, "");
|
|
10252
|
+
}
|
|
10253
|
+
lines.push("| Suite | Passed | Scores |", "| --- | --- | --- |");
|
|
10254
|
+
for (const suite of comparison.suites) lines.push(`| ${suite.suite} | ${suite.base.passed}/${suite.base.total} → ${suite.head.passed}/${suite.head.total} | ${suite.scorers.map(scorerText).join("<br>") || "–"} |`);
|
|
10255
|
+
if (comparison.changedDatasets.length) lines.push("", `Datasets changed: ${comparison.changedDatasets.map((id) => `\`${id}\``).join(", ")}`);
|
|
10256
|
+
return `${lines.join("\n")}\n`;
|
|
10257
|
+
}
|
|
10258
|
+
function comparisonLines(comparison) {
|
|
10259
|
+
const { counts } = comparison;
|
|
10260
|
+
const labels = (change) => comparison.units.filter((unit) => unit.change === change).map((unit) => unit.reason ? `${unit.label} (${unit.reason})` : unit.label);
|
|
10261
|
+
return {
|
|
10262
|
+
units: [
|
|
10263
|
+
"regressed",
|
|
10264
|
+
"fixed",
|
|
10265
|
+
"not-comparable"
|
|
10266
|
+
].map((change) => ({
|
|
10267
|
+
change,
|
|
10268
|
+
labels: labels(change)
|
|
10269
|
+
})).filter((entry) => entry.labels.length),
|
|
10270
|
+
counts: `+ ${counts.new} new − ${counts.removed} removed ${counts.unchanged} unchanged`,
|
|
10271
|
+
suites: comparison.suites.map((suite) => `${suite.suite.padEnd(12)} ${suite.base.passed}/${suite.base.total} → ${suite.head.passed}/${suite.head.total} ${suite.scorers.map(scorerText).join(" · ")}`),
|
|
10272
|
+
...comparison.changedDatasets.length ? { datasets: `datasets ${comparison.changedDatasets.join(", ")} changed` } : {}
|
|
10273
|
+
};
|
|
10274
|
+
}
|
|
10275
|
+
//#endregion
|
|
10276
|
+
//#region ../cli/src/compare-command.ts
|
|
10277
|
+
/** The runs at HEAD that `compare` uses: not running, not archived, clean unless allowed. */
|
|
10278
|
+
function headRuns(runs, head, options) {
|
|
10279
|
+
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");
|
|
10280
|
+
}
|
|
10281
|
+
/** Base runs from a results directory, e.g. an artifact from the base pipeline. Dirty runs are never a base. */
|
|
10282
|
+
async function baseRunsFromDirectory(directory, registry) {
|
|
10283
|
+
return (await createDashboardReportReader(directory, registry).listRuns()).filter((run) => !run.source?.dirty && !run.archived);
|
|
10284
|
+
}
|
|
10285
|
+
/** A full SHA, a unique prefix of at least 7 characters, or a ref's latest shared commit. */
|
|
10286
|
+
function resolveSharedRef(commits, ref) {
|
|
10287
|
+
if (/^[0-9a-f]{40}$/.test(ref)) return commits.some((row) => row.commit === ref) ? ref : void 0;
|
|
10288
|
+
if (/^[0-9a-f]{7,39}$/.test(ref)) {
|
|
10289
|
+
const matches = commits.filter((row) => row.commit.startsWith(ref));
|
|
10290
|
+
if (matches.length === 1) return matches[0].commit;
|
|
10291
|
+
}
|
|
10292
|
+
return commits.find((row) => row.ref === ref)?.commit;
|
|
10293
|
+
}
|
|
10294
|
+
async function getJson(url, serverUrl, headers) {
|
|
10295
|
+
const response = await fetch(url, {
|
|
10296
|
+
headers,
|
|
10297
|
+
redirect: "manual"
|
|
10298
|
+
});
|
|
10299
|
+
await expectShareServer(response, serverUrl);
|
|
10300
|
+
if (response.status === 404) {
|
|
10301
|
+
await response.body?.cancel();
|
|
10302
|
+
return;
|
|
10303
|
+
}
|
|
10304
|
+
if (!response.ok) throw new Error(`Share server error ${response.status}: ${await response.text()}`);
|
|
10305
|
+
return await response.json();
|
|
10306
|
+
}
|
|
10307
|
+
/** Base runs shared for `ref` of this repository; `undefined` when the base was never shared. */
|
|
10308
|
+
async function baseRunsFromServer(options) {
|
|
10309
|
+
const repo = repoKey(options.head);
|
|
10310
|
+
const api = (path) => new URL(`/api/v1/repos/${repo}/-/${path}`, options.serverUrl);
|
|
10311
|
+
const listed = await getJson(api("commits"), options.serverUrl, options.headers);
|
|
10312
|
+
const commits = Array.isArray(listed?.commits) ? listed.commits : [];
|
|
10313
|
+
const commit = resolveSharedRef(commits, options.ref);
|
|
10314
|
+
if (!commit) return void 0;
|
|
10315
|
+
const data = await getJson(api(`${commit}/data/runs.json`), options.serverUrl, options.headers);
|
|
10316
|
+
const runs = (Array.isArray(data?.runs) ? data.runs : []).filter(isComparedRun);
|
|
10317
|
+
const ref = commits.find((row) => row.commit === commit)?.ref;
|
|
10318
|
+
return {
|
|
10319
|
+
commit,
|
|
10320
|
+
...ref ? { ref } : {},
|
|
10321
|
+
runs: runs.filter((run) => !run.source?.dirty)
|
|
10322
|
+
};
|
|
10323
|
+
}
|
|
10324
|
+
/** Shared runs are uploaded by other EvalKit versions; keep only what compare can read. */
|
|
10325
|
+
function isComparedRun(value) {
|
|
10326
|
+
const run = value;
|
|
10327
|
+
return typeof run === "object" && run !== null && typeof run.id === "string" && typeof run.evalId === "string" && typeof run.status === "string" && typeof run.startedAt === "string";
|
|
10328
|
+
}
|
|
10329
|
+
//#endregion
|
|
9498
10330
|
//#region ../cli/src/index.ts
|
|
9499
|
-
const VERSION = "0.5.0";
|
|
9500
10331
|
let projectRoot = process.cwd();
|
|
9501
10332
|
let reportRoot = resolve(projectRoot, "_evalkit-results");
|
|
9502
10333
|
const sandboxRoot = resolve(projectRoot, "_evalkit-sandbox");
|
|
@@ -9510,7 +10341,7 @@ function runtimeFor(evaluation) {
|
|
|
9510
10341
|
"local",
|
|
9511
10342
|
"remote",
|
|
9512
10343
|
"sandbox"
|
|
9513
|
-
]) if (evaluation.agent
|
|
10344
|
+
]) if (agentRuntimes(evaluation.agent)[runtime]) return runtime;
|
|
9514
10345
|
}
|
|
9515
10346
|
async function runEvals(options) {
|
|
9516
10347
|
const registry = await loadRegistry();
|
|
@@ -9539,7 +10370,7 @@ async function runProvenance(registry) {
|
|
|
9539
10370
|
] });
|
|
9540
10371
|
return {
|
|
9541
10372
|
...source ? { source } : {},
|
|
9542
|
-
catalog: runCatalog(registry)
|
|
10373
|
+
catalog: runCatalog(registry, projectRoot)
|
|
9543
10374
|
};
|
|
9544
10375
|
}
|
|
9545
10376
|
function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
|
|
@@ -9621,7 +10452,37 @@ function registerMatrixRoutes(app, registry, dashboardMatrix, trials) {
|
|
|
9621
10452
|
});
|
|
9622
10453
|
}
|
|
9623
10454
|
function registerReportRoutes(app, registry, reports) {
|
|
9624
|
-
app.get("/v1/
|
|
10455
|
+
app.get("/v1/project", async (context) => {
|
|
10456
|
+
const source = await detectSource(projectRoot, { outputDirs: [
|
|
10457
|
+
reportRoot,
|
|
10458
|
+
sandboxRoot,
|
|
10459
|
+
resolve(projectRoot, "_evalkit-dashboard")
|
|
10460
|
+
] });
|
|
10461
|
+
return context.json({ project: source ? projectInfo(source) : null });
|
|
10462
|
+
});
|
|
10463
|
+
app.get("/v1/datasets/:id/files", async (context) => {
|
|
10464
|
+
const dataset = projectDatasets(registry).get(context.req.param("id"));
|
|
10465
|
+
if (!dataset) return context.text("Unknown dataset", 404);
|
|
10466
|
+
try {
|
|
10467
|
+
return context.json(await datasetListing(dataset, projectRoot));
|
|
10468
|
+
} catch (error) {
|
|
10469
|
+
return context.text(error instanceof Error ? error.message : "Unable to list dataset", 400);
|
|
10470
|
+
}
|
|
10471
|
+
});
|
|
10472
|
+
app.get("/v1/datasets/:id/files/:path{.+}", async (context) => {
|
|
10473
|
+
const dataset = projectDatasets(registry).get(context.req.param("id"));
|
|
10474
|
+
if (!dataset) return context.text("Unknown dataset", 404);
|
|
10475
|
+
const file = context.req.param("path");
|
|
10476
|
+
try {
|
|
10477
|
+
return new Response(await staticDatasetFile(dataset, projectRoot, file), { headers: { "content-type": fileContentType(file) } });
|
|
10478
|
+
} catch (error) {
|
|
10479
|
+
return context.text(error instanceof Error ? error.message : "Unable to read dataset file", 404);
|
|
10480
|
+
}
|
|
10481
|
+
});
|
|
10482
|
+
app.get("/v1/catalog", (context) => context.json({
|
|
10483
|
+
evals: registry.catalog({ root: projectRoot }),
|
|
10484
|
+
loadErrors: project?.loadErrors ?? []
|
|
10485
|
+
}));
|
|
9625
10486
|
app.get("/v1/evals", (context) => context.json({ evals: registry.metadata() }));
|
|
9626
10487
|
app.get("/v1/suites", (context) => context.json({ suites: registry.suiteMetadata() }));
|
|
9627
10488
|
app.get("/v1/runs", async (context) => context.json({ runs: await reports.listRuns() }));
|
|
@@ -9646,9 +10507,9 @@ function registerReportRoutes(app, registry, reports) {
|
|
|
9646
10507
|
return context.text(error instanceof Error ? error.message : "Unable to list workspace", 400);
|
|
9647
10508
|
}
|
|
9648
10509
|
});
|
|
9649
|
-
app.get("/v1/runs/:runId/trials/:trialId/workspace
|
|
10510
|
+
app.get("/v1/runs/:runId/trials/:trialId/workspace/:path{.+}", async (context) => {
|
|
9650
10511
|
try {
|
|
9651
|
-
return await reports.candidateFile(context.req.param("runId"), context.req.param("trialId"), context.req.param("
|
|
10512
|
+
return await reports.candidateFile(context.req.param("runId"), context.req.param("trialId"), context.req.param("path"));
|
|
9652
10513
|
} catch (error) {
|
|
9653
10514
|
return context.text(error instanceof Error ? error.message : "Unable to read workspace file", 400);
|
|
9654
10515
|
}
|
|
@@ -9674,12 +10535,12 @@ function registerDashboardAssets(app, dashboardRoot) {
|
|
|
9674
10535
|
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
9675
10536
|
const file = resolve(dashboardRoot, `.${pathname}`);
|
|
9676
10537
|
if (!file.startsWith(dashboardRoot)) return context.text("Not found", 404);
|
|
9677
|
-
let content = Bun.file(file);
|
|
9678
10538
|
let servedFile = file;
|
|
9679
|
-
|
|
10539
|
+
let content = await readFile(file).catch(() => void 0);
|
|
10540
|
+
if (!content) {
|
|
9680
10541
|
if (extname(pathname)) return context.text("Not found", 404);
|
|
9681
10542
|
servedFile = resolve(dashboardRoot, "index.html");
|
|
9682
|
-
content =
|
|
10543
|
+
content = await readFile(servedFile);
|
|
9683
10544
|
}
|
|
9684
10545
|
return new Response(content, { headers: { "content-type": fileContentType(servedFile) } });
|
|
9685
10546
|
});
|
|
@@ -9707,10 +10568,7 @@ async function serveDashboard() {
|
|
|
9707
10568
|
const port = Number(process.env.PORT ?? 4317);
|
|
9708
10569
|
let server;
|
|
9709
10570
|
try {
|
|
9710
|
-
server =
|
|
9711
|
-
port,
|
|
9712
|
-
fetch: app.fetch
|
|
9713
|
-
});
|
|
10571
|
+
server = await serve(app.fetch, port);
|
|
9714
10572
|
} catch (error) {
|
|
9715
10573
|
if (error.code === "EADDRINUSE") throw new Error(`Port ${port} is already in use. Stop the other server or choose another port: PORT=4318 evalkit dev`);
|
|
9716
10574
|
throw error;
|
|
@@ -9722,19 +10580,12 @@ async function serveDashboard() {
|
|
|
9722
10580
|
const matrixCells = dashboardMatrix?.count({ parameters: {} });
|
|
9723
10581
|
const row = (label, value) => console.log(` ${cyan("➜")} ${bold(label.padEnd(9))}${value}`);
|
|
9724
10582
|
banner("dev", VERSION);
|
|
10583
|
+
printLoadErrors(project?.loadErrors ?? []);
|
|
9725
10584
|
row("Local:", cyan(url));
|
|
9726
10585
|
row("Project:", `${plural(registry.evals.length, "eval")} · ${plural(registry.suites.length, "suite")}` + (dashboardMatrix ? ` · matrix ${authoringId(dashboardMatrix)} (${plural(matrixCells, "cell")})` : ""));
|
|
9727
10586
|
row("Reports:", `${relative(process.cwd(), reportRoot) || "."} ${dim(`(${plural(runs, "run")}${legacy ? `, ${legacy} from an older EvalKit hidden` : ""})`)}`);
|
|
9728
10587
|
console.log(`\n ${dim("Runs started from the dashboard are logged below. Press Ctrl+C to stop.")}\n`);
|
|
9729
|
-
if (process.env.EVALKIT_NO_OPEN !== "1")
|
|
9730
|
-
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
9731
|
-
try {
|
|
9732
|
-
Bun.spawn([opener, url], {
|
|
9733
|
-
stdout: "ignore",
|
|
9734
|
-
stderr: "ignore"
|
|
9735
|
-
});
|
|
9736
|
-
} catch {}
|
|
9737
|
-
}
|
|
10588
|
+
if (process.env.EVALKIT_NO_OPEN !== "1") openInBrowser(url);
|
|
9738
10589
|
}
|
|
9739
10590
|
const config = text("config").pipe(optional);
|
|
9740
10591
|
const runOptions = {
|
|
@@ -9754,7 +10605,10 @@ const runOptions = {
|
|
|
9754
10605
|
json: boolean("json"),
|
|
9755
10606
|
dryRun: boolean("dry-run"),
|
|
9756
10607
|
all: boolean("all"),
|
|
9757
|
-
local: boolean("local")
|
|
10608
|
+
local: boolean("local"),
|
|
10609
|
+
junit: text("junit").pipe(optional),
|
|
10610
|
+
verbose: boolean("verbose"),
|
|
10611
|
+
skipBroken: boolean("skip-broken")
|
|
9758
10612
|
};
|
|
9759
10613
|
function runInput(options, positionals) {
|
|
9760
10614
|
const unexpected = positionals.find((value) => value.startsWith("-"));
|
|
@@ -9776,7 +10630,10 @@ function runInput(options, positionals) {
|
|
|
9776
10630
|
json: options.json,
|
|
9777
10631
|
"dry-run": options.dryRun,
|
|
9778
10632
|
all: options.all,
|
|
9779
|
-
local: options.local
|
|
10633
|
+
local: options.local,
|
|
10634
|
+
junit: Option.getOrUndefined(options.junit),
|
|
10635
|
+
verbose: options.verbose,
|
|
10636
|
+
"skip-broken": options.skipBroken
|
|
9780
10637
|
}, positionals);
|
|
9781
10638
|
}
|
|
9782
10639
|
const execute = (input) => Effect.tryPromise({
|
|
@@ -9819,6 +10676,12 @@ const buildCommand = make$1("build", {
|
|
|
9819
10676
|
})) await runAllEvals(configPath);
|
|
9820
10677
|
const result = await buildDashboard({
|
|
9821
10678
|
registry: project.registry,
|
|
10679
|
+
root: projectRoot,
|
|
10680
|
+
source: await detectSource(projectRoot, { outputDirs: [
|
|
10681
|
+
reportRoot,
|
|
10682
|
+
sandboxRoot,
|
|
10683
|
+
resolve(projectRoot, "_evalkit-dashboard")
|
|
10684
|
+
] }),
|
|
9822
10685
|
reportRoot,
|
|
9823
10686
|
dashboardRoot: dashboardAssets(),
|
|
9824
10687
|
outDir: resolve(projectRoot, Option.getOrElse(out, () => "_evalkit-dashboard"))
|
|
@@ -9835,8 +10698,13 @@ const buildCommand = make$1("build", {
|
|
|
9835
10698
|
async function loadProjectStep(configPath) {
|
|
9836
10699
|
await step("Eval project", () => useProject(configPath), () => {
|
|
9837
10700
|
const where = relative(process.cwd(), projectRoot);
|
|
9838
|
-
|
|
10701
|
+
const broken = project.loadErrors.length;
|
|
10702
|
+
return `${plural(project.registry.evals.length, "eval")} loaded${where ? ` from ${where}` : ""}${broken ? yellow(` · ${plural(broken, "file")} failed to load`) : ""}`;
|
|
9839
10703
|
});
|
|
10704
|
+
if (project.loadErrors.length) {
|
|
10705
|
+
console.log("");
|
|
10706
|
+
printLoadErrors(project.loadErrors);
|
|
10707
|
+
}
|
|
9840
10708
|
}
|
|
9841
10709
|
/**
|
|
9842
10710
|
* A snapshot needs data: with no current results, run the evals. Otherwise ask,
|
|
@@ -9894,10 +10762,7 @@ const shareServeCommand = make$1("serve", {
|
|
|
9894
10762
|
});
|
|
9895
10763
|
let server;
|
|
9896
10764
|
try {
|
|
9897
|
-
server =
|
|
9898
|
-
port,
|
|
9899
|
-
fetch: app.fetch
|
|
9900
|
-
});
|
|
10765
|
+
server = await serve(app.fetch, port);
|
|
9901
10766
|
} catch (error) {
|
|
9902
10767
|
if (error.code === "EADDRINUSE") throw new Error(`Port ${port} is already in use. Choose another: evalkit share serve --port 4319`);
|
|
9903
10768
|
throw error;
|
|
@@ -9957,13 +10822,118 @@ const shareCommand = make$1("share", {
|
|
|
9957
10822
|
},
|
|
9958
10823
|
catch: (error) => error
|
|
9959
10824
|
})).pipe(withSubcommands([shareServeCommand]));
|
|
10825
|
+
const compareCommand = make$1("compare", {
|
|
10826
|
+
config,
|
|
10827
|
+
base: text("base").pipe(optional),
|
|
10828
|
+
baseDir: text("base-dir").pipe(optional),
|
|
10829
|
+
markdown: text("markdown").pipe(optional),
|
|
10830
|
+
json: boolean("json"),
|
|
10831
|
+
failOn: text("fail-on").pipe(repeated),
|
|
10832
|
+
requireBase: boolean("require-base"),
|
|
10833
|
+
allowDirty: boolean("allow-dirty")
|
|
10834
|
+
}, (options) => Effect.tryPromise({
|
|
10835
|
+
try: () => compareCommandAction({
|
|
10836
|
+
config: Option.getOrUndefined(options.config),
|
|
10837
|
+
base: Option.getOrUndefined(options.base),
|
|
10838
|
+
baseDir: Option.getOrUndefined(options.baseDir),
|
|
10839
|
+
markdown: Option.getOrUndefined(options.markdown),
|
|
10840
|
+
json: options.json,
|
|
10841
|
+
failOn: options.failOn,
|
|
10842
|
+
requireBase: options.requireBase,
|
|
10843
|
+
allowDirty: options.allowDirty
|
|
10844
|
+
}),
|
|
10845
|
+
catch: (error) => error
|
|
10846
|
+
}));
|
|
10847
|
+
async function compareCommandAction(options) {
|
|
10848
|
+
if (Boolean(options.base) === Boolean(options.baseDir)) throw new Error("Give exactly one of --base <ref> or --base-dir <dir>");
|
|
10849
|
+
const conditions = parseFailOn(options.failOn);
|
|
10850
|
+
const say = options.json ? () => {} : (line = "") => console.log(line);
|
|
10851
|
+
if (!options.json) banner("compare", VERSION);
|
|
10852
|
+
await useProject(options.config);
|
|
10853
|
+
const registry = project.registry;
|
|
10854
|
+
const head = await detectSource(projectRoot, { outputDirs: [
|
|
10855
|
+
reportRoot,
|
|
10856
|
+
sandboxRoot,
|
|
10857
|
+
resolve(projectRoot, "_evalkit-dashboard")
|
|
10858
|
+
] });
|
|
10859
|
+
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.");
|
|
10860
|
+
const headSide = headRuns(await createDashboardReportReader(reportRoot, registry).listRuns(), head, { allowDirty: options.allowDirty });
|
|
10861
|
+
let base;
|
|
10862
|
+
if (options.baseDir) {
|
|
10863
|
+
const runs = await baseRunsFromDirectory(resolve(process.cwd(), options.baseDir), registry);
|
|
10864
|
+
base = runs.length ? {
|
|
10865
|
+
label: options.baseDir,
|
|
10866
|
+
runs
|
|
10867
|
+
} : void 0;
|
|
10868
|
+
} else {
|
|
10869
|
+
const serverUrl = process.env.EVALKIT_SHARE_URL ?? project.config.share?.url;
|
|
10870
|
+
if (!serverUrl) throw new Error("No share server configured for --base. Set share: { url } in evalkit.config.ts or EVALKIT_SHARE_URL, or use --base-dir.");
|
|
10871
|
+
const found = await baseRunsFromServer({
|
|
10872
|
+
serverUrl,
|
|
10873
|
+
headers: await resolveShareHeaders(project.config.share?.headers),
|
|
10874
|
+
head,
|
|
10875
|
+
ref: options.base
|
|
10876
|
+
});
|
|
10877
|
+
base = found?.runs.length ? {
|
|
10878
|
+
label: `${found.commit.slice(0, 7)}${found.ref ? ` (${found.ref})` : ""}`,
|
|
10879
|
+
runs: found.runs
|
|
10880
|
+
} : void 0;
|
|
10881
|
+
}
|
|
10882
|
+
const headLabel = `${shortSource(head)}${head.ref ? ` (${head.ref})` : ""}`;
|
|
10883
|
+
if (!base) {
|
|
10884
|
+
const message = `No shared results for base ${options.base ?? options.baseDir}; nothing to compare.`;
|
|
10885
|
+
if (options.json) console.log(JSON.stringify({
|
|
10886
|
+
base: null,
|
|
10887
|
+
head: headLabel,
|
|
10888
|
+
message
|
|
10889
|
+
}));
|
|
10890
|
+
else say(` ${yellow("◇")} ${message}\n`);
|
|
10891
|
+
if (options.requireBase) process.exitCode = 1;
|
|
10892
|
+
return;
|
|
10893
|
+
}
|
|
10894
|
+
if (!headSide.length) throw new Error(`No results at ${headLabel} to compare. Run \`evalkit run\` first${head.dirty ? ", or pass --allow-dirty" : ""}.`);
|
|
10895
|
+
const comparison = compareRuns(base.runs, headSide);
|
|
10896
|
+
const failures = gateFailures(comparison, conditions);
|
|
10897
|
+
if (options.markdown) await writeFile(resolve(process.cwd(), options.markdown), comparisonMarkdown(comparison, {
|
|
10898
|
+
base: base.label,
|
|
10899
|
+
head: headLabel
|
|
10900
|
+
}));
|
|
10901
|
+
if (options.json) console.log(JSON.stringify({
|
|
10902
|
+
base: base.label,
|
|
10903
|
+
head: headLabel,
|
|
10904
|
+
...comparison,
|
|
10905
|
+
failures
|
|
10906
|
+
}));
|
|
10907
|
+
else {
|
|
10908
|
+
const lines = comparisonLines(comparison);
|
|
10909
|
+
say(` base ${bold(base.label)} → head ${bold(headLabel)}\n`);
|
|
10910
|
+
const marks = {
|
|
10911
|
+
regressed: red("✗"),
|
|
10912
|
+
fixed: green("✓"),
|
|
10913
|
+
"not-comparable": yellow("◌")
|
|
10914
|
+
};
|
|
10915
|
+
for (const { change, labels } of lines.units) say(` ${marks[change]} ${`${labels.length} ${change.replace("-", " ")}`.padEnd(18)} ${labels.join(", ")}`);
|
|
10916
|
+
say(` ${dim(lines.counts)}`);
|
|
10917
|
+
const ignored = comparison.ignoredRuns.base + comparison.ignoredRuns.head;
|
|
10918
|
+
if (ignored) say(` ${dim(`${plural(ignored, "older run")} of the same scenarios ignored`)}`);
|
|
10919
|
+
say("");
|
|
10920
|
+
for (const line of lines.suites) say(` ${line}`);
|
|
10921
|
+
if (lines.datasets) say(` ${lines.datasets}`);
|
|
10922
|
+
say("");
|
|
10923
|
+
for (const failure of failures) say(` ${red("✗")} ${failure}`);
|
|
10924
|
+
if (options.markdown) say(` ${dim(`Markdown: ${options.markdown}`)}`);
|
|
10925
|
+
if (failures.length || options.markdown) say("");
|
|
10926
|
+
}
|
|
10927
|
+
if (failures.length) process.exitCode = 1;
|
|
10928
|
+
}
|
|
9960
10929
|
const cli = run(make$1("evalkit").pipe(withSubcommands([
|
|
9961
10930
|
newCommand,
|
|
9962
10931
|
runCommand,
|
|
9963
10932
|
devCommand,
|
|
9964
10933
|
buildCommand,
|
|
9965
10934
|
tidyCommand,
|
|
9966
|
-
shareCommand
|
|
10935
|
+
shareCommand,
|
|
10936
|
+
compareCommand
|
|
9967
10937
|
])), {
|
|
9968
10938
|
name: "EvalKit",
|
|
9969
10939
|
version: VERSION
|
|
@@ -9972,6 +10942,7 @@ const argv = process.argv.slice();
|
|
|
9972
10942
|
if (argv[2] === "help") argv.splice(2, 1, "--help");
|
|
9973
10943
|
if (argv.at(-2) === "--" && argv.at(-1) === "--help") argv.splice(-2, 1);
|
|
9974
10944
|
try {
|
|
10945
|
+
assertSupportedRuntime();
|
|
9975
10946
|
await Effect.runPromise(cli(argv).pipe(Effect.provide(layer)));
|
|
9976
10947
|
} catch (error) {
|
|
9977
10948
|
console.error(error instanceof Error ? error.message : String(error));
|
|
@@ -9979,5 +10950,3 @@ try {
|
|
|
9979
10950
|
}
|
|
9980
10951
|
//#endregion
|
|
9981
10952
|
export {};
|
|
9982
|
-
|
|
9983
|
-
//# sourceMappingURL=cli.mjs.map
|