@sverka/arena 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.d.mts +23 -1
- package/dist/bin.d.mts.map +1 -1
- package/dist/bin.mjs +275 -7
- package/dist/bin.mjs.map +1 -1
- package/dist/index.d.mts +299 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{aggregate-report-BkLMmG16.mjs → pack-DoL1QL1y.mjs} +1445 -9
- package/dist/pack-DoL1QL1y.mjs.map +1 -0
- package/package.json +4 -4
- package/dist/aggregate-report-BkLMmG16.mjs.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { cp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
-
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
1
|
+
import { execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { chmod, cp, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import { Readable, Writable } from "node:stream";
|
|
@@ -9,6 +9,7 @@ import * as acp from "@agentclientprotocol/sdk";
|
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { createHtmlRenderer } from "@sverka/reporter";
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
12
13
|
//#region src/acp.ts
|
|
13
14
|
/**
|
|
14
15
|
* Shared ACP (Agent Client Protocol) utilities — extracted from the Devin
|
|
@@ -1526,10 +1527,12 @@ function computeAnalysis(results, tasks) {
|
|
|
1526
1527
|
*/
|
|
1527
1528
|
var ArenaError = class extends Error {
|
|
1528
1529
|
code;
|
|
1529
|
-
|
|
1530
|
+
cause;
|
|
1531
|
+
constructor(message, code, cause) {
|
|
1530
1532
|
super(message);
|
|
1531
1533
|
this.code = code;
|
|
1532
1534
|
this.name = "ArenaError";
|
|
1535
|
+
if (cause !== void 0) this.cause = cause;
|
|
1533
1536
|
}
|
|
1534
1537
|
};
|
|
1535
1538
|
const ADAPTERS = { devin: () => new DevinAdapter() };
|
|
@@ -1735,14 +1738,14 @@ function redact(text) {
|
|
|
1735
1738
|
}
|
|
1736
1739
|
return words.join(" ").replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
|
|
1737
1740
|
}
|
|
1738
|
-
function formatTokens(n) {
|
|
1741
|
+
function formatTokens$1(n) {
|
|
1739
1742
|
return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
|
1740
1743
|
}
|
|
1741
1744
|
function metricsLine(result) {
|
|
1742
1745
|
const m = result.metrics;
|
|
1743
1746
|
if (m === void 0) return "no metrics recorded";
|
|
1744
1747
|
const judge = result.verdicts?.length ? `, judge ${Math.round(result.verdicts.reduce((a, v) => a + v.score, 0) / result.verdicts.length)}` : "";
|
|
1745
|
-
return `${formatTokens(m.totalTokens)} tokens, ${m.toolCallCount} tool calls, ${m.llmCallCount} llm calls${judge}`;
|
|
1748
|
+
return `${formatTokens$1(m.totalTokens)} tokens, ${m.toolCallCount} tool calls, ${m.llmCallCount} llm calls${judge}`;
|
|
1746
1749
|
}
|
|
1747
1750
|
function failureReason(result) {
|
|
1748
1751
|
const failed = (result.checkResults ?? []).filter((c) => !c.passed);
|
|
@@ -1892,6 +1895,1439 @@ function writeAggregateReport(result, outputPath, context) {
|
|
|
1892
1895
|
renderer.flush();
|
|
1893
1896
|
}
|
|
1894
1897
|
//#endregion
|
|
1895
|
-
|
|
1898
|
+
//#region src/internal/git.ts
|
|
1899
|
+
/**
|
|
1900
|
+
* Internal git seam — the only place the arena spawns `git`. Tests mock
|
|
1901
|
+
* this module (vi.mock) to script push conflicts deterministically; it is
|
|
1902
|
+
* deliberately not exported from the package index.
|
|
1903
|
+
*/
|
|
1904
|
+
const MAX_BUFFER = 8388608;
|
|
1905
|
+
/** Run `git <args>`; resolves with the exit code, never rejects on non-zero. */
|
|
1906
|
+
function git(args, opts = {}) {
|
|
1907
|
+
return new Promise((resolve, reject) => {
|
|
1908
|
+
execFile("git", [...args], {
|
|
1909
|
+
cwd: opts.cwd,
|
|
1910
|
+
env: {
|
|
1911
|
+
...process.env,
|
|
1912
|
+
...opts.env
|
|
1913
|
+
},
|
|
1914
|
+
maxBuffer: MAX_BUFFER
|
|
1915
|
+
}, (error, stdout, stderr) => {
|
|
1916
|
+
if (error !== null && typeof error.code !== "number") {
|
|
1917
|
+
reject(error);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
resolve({
|
|
1921
|
+
code: typeof error?.code === "number" ? error.code : 0,
|
|
1922
|
+
stdout: String(stdout),
|
|
1923
|
+
stderr: String(stderr)
|
|
1924
|
+
});
|
|
1925
|
+
});
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
/** Args carrying credentials must never reach an error message (CWE-209). */
|
|
1929
|
+
function redactArgs(args) {
|
|
1930
|
+
return args.map((a) => /authorization|bearer|credential|token=/i.test(a) ? "<redacted>" : a).join(" ");
|
|
1931
|
+
}
|
|
1932
|
+
/** Run `git <args>`; throws a plain Error with stderr context on non-zero. */
|
|
1933
|
+
async function gitOrThrow(args, opts = {}) {
|
|
1934
|
+
const res = await git(args, opts);
|
|
1935
|
+
if (res.code !== 0) throw new Error(`git ${redactArgs(args)} failed (exit ${res.code}): ${res.stderr.trim()}`);
|
|
1936
|
+
return res;
|
|
1937
|
+
}
|
|
1938
|
+
//#endregion
|
|
1939
|
+
//#region src/registry.ts
|
|
1940
|
+
/**
|
|
1941
|
+
* Arena results registry — `arena.result/v1` documents committed to an
|
|
1942
|
+
* append-only store, plus the `ArenaRegistry` read/write contract.
|
|
1943
|
+
* Spec: specs/56-arena-eval-service.
|
|
1944
|
+
*
|
|
1945
|
+
* Registry tree layout:
|
|
1946
|
+
*
|
|
1947
|
+
* results/<pack>/<agent>/<YYYY-MM-DD>/<runId>.json
|
|
1948
|
+
* traces/<runId>/<task>.<run-index>.trace.jsonl
|
|
1949
|
+
* packs/<name>/pack.json + tasks/*.json
|
|
1950
|
+
* index.json — denormalized pack → runs; readers
|
|
1951
|
+
* never scan the results/ tree
|
|
1952
|
+
*
|
|
1953
|
+
* Backends: file (local dir — a git checkout or plain tree), git
|
|
1954
|
+
* (clone → write → commit → push), s3 (put-object via an injectable
|
|
1955
|
+
* client — no AWS SDK dependency in tests).
|
|
1956
|
+
*/
|
|
1957
|
+
const taskResultSchema = z.object({
|
|
1958
|
+
task: z.string().min(1),
|
|
1959
|
+
promptHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
1960
|
+
score: z.object({
|
|
1961
|
+
passed: z.boolean(),
|
|
1962
|
+
findings: z.number().int().nonnegative()
|
|
1963
|
+
}),
|
|
1964
|
+
metrics: z.object({
|
|
1965
|
+
tokens: z.number().optional(),
|
|
1966
|
+
toolCalls: z.number().optional(),
|
|
1967
|
+
durationMs: z.number(),
|
|
1968
|
+
stopReason: z.string().optional()
|
|
1969
|
+
}),
|
|
1970
|
+
traceRef: z.string().optional()
|
|
1971
|
+
});
|
|
1972
|
+
const arenaResultV1Schema = z.object({
|
|
1973
|
+
schema: z.literal("arena.result/v1"),
|
|
1974
|
+
runId: z.string().min(1),
|
|
1975
|
+
pack: z.string().min(1),
|
|
1976
|
+
agent: z.string().min(1),
|
|
1977
|
+
model: z.string().min(1),
|
|
1978
|
+
plugins: z.array(z.string()),
|
|
1979
|
+
sverkaVersion: z.string().min(1),
|
|
1980
|
+
startedAt: z.iso.datetime(),
|
|
1981
|
+
tasks: z.array(taskResultSchema)
|
|
1982
|
+
});
|
|
1983
|
+
/** Validate a parsed value as arena.result/v1 — names failing fields. */
|
|
1984
|
+
function parseArenaResultV1(doc) {
|
|
1985
|
+
const parsed = arenaResultV1Schema.safeParse(doc);
|
|
1986
|
+
if (!parsed.success) throw new ArenaError(`invalid arena.result/v1 document:\n${parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n")}`, "SCHEMA_INVALID");
|
|
1987
|
+
return parsed.data;
|
|
1988
|
+
}
|
|
1989
|
+
/** Registry path segments must not escape or nest the layout. */
|
|
1990
|
+
function checkSegment(value, field) {
|
|
1991
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value) || value === "." || value === "..") throw new ArenaError(`invalid ${field} '${value}' — registry path segments must match [a-zA-Z0-9._-]+ and not start with a dot`, "SCHEMA_INVALID");
|
|
1992
|
+
}
|
|
1993
|
+
/** Canonical result path: results/<pack>/<agent>/<YYYY-MM-DD>/<runId>.json */
|
|
1994
|
+
function resultPath(doc, date) {
|
|
1995
|
+
checkSegment(doc.pack, "pack");
|
|
1996
|
+
checkSegment(doc.agent, "agent");
|
|
1997
|
+
checkSegment(doc.runId, "runId");
|
|
1998
|
+
const day = date ?? doc.startedAt.slice(0, 10);
|
|
1999
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) throw new ArenaError(`invalid date partition '${day}' — expected YYYY-MM-DD`, "SCHEMA_INVALID");
|
|
2000
|
+
return `results/${doc.pack}/${doc.agent}/${day}/${doc.runId}.json`;
|
|
2001
|
+
}
|
|
2002
|
+
/** sha256 of a task prompt — the comparability cohort key. */
|
|
2003
|
+
function promptHash(prompt) {
|
|
2004
|
+
return createHash("sha256").update(prompt, "utf8").digest("hex");
|
|
2005
|
+
}
|
|
2006
|
+
const registries = /* @__PURE__ */ new WeakMap();
|
|
2007
|
+
function createFileTree(dir) {
|
|
2008
|
+
const unavailable = (what, cause) => new ArenaError(`registry unavailable — ${what} under ${dir}`, "REGISTRY_UNAVAILABLE", cause);
|
|
2009
|
+
return {
|
|
2010
|
+
async readFile(rel) {
|
|
2011
|
+
try {
|
|
2012
|
+
return await readFile(join(dir, rel), "utf8");
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
if (err.code === "ENOENT") return null;
|
|
2015
|
+
throw unavailable(`cannot read ${rel}`, err);
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
async writeFile(rel, data) {
|
|
2019
|
+
const target = join(dir, rel);
|
|
2020
|
+
try {
|
|
2021
|
+
await mkdir(join(target, ".."), { recursive: true });
|
|
2022
|
+
await writeFile(target, data, {
|
|
2023
|
+
encoding: "utf8",
|
|
2024
|
+
mode: 384
|
|
2025
|
+
});
|
|
2026
|
+
} catch (err) {
|
|
2027
|
+
throw unavailable(`cannot write ${rel}`, err);
|
|
2028
|
+
}
|
|
2029
|
+
},
|
|
2030
|
+
async listFiles(relPrefix) {
|
|
2031
|
+
const out = [];
|
|
2032
|
+
const walk = async (rel) => {
|
|
2033
|
+
let entries;
|
|
2034
|
+
try {
|
|
2035
|
+
entries = await readdir(join(dir, rel), { withFileTypes: true });
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
if (err.code === "ENOENT") return;
|
|
2038
|
+
throw unavailable(`cannot list ${rel}`, err);
|
|
2039
|
+
}
|
|
2040
|
+
for (const e of entries) {
|
|
2041
|
+
const child = rel === "" ? e.name : `${rel}/${e.name}`;
|
|
2042
|
+
if (e.isDirectory()) await walk(child);
|
|
2043
|
+
else if (e.isFile()) out.push(child);
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
await walk(relPrefix);
|
|
2047
|
+
return out;
|
|
2048
|
+
},
|
|
2049
|
+
async finalize() {}
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
function gitRegistryDir(cfg) {
|
|
2053
|
+
return cfg.dir ?? join(tmpdir(), `arena-registry-${createHash("sha256").update(cfg.url).digest("hex").slice(0, 12)}`);
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Auth is injected through GIT_CONFIG_* env vars, never argv — a
|
|
2057
|
+
* `-c http.extraHeader=...` argument would expose the bearer token to
|
|
2058
|
+
* other users on the host via the process list (CWE-214).
|
|
2059
|
+
*/
|
|
2060
|
+
function gitAuthEnv(cfg) {
|
|
2061
|
+
return cfg.token !== void 0 && /^https?:\/\//.test(cfg.url) ? {
|
|
2062
|
+
GIT_CONFIG_COUNT: "1",
|
|
2063
|
+
GIT_CONFIG_KEY_0: "http.extraHeader",
|
|
2064
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: bearer ${cfg.token}`
|
|
2065
|
+
} : {};
|
|
2066
|
+
}
|
|
2067
|
+
const gitUnavailable = (cfg, dir) => (what, cause) => new ArenaError(`registry unavailable — ${what} (${cfg.url} → ${dir})`, "REGISTRY_UNAVAILABLE", cause);
|
|
2068
|
+
const checkouts = /* @__PURE__ */ new Map();
|
|
2069
|
+
/**
|
|
2070
|
+
* Clone-or-refresh a git registry checkout (memoized per dir). Soft on
|
|
2071
|
+
* refresh when a checkout exists — a stale local view stays readable;
|
|
2072
|
+
* publish does its own pull --rebase anyway.
|
|
2073
|
+
*/
|
|
2074
|
+
function ensureGitCheckout(cfg) {
|
|
2075
|
+
const dir = gitRegistryDir(cfg);
|
|
2076
|
+
const branch = cfg.branch ?? "main";
|
|
2077
|
+
const auth = gitAuthEnv(cfg);
|
|
2078
|
+
const unavailable = gitUnavailable(cfg, dir);
|
|
2079
|
+
let p = checkouts.get(dir);
|
|
2080
|
+
if (p === void 0) {
|
|
2081
|
+
p = (async () => {
|
|
2082
|
+
if (cfg.dir === void 0) {
|
|
2083
|
+
await mkdir(dir, {
|
|
2084
|
+
recursive: true,
|
|
2085
|
+
mode: 448
|
|
2086
|
+
});
|
|
2087
|
+
await chmod(dir, 448);
|
|
2088
|
+
}
|
|
2089
|
+
if (existsSync(join(dir, ".git"))) {
|
|
2090
|
+
if (existsSync(join(dir, ".git", "rebase-merge")) || existsSync(join(dir, ".git", "rebase-apply"))) await git([
|
|
2091
|
+
"-C",
|
|
2092
|
+
dir,
|
|
2093
|
+
"rebase",
|
|
2094
|
+
"--abort"
|
|
2095
|
+
], { env: auth });
|
|
2096
|
+
const head = await git([
|
|
2097
|
+
"-C",
|
|
2098
|
+
dir,
|
|
2099
|
+
"symbolic-ref",
|
|
2100
|
+
"-q",
|
|
2101
|
+
"--short",
|
|
2102
|
+
"HEAD"
|
|
2103
|
+
], { env: auth });
|
|
2104
|
+
if (head.code === 0 && head.stdout.trim() === branch) await git([
|
|
2105
|
+
"-C",
|
|
2106
|
+
dir,
|
|
2107
|
+
"pull",
|
|
2108
|
+
"--ff-only",
|
|
2109
|
+
"origin",
|
|
2110
|
+
branch
|
|
2111
|
+
], { env: auth });
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
try {
|
|
2115
|
+
await gitOrThrow([
|
|
2116
|
+
"clone",
|
|
2117
|
+
...cfg.branch !== void 0 ? ["--branch", cfg.branch] : [],
|
|
2118
|
+
cfg.url,
|
|
2119
|
+
dir
|
|
2120
|
+
], { env: auth });
|
|
2121
|
+
} catch (err) {
|
|
2122
|
+
throw unavailable(`git clone failed`, err);
|
|
2123
|
+
}
|
|
2124
|
+
})();
|
|
2125
|
+
checkouts.set(dir, p);
|
|
2126
|
+
p.catch(() => {
|
|
2127
|
+
if (checkouts.get(dir) === p) checkouts.delete(dir);
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
return p;
|
|
2131
|
+
}
|
|
2132
|
+
const GIT_IDENTITY = [
|
|
2133
|
+
"-c",
|
|
2134
|
+
"user.name=sverka-arena",
|
|
2135
|
+
"-c",
|
|
2136
|
+
"user.email=arena@sverka.dev"
|
|
2137
|
+
];
|
|
2138
|
+
const gitOps = /* @__PURE__ */ new Map();
|
|
2139
|
+
/**
|
|
2140
|
+
* Serialize git-mutating operations per checkout dir — a refresh pull
|
|
2141
|
+
* racing publish's add/commit would fail on index.lock, and a stopped
|
|
2142
|
+
* rebase observed under the lock is dead (a live one holds the queue
|
|
2143
|
+
* through finalize), so the holder may safely abort it.
|
|
2144
|
+
*/
|
|
2145
|
+
function serializedGitOp(dir, fn) {
|
|
2146
|
+
const run = (gitOps.get(dir) ?? Promise.resolve()).then(fn);
|
|
2147
|
+
gitOps.set(dir, run.then(() => void 0, () => void 0));
|
|
2148
|
+
return run;
|
|
2149
|
+
}
|
|
2150
|
+
function createGitTree(cfg) {
|
|
2151
|
+
const dir = gitRegistryDir(cfg);
|
|
2152
|
+
const branch = cfg.branch ?? "main";
|
|
2153
|
+
const auth = gitAuthEnv(cfg);
|
|
2154
|
+
const inner = createFileTree(dir);
|
|
2155
|
+
const unavailable = gitUnavailable(cfg, dir);
|
|
2156
|
+
const ensure = () => ensureGitCheckout(cfg);
|
|
2157
|
+
const rebaseInProgress = () => existsSync(join(dir, ".git", "rebase-merge")) || existsSync(join(dir, ".git", "rebase-apply"));
|
|
2158
|
+
/**
|
|
2159
|
+
* Best-effort re-sync before a read — one `pull --ff-only` per call,
|
|
2160
|
+
* serialized against publish's git ops on this checkout. Every step
|
|
2161
|
+
* is soft: a stopped rebase found here is dead (a live publish holds
|
|
2162
|
+
* the queue) so it's aborted; a non-ff/dirty/offline pull just leaves
|
|
2163
|
+
* the stale view, which stays readable — publish reconciles through
|
|
2164
|
+
* its own pull --rebase.
|
|
2165
|
+
*/
|
|
2166
|
+
async function refresh() {
|
|
2167
|
+
await serializedGitOp(dir, async () => {
|
|
2168
|
+
await ensure();
|
|
2169
|
+
if (rebaseInProgress()) await git([
|
|
2170
|
+
"-C",
|
|
2171
|
+
dir,
|
|
2172
|
+
"rebase",
|
|
2173
|
+
"--abort"
|
|
2174
|
+
], { env: auth });
|
|
2175
|
+
const head = await git([
|
|
2176
|
+
"-C",
|
|
2177
|
+
dir,
|
|
2178
|
+
"symbolic-ref",
|
|
2179
|
+
"-q",
|
|
2180
|
+
"--short",
|
|
2181
|
+
"HEAD"
|
|
2182
|
+
], { env: auth });
|
|
2183
|
+
if (head.code === 0 && head.stdout.trim() === branch) await git([
|
|
2184
|
+
"-C",
|
|
2185
|
+
dir,
|
|
2186
|
+
"pull",
|
|
2187
|
+
"--ff-only",
|
|
2188
|
+
"origin",
|
|
2189
|
+
branch
|
|
2190
|
+
], { env: auth });
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Recover a stopped rebase. Two publishers racing always collide on
|
|
2195
|
+
* index.json — each commit rewrites it — while result/trace paths are
|
|
2196
|
+
* per-run and never conflict. index.json is a pure function of the
|
|
2197
|
+
* results/ tree (reindex semantics), so a conflicted rebase whose only
|
|
2198
|
+
* unmerged path is index.json is resolved by regenerating it and
|
|
2199
|
+
* continuing. Anything else is a real conflict: give up and let the
|
|
2200
|
+
* caller abort.
|
|
2201
|
+
*/
|
|
2202
|
+
async function resolveRebaseConflicts() {
|
|
2203
|
+
while (rebaseInProgress()) {
|
|
2204
|
+
const unmerged = await git([
|
|
2205
|
+
"-C",
|
|
2206
|
+
dir,
|
|
2207
|
+
"diff",
|
|
2208
|
+
"--name-only",
|
|
2209
|
+
"--diff-filter=U"
|
|
2210
|
+
], { env: auth });
|
|
2211
|
+
if (unmerged.code !== 0) return false;
|
|
2212
|
+
const paths = unmerged.stdout.split("\n").filter((l) => l !== "");
|
|
2213
|
+
if (paths.length === 0 || paths.some((p) => p !== INDEX_PATH)) return false;
|
|
2214
|
+
const { index } = await buildIndex(inner);
|
|
2215
|
+
await writeIndex(inner, index);
|
|
2216
|
+
if ((await git([
|
|
2217
|
+
"-C",
|
|
2218
|
+
dir,
|
|
2219
|
+
"add",
|
|
2220
|
+
"--",
|
|
2221
|
+
INDEX_PATH
|
|
2222
|
+
], { env: auth })).code !== 0) return false;
|
|
2223
|
+
await git([
|
|
2224
|
+
"-C",
|
|
2225
|
+
dir,
|
|
2226
|
+
...GIT_IDENTITY,
|
|
2227
|
+
"rebase",
|
|
2228
|
+
"--continue"
|
|
2229
|
+
], { env: {
|
|
2230
|
+
...auth,
|
|
2231
|
+
GIT_EDITOR: "true"
|
|
2232
|
+
} });
|
|
2233
|
+
}
|
|
2234
|
+
return !rebaseInProgress();
|
|
2235
|
+
}
|
|
2236
|
+
async function pullRebase() {
|
|
2237
|
+
const res = await git([
|
|
2238
|
+
"-C",
|
|
2239
|
+
dir,
|
|
2240
|
+
...GIT_IDENTITY,
|
|
2241
|
+
"pull",
|
|
2242
|
+
"--rebase",
|
|
2243
|
+
"origin",
|
|
2244
|
+
branch
|
|
2245
|
+
], { env: auth });
|
|
2246
|
+
if (res.code === 0) return;
|
|
2247
|
+
if (!rebaseInProgress()) throw unavailable(`git pull --rebase failed`, res.stderr.trim());
|
|
2248
|
+
if (!await resolveRebaseConflicts().catch(() => false)) {
|
|
2249
|
+
await git([
|
|
2250
|
+
"-C",
|
|
2251
|
+
dir,
|
|
2252
|
+
"rebase",
|
|
2253
|
+
"--abort"
|
|
2254
|
+
], { env: auth });
|
|
2255
|
+
throw unavailable(`git pull --rebase failed`, res.stderr.trim());
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
async function push() {
|
|
2259
|
+
return git([
|
|
2260
|
+
"-C",
|
|
2261
|
+
dir,
|
|
2262
|
+
"push",
|
|
2263
|
+
"origin",
|
|
2264
|
+
`HEAD:${branch}`
|
|
2265
|
+
], { env: auth });
|
|
2266
|
+
}
|
|
2267
|
+
return {
|
|
2268
|
+
dir,
|
|
2269
|
+
async readFile(rel) {
|
|
2270
|
+
await ensure();
|
|
2271
|
+
return inner.readFile(rel);
|
|
2272
|
+
},
|
|
2273
|
+
async writeFile(rel, data) {
|
|
2274
|
+
await ensure();
|
|
2275
|
+
return inner.writeFile(rel, data);
|
|
2276
|
+
},
|
|
2277
|
+
async listFiles(relPrefix) {
|
|
2278
|
+
await ensure();
|
|
2279
|
+
return inner.listFiles(relPrefix);
|
|
2280
|
+
},
|
|
2281
|
+
refresh,
|
|
2282
|
+
async finalize(message) {
|
|
2283
|
+
await serializedGitOp(dir, async () => {
|
|
2284
|
+
await ensure();
|
|
2285
|
+
try {
|
|
2286
|
+
await gitOrThrow([
|
|
2287
|
+
"-C",
|
|
2288
|
+
dir,
|
|
2289
|
+
"add",
|
|
2290
|
+
"-A"
|
|
2291
|
+
]);
|
|
2292
|
+
} catch (err) {
|
|
2293
|
+
throw unavailable(`git add failed`, err);
|
|
2294
|
+
}
|
|
2295
|
+
if ((await git([
|
|
2296
|
+
"-C",
|
|
2297
|
+
dir,
|
|
2298
|
+
"diff",
|
|
2299
|
+
"--cached",
|
|
2300
|
+
"--quiet"
|
|
2301
|
+
])).code === 0) return;
|
|
2302
|
+
try {
|
|
2303
|
+
await gitOrThrow([
|
|
2304
|
+
"-C",
|
|
2305
|
+
dir,
|
|
2306
|
+
...GIT_IDENTITY,
|
|
2307
|
+
"commit",
|
|
2308
|
+
"-m",
|
|
2309
|
+
message
|
|
2310
|
+
]);
|
|
2311
|
+
} catch (err) {
|
|
2312
|
+
throw unavailable(`git commit failed`, err);
|
|
2313
|
+
}
|
|
2314
|
+
let res = await push();
|
|
2315
|
+
if (res.code !== 0) {
|
|
2316
|
+
await pullRebase();
|
|
2317
|
+
res = await push();
|
|
2318
|
+
if (res.code !== 0) throw new ArenaError(`publish rejected — git push to '${cfg.url}' failed after rebase retry; results are preserved in the local checkout at ${dir}`, "PUBLISH_CONFLICT", res.stderr.trim());
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
function createGitRegistry(cfg) {
|
|
2325
|
+
const tree = createGitTree(cfg);
|
|
2326
|
+
const registry = createRegistry(tree);
|
|
2327
|
+
registries.set(registry, tree);
|
|
2328
|
+
return registry;
|
|
2329
|
+
}
|
|
2330
|
+
/** Computed specifier — keeps the optional AWS peer out of the dep graph. */
|
|
2331
|
+
const S3_SDK_MODULE = "@aws-sdk/client-s3";
|
|
2332
|
+
async function defaultS3Client() {
|
|
2333
|
+
try {
|
|
2334
|
+
return new (await (import(S3_SDK_MODULE))).S3Client({});
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
throw new ArenaError(`s3 registry requires a client — install @aws-sdk/client-s3 or pass { client }`, "REGISTRY_UNAVAILABLE", err);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
async function s3BodyToString(body) {
|
|
2340
|
+
if (body === void 0 || body === null) return "";
|
|
2341
|
+
if (typeof body === "string") return body;
|
|
2342
|
+
if (body instanceof Uint8Array) return new TextDecoder().decode(body);
|
|
2343
|
+
if (typeof body.transformToString === "function") return body.transformToString();
|
|
2344
|
+
if (typeof body[Symbol.asyncIterator] === "function") {
|
|
2345
|
+
const chunks = [];
|
|
2346
|
+
for await (const c of body) chunks.push(c);
|
|
2347
|
+
return new TextDecoder().decode(chunks.reduce((a, c) => {
|
|
2348
|
+
const n = new Uint8Array(a.length + c.length);
|
|
2349
|
+
n.set(a);
|
|
2350
|
+
n.set(c, a.length);
|
|
2351
|
+
return n;
|
|
2352
|
+
}, /* @__PURE__ */ new Uint8Array(0)));
|
|
2353
|
+
}
|
|
2354
|
+
throw new ArenaError("s3 registry: unsupported getObject Body type", "REGISTRY_UNAVAILABLE");
|
|
2355
|
+
}
|
|
2356
|
+
/** Strip trailing '/' without a regex — a `\/+$` match on a hostile
|
|
2357
|
+
* prefix (many trailing slashes + a non-slash tail) backtracks
|
|
2358
|
+
* quadratically (CodeQL js/polynomial-redos). */
|
|
2359
|
+
function stripTrailingSlashes(s) {
|
|
2360
|
+
let end = s.length;
|
|
2361
|
+
while (end > 0 && s.codePointAt(end - 1) === 47) end--;
|
|
2362
|
+
return s.slice(0, end);
|
|
2363
|
+
}
|
|
2364
|
+
function createS3Tree(cfg) {
|
|
2365
|
+
const stripped = cfg.prefix === void 0 ? "" : stripTrailingSlashes(cfg.prefix);
|
|
2366
|
+
const prefix = stripped === "" ? "" : `${stripped}/`;
|
|
2367
|
+
let clientP;
|
|
2368
|
+
const client = () => {
|
|
2369
|
+
clientP ??= cfg.client !== void 0 ? Promise.resolve(cfg.client) : defaultS3Client();
|
|
2370
|
+
return clientP;
|
|
2371
|
+
};
|
|
2372
|
+
const unavailable = (what, cause) => new ArenaError(`s3 registry unavailable — ${what}`, "REGISTRY_UNAVAILABLE", cause);
|
|
2373
|
+
return {
|
|
2374
|
+
async readFile(rel) {
|
|
2375
|
+
const c = await client();
|
|
2376
|
+
if (c.getObject === void 0) throw unavailable(`client has no getObject`, void 0);
|
|
2377
|
+
try {
|
|
2378
|
+
return await s3BodyToString((await c.getObject({
|
|
2379
|
+
Bucket: cfg.bucket,
|
|
2380
|
+
Key: prefix + rel
|
|
2381
|
+
}))?.Body);
|
|
2382
|
+
} catch (err) {
|
|
2383
|
+
const name = err.name ?? "";
|
|
2384
|
+
if (name === "NoSuchKey" || name === "NotFound") return null;
|
|
2385
|
+
if (err instanceof ArenaError) throw err;
|
|
2386
|
+
throw unavailable(`getObject ${rel} failed`, err);
|
|
2387
|
+
}
|
|
2388
|
+
},
|
|
2389
|
+
async writeFile(rel, data) {
|
|
2390
|
+
const c = await client();
|
|
2391
|
+
try {
|
|
2392
|
+
await c.putObject({
|
|
2393
|
+
Bucket: cfg.bucket,
|
|
2394
|
+
Key: prefix + rel,
|
|
2395
|
+
Body: data
|
|
2396
|
+
});
|
|
2397
|
+
} catch (err) {
|
|
2398
|
+
throw unavailable(`putObject ${rel} failed`, err);
|
|
2399
|
+
}
|
|
2400
|
+
},
|
|
2401
|
+
async listFiles(relPrefix) {
|
|
2402
|
+
const c = await client();
|
|
2403
|
+
if (c.listObjectsV2 === void 0) throw unavailable(`client has no listObjectsV2`, void 0);
|
|
2404
|
+
const out = [];
|
|
2405
|
+
let token;
|
|
2406
|
+
try {
|
|
2407
|
+
do {
|
|
2408
|
+
const page = await c.listObjectsV2({
|
|
2409
|
+
Bucket: cfg.bucket,
|
|
2410
|
+
Prefix: prefix + relPrefix,
|
|
2411
|
+
...token !== void 0 ? { ContinuationToken: token } : {}
|
|
2412
|
+
});
|
|
2413
|
+
for (const obj of page.Contents ?? []) if (obj.Key !== void 0) out.push(obj.Key.slice(prefix.length));
|
|
2414
|
+
token = page.IsTruncated === true ? page.NextContinuationToken : void 0;
|
|
2415
|
+
} while (token !== void 0);
|
|
2416
|
+
} catch (err) {
|
|
2417
|
+
throw unavailable(`listObjectsV2 failed`, err);
|
|
2418
|
+
}
|
|
2419
|
+
return out;
|
|
2420
|
+
},
|
|
2421
|
+
async finalize() {}
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
function createS3Registry(cfg) {
|
|
2425
|
+
const tree = createS3Tree(cfg);
|
|
2426
|
+
const registry = createRegistry(tree);
|
|
2427
|
+
registries.set(registry, tree);
|
|
2428
|
+
return registry;
|
|
2429
|
+
}
|
|
2430
|
+
/** Registry over a local directory — a git checkout or a plain tree. */
|
|
2431
|
+
function createFileRegistry(dir) {
|
|
2432
|
+
const tree = createFileTree(resolve(dir));
|
|
2433
|
+
const registry = createRegistry(tree);
|
|
2434
|
+
registries.set(registry, tree);
|
|
2435
|
+
return registry;
|
|
2436
|
+
}
|
|
2437
|
+
const INDEX_PATH = "index.json";
|
|
2438
|
+
async function readIndex(tree) {
|
|
2439
|
+
const raw = await tree.readFile(INDEX_PATH);
|
|
2440
|
+
if (raw === null) return {
|
|
2441
|
+
schema: "arena.index/v1",
|
|
2442
|
+
updatedAt: "",
|
|
2443
|
+
packs: {}
|
|
2444
|
+
};
|
|
2445
|
+
try {
|
|
2446
|
+
const parsed = JSON.parse(raw);
|
|
2447
|
+
if (parsed.schema !== "arena.index/v1" || typeof parsed.packs !== "object" || parsed.packs === null) return {
|
|
2448
|
+
schema: "arena.index/v1",
|
|
2449
|
+
updatedAt: "",
|
|
2450
|
+
packs: {}
|
|
2451
|
+
};
|
|
2452
|
+
return parsed;
|
|
2453
|
+
} catch {
|
|
2454
|
+
return {
|
|
2455
|
+
schema: "arena.index/v1",
|
|
2456
|
+
updatedAt: "",
|
|
2457
|
+
packs: {}
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
async function writeIndex(tree, index) {
|
|
2462
|
+
index.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2463
|
+
await tree.writeFile(INDEX_PATH, JSON.stringify(index, null, 2) + "\n");
|
|
2464
|
+
}
|
|
2465
|
+
/** Own-property lookup — "constructor" is a valid pack name. */
|
|
2466
|
+
function indexPack(index, name) {
|
|
2467
|
+
return Object.hasOwn(index.packs, name) ? index.packs[name] : void 0;
|
|
2468
|
+
}
|
|
2469
|
+
function compareIndexRuns(a, b) {
|
|
2470
|
+
if (a.date !== b.date) return a.date < b.date ? -1 : 1;
|
|
2471
|
+
if (a.runId !== b.runId) return a.runId < b.runId ? -1 : 1;
|
|
2472
|
+
return 0;
|
|
2473
|
+
}
|
|
2474
|
+
async function updateIndex(tree, doc, relPath) {
|
|
2475
|
+
const index = await readIndex(tree);
|
|
2476
|
+
const date = relPath.split("/")[3] ?? doc.startedAt.slice(0, 10);
|
|
2477
|
+
const pack = indexPack(index, doc.pack) ?? { runs: [] };
|
|
2478
|
+
pack.runs = [...pack.runs.filter((r) => r.runId !== doc.runId), {
|
|
2479
|
+
runId: doc.runId,
|
|
2480
|
+
agent: doc.agent,
|
|
2481
|
+
date,
|
|
2482
|
+
path: relPath
|
|
2483
|
+
}];
|
|
2484
|
+
pack.runs.sort(compareIndexRuns);
|
|
2485
|
+
index.packs[doc.pack] = pack;
|
|
2486
|
+
await writeIndex(tree, index);
|
|
2487
|
+
}
|
|
2488
|
+
function matchesQuery(doc, query) {
|
|
2489
|
+
if (query.pack !== void 0 && doc.pack !== query.pack) return false;
|
|
2490
|
+
if (query.agent !== void 0 && doc.agent !== query.agent) return false;
|
|
2491
|
+
if (query.since !== void 0 && Date.parse(doc.startedAt) < Date.parse(query.since)) return false;
|
|
2492
|
+
return true;
|
|
2493
|
+
}
|
|
2494
|
+
function tryParseResult(raw) {
|
|
2495
|
+
if (raw === null) return null;
|
|
2496
|
+
try {
|
|
2497
|
+
const doc = JSON.parse(raw);
|
|
2498
|
+
if (doc?.schema !== "arena.result/v1") return null;
|
|
2499
|
+
return parseArenaResultV1(doc);
|
|
2500
|
+
} catch {
|
|
2501
|
+
return null;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
async function writeResult(tree, doc, opts) {
|
|
2505
|
+
const valid = parseArenaResultV1(doc);
|
|
2506
|
+
const rel = resultPath(valid, opts.date);
|
|
2507
|
+
await tree.writeFile(rel, JSON.stringify(valid, null, 2) + "\n");
|
|
2508
|
+
for (const trace of opts.traces ?? []) {
|
|
2509
|
+
const name = typeof trace === "string" ? basename(trace) : basename(trace.name);
|
|
2510
|
+
checkSegment(name.replace(/\.[^.]+$/, ""), "trace name");
|
|
2511
|
+
const data = typeof trace === "string" ? await readFile(trace, "utf8") : JSON.stringify(trace.data) + "\n";
|
|
2512
|
+
await tree.writeFile(`traces/${valid.runId}/${name}`, data);
|
|
2513
|
+
}
|
|
2514
|
+
await updateIndex(tree, valid, rel);
|
|
2515
|
+
return rel;
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Publish several documents under ONE finalize — a matrix explosion
|
|
2519
|
+
* produces one commit+push, not N. Internal: package code only.
|
|
2520
|
+
*/
|
|
2521
|
+
async function publishBatch(registry, docs, message) {
|
|
2522
|
+
const tree = registries.get(registry);
|
|
2523
|
+
if (tree === void 0) {
|
|
2524
|
+
const paths = [];
|
|
2525
|
+
for (const { doc, opts } of docs) paths.push(await registry.publish(doc, opts));
|
|
2526
|
+
return paths;
|
|
2527
|
+
}
|
|
2528
|
+
const paths = [];
|
|
2529
|
+
for (const { doc, opts } of docs) paths.push(await writeResult(tree, doc, opts ?? {}));
|
|
2530
|
+
await tree.finalize(message);
|
|
2531
|
+
return paths;
|
|
2532
|
+
}
|
|
2533
|
+
function createRegistry(tree) {
|
|
2534
|
+
return {
|
|
2535
|
+
async publish(result, opts = {}) {
|
|
2536
|
+
const rel = await writeResult(tree, result, opts);
|
|
2537
|
+
await tree.finalize(`arena: publish ${result.pack}/${result.agent}/${result.runId}`);
|
|
2538
|
+
return rel;
|
|
2539
|
+
},
|
|
2540
|
+
async list(query = {}) {
|
|
2541
|
+
await tree.refresh?.();
|
|
2542
|
+
const out = [];
|
|
2543
|
+
const index = await readIndex(tree);
|
|
2544
|
+
const packEntries = query.pack !== void 0 ? [[query.pack, indexPack(index, query.pack)]] : Object.entries(index.packs);
|
|
2545
|
+
if (packEntries.some(([, v]) => v !== void 0)) for (const [pack, entry] of packEntries) {
|
|
2546
|
+
if (entry === void 0) continue;
|
|
2547
|
+
for (const run of entry.runs) {
|
|
2548
|
+
const doc = tryParseResult(await tree.readFile(run.path));
|
|
2549
|
+
if (doc !== null && doc.pack === pack && matchesQuery(doc, query)) out.push(doc);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
else for (const rel of await tree.listFiles("results/")) {
|
|
2553
|
+
if (!rel.endsWith(".json")) continue;
|
|
2554
|
+
const doc = tryParseResult(await tree.readFile(rel));
|
|
2555
|
+
if (doc !== null && matchesQuery(doc, query)) out.push(doc);
|
|
2556
|
+
}
|
|
2557
|
+
out.sort((a, b) => a.startedAt < b.startedAt ? -1 : 1);
|
|
2558
|
+
return out;
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Build index.json by scanning the results/ tree — the shared
|
|
2564
|
+
* denormalization pass behind `reindex` and the git backend's
|
|
2565
|
+
* index-conflict resolution.
|
|
2566
|
+
*/
|
|
2567
|
+
async function buildIndex(tree) {
|
|
2568
|
+
const index = {
|
|
2569
|
+
schema: "arena.index/v1",
|
|
2570
|
+
updatedAt: "",
|
|
2571
|
+
packs: {}
|
|
2572
|
+
};
|
|
2573
|
+
let runs = 0;
|
|
2574
|
+
for (const rel of await tree.listFiles("results/")) {
|
|
2575
|
+
if (!rel.endsWith(".json")) continue;
|
|
2576
|
+
const doc = tryParseResult(await tree.readFile(rel));
|
|
2577
|
+
if (doc === null) continue;
|
|
2578
|
+
const parts = rel.split("/");
|
|
2579
|
+
const pack = indexPack(index, doc.pack) ?? { runs: [] };
|
|
2580
|
+
pack.runs.push({
|
|
2581
|
+
runId: doc.runId,
|
|
2582
|
+
agent: doc.agent,
|
|
2583
|
+
date: parts[3] ?? doc.startedAt.slice(0, 10),
|
|
2584
|
+
path: rel
|
|
2585
|
+
});
|
|
2586
|
+
index.packs[doc.pack] = pack;
|
|
2587
|
+
runs++;
|
|
2588
|
+
}
|
|
2589
|
+
for (const pack of Object.values(index.packs)) pack.runs.sort(compareIndexRuns);
|
|
2590
|
+
return {
|
|
2591
|
+
index,
|
|
2592
|
+
runs
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Rebuild index.json by scanning the results/ tree — the CI job that
|
|
2597
|
+
* denormalizes after publish pushes (`arena reindex`).
|
|
2598
|
+
*/
|
|
2599
|
+
async function reindexRegistry(registry) {
|
|
2600
|
+
const tree = registries.get(registry);
|
|
2601
|
+
if (tree === void 0) throw new ArenaError("reindex: registry was not created by @sverka/arena", "REGISTRY_UNAVAILABLE");
|
|
2602
|
+
await tree.refresh?.();
|
|
2603
|
+
const { index, runs } = await buildIndex(tree);
|
|
2604
|
+
await writeIndex(tree, index);
|
|
2605
|
+
await tree.finalize("arena: reindex");
|
|
2606
|
+
return { runs };
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Resolve a registry reference to an ArenaRegistry:
|
|
2610
|
+
* s3://bucket[/prefix] → s3 backend
|
|
2611
|
+
* file:///abs/path | ./dir → file backend
|
|
2612
|
+
* git::<url> | <git-url> → git backend
|
|
2613
|
+
*/
|
|
2614
|
+
function openRegistry(ref, opts = {}) {
|
|
2615
|
+
if (ref.startsWith("s3://")) {
|
|
2616
|
+
const rest = ref.slice(5);
|
|
2617
|
+
const slash = rest.indexOf("/");
|
|
2618
|
+
const bucket = slash === -1 ? rest : rest.slice(0, slash);
|
|
2619
|
+
const prefix = slash === -1 ? void 0 : rest.slice(slash + 1);
|
|
2620
|
+
return createS3Registry({
|
|
2621
|
+
bucket,
|
|
2622
|
+
...prefix !== void 0 && prefix !== "" ? { prefix } : {},
|
|
2623
|
+
...opts.client !== void 0 ? { client: opts.client } : {}
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
const gitRef = ref.startsWith("git::") ? ref.slice(5) : ref;
|
|
2627
|
+
if (/^https?:\/\//.test(gitRef) || gitRef.startsWith("git@") || /^(?:ssh|git):\/\//.test(gitRef) || gitRef.endsWith(".git")) return createGitRegistry({
|
|
2628
|
+
url: gitRef,
|
|
2629
|
+
...opts.token !== void 0 ? { token: opts.token } : {},
|
|
2630
|
+
...opts.dir !== void 0 ? { dir: opts.dir } : {}
|
|
2631
|
+
});
|
|
2632
|
+
return createFileRegistry(ref.startsWith("file://") ? ref.slice(7) : ref);
|
|
2633
|
+
}
|
|
2634
|
+
/**
|
|
2635
|
+
* Resolve a registry ref to a local directory — file refs return their
|
|
2636
|
+
* path, git refs return the (cloned) checkout dir. s3 registries have no
|
|
2637
|
+
* local dir — callers that need one must error themselves.
|
|
2638
|
+
*/
|
|
2639
|
+
async function resolveRegistryDir(ref, opts = {}) {
|
|
2640
|
+
if (ref.startsWith("s3://")) throw new ArenaError(`s3 registry '${ref}' has no local directory`, "REGISTRY_UNAVAILABLE");
|
|
2641
|
+
const gitRef = ref.startsWith("git::") ? ref.slice(5) : ref;
|
|
2642
|
+
if (/^https?:\/\//.test(gitRef) || gitRef.startsWith("git@") || /^(?:ssh|git):\/\//.test(gitRef) || gitRef.endsWith(".git")) {
|
|
2643
|
+
const cfg = {
|
|
2644
|
+
url: gitRef,
|
|
2645
|
+
...opts.token !== void 0 ? { token: opts.token } : {},
|
|
2646
|
+
...opts.dir !== void 0 ? { dir: opts.dir } : {}
|
|
2647
|
+
};
|
|
2648
|
+
await ensureGitCheckout(cfg);
|
|
2649
|
+
return gitRegistryDir(cfg);
|
|
2650
|
+
}
|
|
2651
|
+
const path = ref.startsWith("file://") ? ref.slice(7) : ref;
|
|
2652
|
+
return resolve(path);
|
|
2653
|
+
}
|
|
2654
|
+
//#endregion
|
|
2655
|
+
//#region src/publish.ts
|
|
2656
|
+
/**
|
|
2657
|
+
* Publish matrix `ArenaResult`s to a registry — explodes one matrix
|
|
2658
|
+
* results.json into one `arena.result/v1` document per
|
|
2659
|
+
* (model, plugin-set) cell, then writes result + trace files via the
|
|
2660
|
+
* registry backend. Spec: specs/56-arena-eval-service.
|
|
2661
|
+
*/
|
|
2662
|
+
/** Unique per-document run id — readable, sortable, collision-free. */
|
|
2663
|
+
function newRunId() {
|
|
2664
|
+
return `run-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
|
2665
|
+
}
|
|
2666
|
+
/** Registry-safe file stem for a task id. */
|
|
2667
|
+
function taskStem(taskId) {
|
|
2668
|
+
return taskId.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Trace file name for one run — the run's index inside its cell keeps
|
|
2672
|
+
* repetitions of the same task apart and disambiguates task ids whose
|
|
2673
|
+
* stems collide (`a/b` and `a-b` both stem to `a-b`).
|
|
2674
|
+
*/
|
|
2675
|
+
function traceName(runIndex, taskId) {
|
|
2676
|
+
return `${taskStem(taskId)}.${runIndex}.trace.jsonl`;
|
|
2677
|
+
}
|
|
2678
|
+
function cellsOf(result) {
|
|
2679
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2680
|
+
const cells = [];
|
|
2681
|
+
for (const run of result.results) {
|
|
2682
|
+
const plugins = [...run.pluginIds].sort((a, b) => a.localeCompare(b));
|
|
2683
|
+
const key = JSON.stringify([run.modelId, plugins]);
|
|
2684
|
+
let cell = seen.get(key);
|
|
2685
|
+
if (cell === void 0) {
|
|
2686
|
+
cell = {
|
|
2687
|
+
model: run.modelId,
|
|
2688
|
+
plugins,
|
|
2689
|
+
runs: []
|
|
2690
|
+
};
|
|
2691
|
+
seen.set(key, cell);
|
|
2692
|
+
cells.push(cell);
|
|
2693
|
+
}
|
|
2694
|
+
cell.runs.push(run);
|
|
2695
|
+
}
|
|
2696
|
+
return cells;
|
|
2697
|
+
}
|
|
2698
|
+
function promptFor(result, ctx, taskId) {
|
|
2699
|
+
const analysis = result.analysis;
|
|
2700
|
+
if (analysis !== void 0 && !Array.isArray(analysis)) throw new ArenaError("results.analysis must be an array when present", "SCHEMA_INVALID");
|
|
2701
|
+
const prompt = analysis?.find((a) => a.taskId === taskId)?.prompt ?? ctx.prompts?.[taskId];
|
|
2702
|
+
if (prompt === void 0) throw new ArenaError(`cannot compute promptHash for task '${taskId}' — the prompt is not in results.analysis and no prompts map was supplied`, "SCHEMA_INVALID");
|
|
2703
|
+
return prompt;
|
|
2704
|
+
}
|
|
2705
|
+
function toTaskResult(run, result, ctx, runId, runIndex) {
|
|
2706
|
+
const failedChecks = (run.checkResults ?? []).filter((c) => !c.passed);
|
|
2707
|
+
const hasTrace = (run.trace?.steps?.length ?? 0) > 0;
|
|
2708
|
+
return {
|
|
2709
|
+
task: run.taskId,
|
|
2710
|
+
promptHash: promptHash(promptFor(result, ctx, run.taskId)),
|
|
2711
|
+
score: {
|
|
2712
|
+
passed: run.success,
|
|
2713
|
+
findings: failedChecks.length
|
|
2714
|
+
},
|
|
2715
|
+
metrics: {
|
|
2716
|
+
tokens: run.metrics.totalTokens,
|
|
2717
|
+
toolCalls: run.metrics.toolCallCount,
|
|
2718
|
+
durationMs: run.metrics.executionTimeMs,
|
|
2719
|
+
...run.metrics.stopReason !== void 0 ? { stopReason: run.metrics.stopReason } : {}
|
|
2720
|
+
},
|
|
2721
|
+
...hasTrace ? { traceRef: `traces/${runId}/${traceName(runIndex, run.taskId)}` } : {}
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* Explode a matrix {@link ArenaResult} into one `arena.result/v1`
|
|
2726
|
+
* document per (model, plugin-set) cell.
|
|
2727
|
+
*/
|
|
2728
|
+
function explodeResult(result, ctx) {
|
|
2729
|
+
return cellsOf(result).map((cell, i) => {
|
|
2730
|
+
const runId = ctx.runId?.({
|
|
2731
|
+
model: cell.model,
|
|
2732
|
+
plugins: cell.plugins
|
|
2733
|
+
}, i) ?? newRunId();
|
|
2734
|
+
return {
|
|
2735
|
+
schema: "arena.result/v1",
|
|
2736
|
+
runId,
|
|
2737
|
+
pack: ctx.pack,
|
|
2738
|
+
agent: ctx.agent,
|
|
2739
|
+
model: cell.model,
|
|
2740
|
+
plugins: cell.plugins,
|
|
2741
|
+
sverkaVersion: ctx.sverkaVersion,
|
|
2742
|
+
startedAt: ctx.startedAt ?? result.timestamp,
|
|
2743
|
+
tasks: cell.runs.map((r, k) => toTaskResult(r, result, ctx, runId, k))
|
|
2744
|
+
};
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Trace payloads for one cell — `runs[i]` produced `doc.tasks[i]`, so the
|
|
2749
|
+
* run index keeps each repetition's trace under its own file (a
|
|
2750
|
+
* taskId-keyed Map would collapse repeats to the last run's trace).
|
|
2751
|
+
*/
|
|
2752
|
+
function tracePayloads(runs) {
|
|
2753
|
+
const out = [];
|
|
2754
|
+
runs.forEach((run, i) => {
|
|
2755
|
+
if ((run.trace?.steps?.length ?? 0) > 0) out.push({
|
|
2756
|
+
name: traceName(i, run.taskId),
|
|
2757
|
+
data: run.trace
|
|
2758
|
+
});
|
|
2759
|
+
});
|
|
2760
|
+
return out;
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* Explode a matrix result and publish every cell document (with its
|
|
2764
|
+
* traces) to the registry. Returns the registry-relative result paths.
|
|
2765
|
+
*/
|
|
2766
|
+
async function publishResult(result, registry, ctx) {
|
|
2767
|
+
const docs = explodeResult(result, ctx);
|
|
2768
|
+
const cells = cellsOf(result);
|
|
2769
|
+
return publishBatch(registry, docs.map((doc, i) => ({
|
|
2770
|
+
doc,
|
|
2771
|
+
opts: { traces: tracePayloads(cells[i].runs) }
|
|
2772
|
+
})), `arena: publish ${ctx.pack}/${ctx.agent} (${docs.length} run${docs.length === 1 ? "" : "s"})`);
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Publish a results file — accepts either an `arena.result/v1` document
|
|
2776
|
+
* (or an array of them, published as-is) or a matrix `results.json`
|
|
2777
|
+
* (exploded per cell). Returns the registry-relative result paths.
|
|
2778
|
+
*/
|
|
2779
|
+
async function publishFile(file, registry, ctx) {
|
|
2780
|
+
let parsed;
|
|
2781
|
+
try {
|
|
2782
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
2783
|
+
} catch (err) {
|
|
2784
|
+
throw new ArenaError(`cannot parse '${file}': ${err instanceof Error ? err.message : String(err)}`, "SCHEMA_INVALID", err);
|
|
2785
|
+
}
|
|
2786
|
+
const docs = [];
|
|
2787
|
+
if (Array.isArray(parsed)) for (const doc of parsed) docs.push(parseArenaResultV1(doc));
|
|
2788
|
+
else if (typeof parsed === "object" && parsed !== null && parsed.schema === "arena.result/v1") docs.push(parseArenaResultV1(parsed));
|
|
2789
|
+
else if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.results)) return publishResult(parsed, registry, ctx);
|
|
2790
|
+
else throw new ArenaError(`'${file}' is neither an arena.result/v1 document nor a matrix results.json`, "SCHEMA_INVALID");
|
|
2791
|
+
const paths = [];
|
|
2792
|
+
for (const doc of docs) paths.push(await registry.publish(doc, { ...ctx.traces !== void 0 ? { traces: ctx.traces } : {} }));
|
|
2793
|
+
return paths;
|
|
2794
|
+
}
|
|
2795
|
+
//#endregion
|
|
2796
|
+
//#region src/board.ts
|
|
2797
|
+
function median(xs) {
|
|
2798
|
+
if (xs.length === 0) return void 0;
|
|
2799
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
2800
|
+
const m = s.length >> 1;
|
|
2801
|
+
return s.length % 2 === 1 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
2802
|
+
}
|
|
2803
|
+
function isoDay(d) {
|
|
2804
|
+
return d.toISOString().slice(0, 10);
|
|
2805
|
+
}
|
|
2806
|
+
const TREND_DAYS = 30;
|
|
2807
|
+
/**
|
|
2808
|
+
* Aggregate published results into board cohorts. The cohort key is
|
|
2809
|
+
* (pack, task, sverkaVersion, promptHash) — a board row is only ever a
|
|
2810
|
+
* comparison inside one prompt+toolchain generation.
|
|
2811
|
+
*/
|
|
2812
|
+
function buildBoard(results, opts = {}) {
|
|
2813
|
+
const days = opts.days ?? TREND_DAYS;
|
|
2814
|
+
const cohorts = /* @__PURE__ */ new Map();
|
|
2815
|
+
for (const doc of results) {
|
|
2816
|
+
const day = doc.startedAt.slice(0, 10);
|
|
2817
|
+
for (const t of doc.tasks) {
|
|
2818
|
+
const ck = JSON.stringify([
|
|
2819
|
+
doc.pack,
|
|
2820
|
+
t.task,
|
|
2821
|
+
doc.sverkaVersion,
|
|
2822
|
+
t.promptHash
|
|
2823
|
+
]);
|
|
2824
|
+
let cohort = cohorts.get(ck);
|
|
2825
|
+
if (cohort === void 0) {
|
|
2826
|
+
cohort = {
|
|
2827
|
+
key: {
|
|
2828
|
+
pack: doc.pack,
|
|
2829
|
+
task: t.task,
|
|
2830
|
+
sverkaVersion: doc.sverkaVersion,
|
|
2831
|
+
promptHash: t.promptHash
|
|
2832
|
+
},
|
|
2833
|
+
rows: /* @__PURE__ */ new Map()
|
|
2834
|
+
};
|
|
2835
|
+
cohorts.set(ck, cohort);
|
|
2836
|
+
}
|
|
2837
|
+
const rk = JSON.stringify([doc.agent, doc.model]);
|
|
2838
|
+
let row = cohort.rows.get(rk);
|
|
2839
|
+
if (row === void 0) {
|
|
2840
|
+
row = {
|
|
2841
|
+
agent: doc.agent,
|
|
2842
|
+
model: doc.model,
|
|
2843
|
+
samples: []
|
|
2844
|
+
};
|
|
2845
|
+
cohort.rows.set(rk, row);
|
|
2846
|
+
}
|
|
2847
|
+
row.samples.push({
|
|
2848
|
+
day,
|
|
2849
|
+
passed: t.score.passed,
|
|
2850
|
+
...t.metrics.tokens !== void 0 ? { tokens: t.metrics.tokens } : {},
|
|
2851
|
+
durationMs: t.metrics.durationMs
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
const out = [];
|
|
2856
|
+
for (const { key, rows } of cohorts.values()) {
|
|
2857
|
+
const anchor = opts.now !== void 0 ? isoDay(opts.now) : [...rows.values()].reduce((max, r) => r.samples.reduce((m, s) => s.day > m ? s.day : m, max), "0000-00-00");
|
|
2858
|
+
const boardRows = [...rows.values()].map((r) => {
|
|
2859
|
+
const n = r.samples.length;
|
|
2860
|
+
const passes = r.samples.filter((s) => s.passed).length;
|
|
2861
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
2862
|
+
for (const s of r.samples) {
|
|
2863
|
+
const d = byDay.get(s.day) ?? {
|
|
2864
|
+
pass: 0,
|
|
2865
|
+
n: 0
|
|
2866
|
+
};
|
|
2867
|
+
d.n++;
|
|
2868
|
+
if (s.passed) d.pass++;
|
|
2869
|
+
byDay.set(s.day, d);
|
|
2870
|
+
}
|
|
2871
|
+
const trend = [];
|
|
2872
|
+
const anchorDate = /* @__PURE__ */ new Date(`${anchor}T00:00:00.000Z`);
|
|
2873
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
2874
|
+
const d = /* @__PURE__ */ new Date(anchorDate.getTime() - i * 864e5);
|
|
2875
|
+
const stat = byDay.get(isoDay(d));
|
|
2876
|
+
trend.push(stat === void 0 ? NaN : stat.pass / stat.n);
|
|
2877
|
+
}
|
|
2878
|
+
const tokens = median(r.samples.flatMap((s) => s.tokens !== void 0 ? [s.tokens] : []));
|
|
2879
|
+
return {
|
|
2880
|
+
agent: r.agent,
|
|
2881
|
+
model: r.model,
|
|
2882
|
+
successRate: n === 0 ? 0 : passes / n,
|
|
2883
|
+
...tokens !== void 0 ? { medianTokens: tokens } : {},
|
|
2884
|
+
medianDurationMs: median(r.samples.map((s) => s.durationMs)) ?? 0,
|
|
2885
|
+
runs: n,
|
|
2886
|
+
trend
|
|
2887
|
+
};
|
|
2888
|
+
});
|
|
2889
|
+
boardRows.sort((a, b) => b.successRate - a.successRate || (a.medianTokens ?? Infinity) - (b.medianTokens ?? Infinity) || a.agent.localeCompare(b.agent) || a.model.localeCompare(b.model));
|
|
2890
|
+
out.push({
|
|
2891
|
+
...key,
|
|
2892
|
+
rows: boardRows
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
out.sort((a, b) => a.pack.localeCompare(b.pack) || a.task.localeCompare(b.task) || a.sverkaVersion.localeCompare(b.sverkaVersion) || a.promptHash.localeCompare(b.promptHash));
|
|
2896
|
+
return out;
|
|
2897
|
+
}
|
|
2898
|
+
const SPARK = "▁▂▃▄▅▆▇█";
|
|
2899
|
+
/** Sparkline over the trend array — NaN days render as a gap dot. */
|
|
2900
|
+
function sparkline(trend) {
|
|
2901
|
+
return trend.map((v) => Number.isNaN(v) ? "·" : SPARK[Math.min(7, Math.max(0, Math.round(v * 7)))]).join("");
|
|
2902
|
+
}
|
|
2903
|
+
function formatTokens(n) {
|
|
2904
|
+
if (n === void 0) return "—";
|
|
2905
|
+
return n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n));
|
|
2906
|
+
}
|
|
2907
|
+
function formatDuration(ms) {
|
|
2908
|
+
return ms >= 1e3 ? `${(ms / 1e3).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
|
2909
|
+
}
|
|
2910
|
+
function cohortTitle(c) {
|
|
2911
|
+
return `${c.pack} · ${c.task} · sverka ${c.sverkaVersion} · prompt ${c.promptHash.slice(0, 12)}`;
|
|
2912
|
+
}
|
|
2913
|
+
/** Render the leaderboard as a text table — `sverka-arena board`. */
|
|
2914
|
+
function renderBoard(cohorts) {
|
|
2915
|
+
if (cohorts.length === 0) return "no arena results — nothing published to this registry yet";
|
|
2916
|
+
const out = [];
|
|
2917
|
+
for (const c of cohorts) {
|
|
2918
|
+
out.push(cohortTitle(c));
|
|
2919
|
+
const header = [
|
|
2920
|
+
"AGENT",
|
|
2921
|
+
"MODEL",
|
|
2922
|
+
"SUCCESS",
|
|
2923
|
+
"MED-TOK",
|
|
2924
|
+
"MED-DUR",
|
|
2925
|
+
"RUNS",
|
|
2926
|
+
"TREND"
|
|
2927
|
+
];
|
|
2928
|
+
const lines = c.rows.map((r) => [
|
|
2929
|
+
r.agent,
|
|
2930
|
+
r.model,
|
|
2931
|
+
`${(r.successRate * 100).toFixed(1)}%`,
|
|
2932
|
+
formatTokens(r.medianTokens),
|
|
2933
|
+
formatDuration(r.medianDurationMs),
|
|
2934
|
+
String(r.runs),
|
|
2935
|
+
sparkline(r.trend)
|
|
2936
|
+
]);
|
|
2937
|
+
const widths = header.map((h, i) => Math.max(h.length, ...lines.map((l) => l[i].length)));
|
|
2938
|
+
const fmt = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
|
|
2939
|
+
out.push(` ${fmt(header)}`);
|
|
2940
|
+
for (const l of lines) out.push(` ${fmt(l)}`);
|
|
2941
|
+
out.push("");
|
|
2942
|
+
}
|
|
2943
|
+
return out.join("\n").trimEnd();
|
|
2944
|
+
}
|
|
2945
|
+
function rateClass(successRate) {
|
|
2946
|
+
if (successRate >= .99) return "ok";
|
|
2947
|
+
if (successRate >= .5) return "mid";
|
|
2948
|
+
return "bad";
|
|
2949
|
+
}
|
|
2950
|
+
function esc(s) {
|
|
2951
|
+
return s.replaceAll(/&/g, "&").replaceAll(/</g, "<").replaceAll(/>/g, ">").replaceAll(/"/g, """);
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* Render the leaderboard as a self-contained static HTML page — no JS,
|
|
2955
|
+
* no server. Used by `sverka-arena board --format html` and the website
|
|
2956
|
+
* arena page (generated at build time from the registry).
|
|
2957
|
+
*/
|
|
2958
|
+
function renderBoardHtml(cohorts, opts = {}) {
|
|
2959
|
+
const title = opts.title ?? "Sverka Arena — Leaderboard";
|
|
2960
|
+
const generatedAt = opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2961
|
+
const sections = cohorts.map((c) => {
|
|
2962
|
+
const rows = c.rows.map((r) => {
|
|
2963
|
+
const spark = esc(sparkline(r.trend));
|
|
2964
|
+
return ` <tr>
|
|
2965
|
+
<td>${esc(r.agent)}</td>
|
|
2966
|
+
<td>${esc(r.model)}</td>
|
|
2967
|
+
<td class="num ${rateClass(r.successRate)}">${(r.successRate * 100).toFixed(1)}%</td>
|
|
2968
|
+
<td class="num">${formatTokens(r.medianTokens)}</td>
|
|
2969
|
+
<td class="num">${formatDuration(r.medianDurationMs)}</td>
|
|
2970
|
+
<td class="num">${r.runs}</td>
|
|
2971
|
+
<td class="spark" title="daily success rate, last ${r.trend.length} days">${spark}</td>
|
|
2972
|
+
</tr>`;
|
|
2973
|
+
}).join("\n");
|
|
2974
|
+
return ` <section class="cohort">
|
|
2975
|
+
<h2>${esc(cohortTitle(c))}</h2>
|
|
2976
|
+
<table>
|
|
2977
|
+
<thead><tr><th>agent</th><th>model</th><th class="num">success</th><th class="num">med tokens</th><th class="num">med duration</th><th class="num">runs</th><th>trend</th></tr></thead>
|
|
2978
|
+
<tbody>
|
|
2979
|
+
${rows}
|
|
2980
|
+
</tbody>
|
|
2981
|
+
</table>
|
|
2982
|
+
</section>`;
|
|
2983
|
+
}).join("\n");
|
|
2984
|
+
const body = cohorts.length === 0 ? ` <p class="empty">no arena results — nothing published to this registry yet</p>` : sections;
|
|
2985
|
+
return `<!doctype html>
|
|
2986
|
+
<html lang="en" data-theme="dark">
|
|
2987
|
+
<head>
|
|
2988
|
+
<meta charset="utf-8" />
|
|
2989
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2990
|
+
<title>${esc(title)}</title>
|
|
2991
|
+
<style>
|
|
2992
|
+
:root {
|
|
2993
|
+
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
|
2994
|
+
--text: #e6edf3; --text-muted: #8b949e;
|
|
2995
|
+
--green: #3fb950; --yellow: #d29922; --red: #f85149; --accent: #58a6ff;
|
|
2996
|
+
}
|
|
2997
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2998
|
+
body {
|
|
2999
|
+
background: var(--bg); color: var(--text);
|
|
3000
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
3001
|
+
line-height: 1.6; padding: 2rem; max-width: 1100px; margin: 0 auto;
|
|
3002
|
+
}
|
|
3003
|
+
h1 { font-size: 1.4rem; }
|
|
3004
|
+
.subtitle { color: var(--text-muted); font-size: 0.85rem; margin: 0.25rem 0 1.5rem; }
|
|
3005
|
+
.cohort {
|
|
3006
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
3007
|
+
border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1.25rem;
|
|
3008
|
+
}
|
|
3009
|
+
.cohort h2 {
|
|
3010
|
+
font-size: 0.95rem; font-family: ui-monospace, "SF Mono", monospace;
|
|
3011
|
+
color: var(--accent); margin-bottom: 0.75rem; font-weight: 600;
|
|
3012
|
+
}
|
|
3013
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
3014
|
+
th {
|
|
3015
|
+
text-align: left; color: var(--text-muted); font-weight: 600;
|
|
3016
|
+
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
3017
|
+
padding: 0.3rem 0.6rem; border-bottom: 1px solid var(--border);
|
|
3018
|
+
}
|
|
3019
|
+
td { padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
|
|
3020
|
+
tr:last-child td { border-bottom: none; }
|
|
3021
|
+
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
3022
|
+
th.num { text-align: right; }
|
|
3023
|
+
.ok { color: var(--green); } .mid { color: var(--yellow); } .bad { color: var(--red); }
|
|
3024
|
+
.spark { font-family: ui-monospace, "SF Mono", monospace; letter-spacing: 0.05em; color: var(--green); }
|
|
3025
|
+
.empty { color: var(--text-muted); padding: 3rem 0; text-align: center; }
|
|
3026
|
+
footer { color: var(--text-muted); font-size: 0.75rem; margin-top: 2rem; }
|
|
3027
|
+
</style>
|
|
3028
|
+
</head>
|
|
3029
|
+
<body>
|
|
3030
|
+
<h1>${esc(title)}</h1>
|
|
3031
|
+
<p class="subtitle">verification-grounded agent evals — cohorts are (pack · task · sverka version · prompt hash); rows compare agent×model inside one cohort only</p>
|
|
3032
|
+
${body}
|
|
3033
|
+
<footer>generated ${esc(generatedAt)} — deterministic checks as ground truth, no LLM judge</footer>
|
|
3034
|
+
</body>
|
|
3035
|
+
</html>
|
|
3036
|
+
`;
|
|
3037
|
+
}
|
|
3038
|
+
//#endregion
|
|
3039
|
+
//#region src/pack.ts
|
|
3040
|
+
/**
|
|
3041
|
+
* Task packs — shareable benchmark suites. A pack is a directory:
|
|
3042
|
+
*
|
|
3043
|
+
* <pack>/
|
|
3044
|
+
* pack.json # { name, version, description, defaults }
|
|
3045
|
+
* tasks/<id>.json # { id, prompt, repo|fixture, checks, timeout? }
|
|
3046
|
+
*
|
|
3047
|
+
* `sverka-arena run --pack <ref>` resolves a local dir, a git URL, or a
|
|
3048
|
+
* pack name inside a registry (`packs/<name>/`). Community contribution
|
|
3049
|
+
* is a git PR adding a pack — no registry write API needed.
|
|
3050
|
+
* Spec: specs/56-arena-eval-service.
|
|
3051
|
+
*/
|
|
3052
|
+
const packCheckSchema = z.object({
|
|
3053
|
+
id: z.string().min(1),
|
|
3054
|
+
command: z.string().min(1),
|
|
3055
|
+
description: z.string().optional()
|
|
3056
|
+
});
|
|
3057
|
+
const packTaskSchema = z.object({
|
|
3058
|
+
id: z.string().min(1).optional(),
|
|
3059
|
+
name: z.string().optional(),
|
|
3060
|
+
prompt: z.string().min(1),
|
|
3061
|
+
timeoutMs: z.number().positive().optional(),
|
|
3062
|
+
/** Git URL cloned as the task's workspace seed. */
|
|
3063
|
+
repo: z.string().min(1).optional(),
|
|
3064
|
+
/** Directory inside the pack copied into the workspace. */
|
|
3065
|
+
fixture: z.string().min(1).optional(),
|
|
3066
|
+
setup: z.array(z.string()).optional(),
|
|
3067
|
+
checks: z.array(packCheckSchema).optional(),
|
|
3068
|
+
successCriteria: z.string().optional(),
|
|
3069
|
+
expectedOutput: z.string().optional()
|
|
3070
|
+
});
|
|
3071
|
+
const packJsonSchema = z.object({
|
|
3072
|
+
name: z.string().min(1),
|
|
3073
|
+
version: z.string().optional(),
|
|
3074
|
+
description: z.string().optional(),
|
|
3075
|
+
defaults: z.object({
|
|
3076
|
+
timeoutMs: z.number().positive().optional(),
|
|
3077
|
+
repetitions: z.number().int().positive().optional(),
|
|
3078
|
+
outputDir: z.string().min(1).optional()
|
|
3079
|
+
}).optional()
|
|
3080
|
+
});
|
|
3081
|
+
async function taskFromFile(file, dir, defaults, opts) {
|
|
3082
|
+
let raw;
|
|
3083
|
+
try {
|
|
3084
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
3085
|
+
} catch (err) {
|
|
3086
|
+
throw new ArenaError(`pack task '${file}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, "PACK_INVALID", err);
|
|
3087
|
+
}
|
|
3088
|
+
const parsed = packTaskSchema.safeParse(raw);
|
|
3089
|
+
if (!parsed.success) throw new ArenaError(`pack task '${file}' is invalid:\n${parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n")}`, "PACK_INVALID");
|
|
3090
|
+
const t = parsed.data;
|
|
3091
|
+
const id = t.id ?? basename(file, ".json");
|
|
3092
|
+
if (t.fixture !== void 0 && t.repo !== void 0) throw new ArenaError(`pack task '${id}': cannot specify both 'fixture' and 'repo' — choose one`, "PACK_INVALID");
|
|
3093
|
+
let fixture;
|
|
3094
|
+
if (t.fixture !== void 0) {
|
|
3095
|
+
fixture = resolve(dir, t.fixture);
|
|
3096
|
+
const rel = relative(dir, fixture);
|
|
3097
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new ArenaError(`pack task '${id}': fixture '${t.fixture}' escapes the pack dir`, "PACK_INVALID");
|
|
3098
|
+
if (!existsSync(fixture) || !statSync(fixture).isDirectory()) throw new ArenaError(`pack task '${id}': fixture dir '${t.fixture}' does not exist in ${dir}`, "PACK_INVALID");
|
|
3099
|
+
}
|
|
3100
|
+
if (t.repo !== void 0) fixture = await cloneRepo(t.repo, opts);
|
|
3101
|
+
const checks = t.checks?.map((c) => ({
|
|
3102
|
+
id: c.id,
|
|
3103
|
+
command: c.command,
|
|
3104
|
+
description: c.description ?? c.command
|
|
3105
|
+
}));
|
|
3106
|
+
return {
|
|
3107
|
+
id,
|
|
3108
|
+
name: t.name ?? id,
|
|
3109
|
+
prompt: t.prompt,
|
|
3110
|
+
...t.timeoutMs !== void 0 || defaults.timeoutMs !== void 0 ? { timeoutMs: t.timeoutMs ?? defaults.timeoutMs } : {},
|
|
3111
|
+
...fixture !== void 0 ? { fixture } : {},
|
|
3112
|
+
...t.setup !== void 0 ? { setup: t.setup } : {},
|
|
3113
|
+
...checks !== void 0 ? { checks } : {},
|
|
3114
|
+
...t.successCriteria !== void 0 ? { successCriteria: t.successCriteria } : {},
|
|
3115
|
+
...t.expectedOutput !== void 0 ? { expectedOutput: t.expectedOutput } : {}
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
/** Load a pack directory into a {@link TaskPack}. */
|
|
3119
|
+
async function loadPack(dir, opts = {}) {
|
|
3120
|
+
const packJsonPath = join(dir, "pack.json");
|
|
3121
|
+
if (!existsSync(packJsonPath)) throw new ArenaError(`no pack.json in '${dir}' — not a task pack`, "PACK_NOT_FOUND");
|
|
3122
|
+
let raw;
|
|
3123
|
+
try {
|
|
3124
|
+
raw = JSON.parse(await readFile(packJsonPath, "utf8"));
|
|
3125
|
+
} catch (err) {
|
|
3126
|
+
throw new ArenaError(`pack.json in '${dir}' is not valid JSON`, "PACK_INVALID", err);
|
|
3127
|
+
}
|
|
3128
|
+
const parsed = packJsonSchema.safeParse(raw);
|
|
3129
|
+
if (!parsed.success) throw new ArenaError(`pack.json in '${dir}' is invalid:\n${parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n")}`, "PACK_INVALID");
|
|
3130
|
+
const meta = parsed.data;
|
|
3131
|
+
const defaults = {
|
|
3132
|
+
...meta.defaults?.timeoutMs !== void 0 ? { timeoutMs: meta.defaults.timeoutMs } : {},
|
|
3133
|
+
...meta.defaults?.repetitions !== void 0 ? { repetitions: meta.defaults.repetitions } : {},
|
|
3134
|
+
...meta.defaults?.outputDir !== void 0 ? { outputDir: meta.defaults.outputDir } : {}
|
|
3135
|
+
};
|
|
3136
|
+
const tasksDir = join(dir, "tasks");
|
|
3137
|
+
const files = existsSync(tasksDir) ? (await readdir(tasksDir)).filter((f) => f.endsWith(".json")).sort() : [];
|
|
3138
|
+
const tasks = [];
|
|
3139
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3140
|
+
for (const f of files) {
|
|
3141
|
+
const task = await taskFromFile(join(tasksDir, f), dir, defaults, opts);
|
|
3142
|
+
if (ids.has(task.id)) throw new ArenaError(`duplicate task id '${task.id}' in pack '${meta.name}'`, "PACK_INVALID");
|
|
3143
|
+
ids.add(task.id);
|
|
3144
|
+
tasks.push(task);
|
|
3145
|
+
}
|
|
3146
|
+
if (tasks.length === 0) throw new ArenaError(`pack '${meta.name}' has no tasks — add tasks/<id>.json`, "PACK_INVALID");
|
|
3147
|
+
return {
|
|
3148
|
+
name: meta.name,
|
|
3149
|
+
...meta.version !== void 0 ? { version: meta.version } : {},
|
|
3150
|
+
...meta.description !== void 0 ? { description: meta.description } : {},
|
|
3151
|
+
dir,
|
|
3152
|
+
tasks,
|
|
3153
|
+
defaults
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
3157
|
+
* Validate a pack directory without loading it into a run — collects
|
|
3158
|
+
* errors AND warnings instead of throwing on the first problem.
|
|
3159
|
+
*/
|
|
3160
|
+
async function lintPack(dir) {
|
|
3161
|
+
const errors = [];
|
|
3162
|
+
const warnings = [];
|
|
3163
|
+
const packJsonPath = join(dir, "pack.json");
|
|
3164
|
+
if (!existsSync(packJsonPath)) return {
|
|
3165
|
+
errors: [`no pack.json in '${dir}'`],
|
|
3166
|
+
warnings
|
|
3167
|
+
};
|
|
3168
|
+
try {
|
|
3169
|
+
const parsed = packJsonSchema.safeParse(JSON.parse(await readFile(packJsonPath, "utf8")));
|
|
3170
|
+
if (!parsed.success) {
|
|
3171
|
+
for (const i of parsed.error.issues) errors.push(`pack.json ${i.path.join(".") || "(root)"}: ${i.message}`);
|
|
3172
|
+
return {
|
|
3173
|
+
errors,
|
|
3174
|
+
warnings
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
if (parsed.data.name !== basename(dir)) warnings.push(`pack name '${parsed.data.name}' does not match directory '${basename(dir)}'`);
|
|
3178
|
+
} catch (err) {
|
|
3179
|
+
return {
|
|
3180
|
+
errors: [`pack.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`],
|
|
3181
|
+
warnings
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
const tasksDir = join(dir, "tasks");
|
|
3185
|
+
if (!existsSync(tasksDir)) {
|
|
3186
|
+
errors.push(`no tasks/ directory in '${dir}'`);
|
|
3187
|
+
return {
|
|
3188
|
+
errors,
|
|
3189
|
+
warnings
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
const files = (await readdir(tasksDir)).filter((f) => f.endsWith(".json"));
|
|
3193
|
+
if (files.length === 0) errors.push(`no tasks/*.json files in '${dir}'`);
|
|
3194
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3195
|
+
for (const f of files.sort()) {
|
|
3196
|
+
const file = join(tasksDir, f);
|
|
3197
|
+
let raw;
|
|
3198
|
+
try {
|
|
3199
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
3200
|
+
} catch (err) {
|
|
3201
|
+
errors.push(`tasks/${f}: not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
3202
|
+
continue;
|
|
3203
|
+
}
|
|
3204
|
+
const parsed = packTaskSchema.safeParse(raw);
|
|
3205
|
+
if (!parsed.success) {
|
|
3206
|
+
for (const i of parsed.error.issues) errors.push(`tasks/${f} ${i.path.join(".") || "(root)"}: ${i.message}`);
|
|
3207
|
+
continue;
|
|
3208
|
+
}
|
|
3209
|
+
const t = parsed.data;
|
|
3210
|
+
const id = t.id ?? basename(f, ".json");
|
|
3211
|
+
if (ids.has(id)) errors.push(`duplicate task id '${id}'`);
|
|
3212
|
+
ids.add(id);
|
|
3213
|
+
if (t.checks === void 0 || t.checks.length === 0) warnings.push(`task '${id}' has no checks — it scores on agent exit status only, not verification`);
|
|
3214
|
+
if (t.fixture !== void 0 && t.repo !== void 0) errors.push(`task '${id}': cannot specify both 'fixture' and 'repo' — choose one`);
|
|
3215
|
+
else if (t.fixture !== void 0) {
|
|
3216
|
+
const fx = resolve(dir, t.fixture);
|
|
3217
|
+
const rel = relative(dir, fx);
|
|
3218
|
+
if (rel.startsWith("..") || isAbsolute(rel)) errors.push(`task '${id}': fixture '${t.fixture}' escapes the pack dir`);
|
|
3219
|
+
else if (!existsSync(fx) || !statSync(fx).isDirectory()) errors.push(`task '${id}': fixture dir '${t.fixture}' does not exist`);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
return {
|
|
3223
|
+
errors,
|
|
3224
|
+
warnings
|
|
3225
|
+
};
|
|
3226
|
+
}
|
|
3227
|
+
/** Scaffold a new pack at `<dir>` — `sverka-arena pack init <name>`. */
|
|
3228
|
+
async function initPack(dir, name) {
|
|
3229
|
+
if (existsSync(dir) && (await readdir(dir)).length > 0) throw new ArenaError(`'${dir}' exists and is not empty — pick a fresh pack directory`, "PACK_INVALID");
|
|
3230
|
+
await mkdir(join(dir, "tasks"), { recursive: true });
|
|
3231
|
+
await writeFile(join(dir, "pack.json"), JSON.stringify({
|
|
3232
|
+
name,
|
|
3233
|
+
version: "0.1.0",
|
|
3234
|
+
description: `${name} — sverka arena task pack`,
|
|
3235
|
+
defaults: {
|
|
3236
|
+
timeoutMs: 12e4,
|
|
3237
|
+
repetitions: 1,
|
|
3238
|
+
outputDir: ".arena"
|
|
3239
|
+
}
|
|
3240
|
+
}, null, 2) + "\n", "utf8");
|
|
3241
|
+
await writeFile(join(dir, "tasks", "example-task.json"), JSON.stringify({
|
|
3242
|
+
id: "example-task",
|
|
3243
|
+
name: "Example task",
|
|
3244
|
+
prompt: "Describe the task for the agent here. The score comes from the checks below — deterministic verification, not an LLM judge.",
|
|
3245
|
+
checks: [{
|
|
3246
|
+
id: "example-check",
|
|
3247
|
+
command: "true",
|
|
3248
|
+
description: "replace with a real verification (e.g. npm test, sverka run)"
|
|
3249
|
+
}]
|
|
3250
|
+
}, null, 2) + "\n", "utf8");
|
|
3251
|
+
}
|
|
3252
|
+
function isGitUrl(ref) {
|
|
3253
|
+
return /^https?:\/\//.test(ref) || ref.startsWith("git@") || /^(?:ssh|git):\/\//.test(ref) || ref.endsWith(".git");
|
|
3254
|
+
}
|
|
3255
|
+
/** Predictable tmpdir clone destinations stay owner-only (CWE-377). */
|
|
3256
|
+
async function privateDir(dir) {
|
|
3257
|
+
await mkdir(dir, {
|
|
3258
|
+
recursive: true,
|
|
3259
|
+
mode: 448
|
|
3260
|
+
});
|
|
3261
|
+
await chmod(dir, 448);
|
|
3262
|
+
}
|
|
3263
|
+
async function cloneRepo(url, opts) {
|
|
3264
|
+
const dest = join(opts.cacheDir ?? tmpdir(), `arena-pack-repo-${createHash("sha256").update(url).digest("hex").slice(0, 12)}`);
|
|
3265
|
+
if (existsSync(join(dest, ".git"))) {
|
|
3266
|
+
await git([
|
|
3267
|
+
"-C",
|
|
3268
|
+
dest,
|
|
3269
|
+
"pull",
|
|
3270
|
+
"--ff-only"
|
|
3271
|
+
]);
|
|
3272
|
+
return dest;
|
|
3273
|
+
}
|
|
3274
|
+
try {
|
|
3275
|
+
await privateDir(dest);
|
|
3276
|
+
await gitOrThrow([
|
|
3277
|
+
"clone",
|
|
3278
|
+
"--depth",
|
|
3279
|
+
"1",
|
|
3280
|
+
url,
|
|
3281
|
+
dest
|
|
3282
|
+
]);
|
|
3283
|
+
} catch (err) {
|
|
3284
|
+
throw new ArenaError(`cannot clone task repo '${url}': ${err instanceof Error ? err.message : String(err)}`, "PACK_NOT_FOUND", err);
|
|
3285
|
+
}
|
|
3286
|
+
return dest;
|
|
3287
|
+
}
|
|
3288
|
+
async function clonePack(url, opts) {
|
|
3289
|
+
const dest = join(opts.cacheDir ?? tmpdir(), `arena-pack-${createHash("sha256").update(url).digest("hex").slice(0, 12)}`);
|
|
3290
|
+
if (existsSync(join(dest, ".git"))) {
|
|
3291
|
+
const res = await git([
|
|
3292
|
+
"-C",
|
|
3293
|
+
dest,
|
|
3294
|
+
"pull",
|
|
3295
|
+
"--ff-only"
|
|
3296
|
+
]);
|
|
3297
|
+
if (res.code !== 0) throw new ArenaError(`cannot update pack clone '${url}': ${res.stderr.trim()}`, "PACK_NOT_FOUND");
|
|
3298
|
+
return dest;
|
|
3299
|
+
}
|
|
3300
|
+
try {
|
|
3301
|
+
await privateDir(dest);
|
|
3302
|
+
await gitOrThrow([
|
|
3303
|
+
"clone",
|
|
3304
|
+
url,
|
|
3305
|
+
dest
|
|
3306
|
+
]);
|
|
3307
|
+
} catch (err) {
|
|
3308
|
+
throw new ArenaError(`cannot clone pack '${url}': ${err instanceof Error ? err.message : String(err)}`, "PACK_NOT_FOUND", err);
|
|
3309
|
+
}
|
|
3310
|
+
return dest;
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Resolve a pack reference to a loaded {@link TaskPack}:
|
|
3314
|
+
* <dir> — a local pack directory
|
|
3315
|
+
* <git-url> — cloned into a tmp cache, then loaded
|
|
3316
|
+
* <bare-name> — packs/<name>/ inside --registry (file or git)
|
|
3317
|
+
*/
|
|
3318
|
+
async function resolvePack(ref, opts = {}) {
|
|
3319
|
+
if (existsSync(ref) && existsSync(join(ref, "pack.json"))) return loadPack(resolve(ref), opts);
|
|
3320
|
+
if (isGitUrl(ref) || ref.startsWith("git::")) return loadPack(await clonePack(ref.startsWith("git::") ? ref.slice(5) : ref, opts), opts);
|
|
3321
|
+
if (/^[a-zA-Z0-9._-]+$/.test(ref)) {
|
|
3322
|
+
if (opts.registry === void 0) throw new ArenaError(`cannot resolve pack '${ref}' — no --registry given (packs live at packs/<name>/ inside a registry)`, "PACK_NOT_FOUND");
|
|
3323
|
+
const regDir = await resolveRegistryDir(opts.registry, { ...opts.token !== void 0 ? { token: opts.token } : {} });
|
|
3324
|
+
const packDir = join(regDir, "packs", ref);
|
|
3325
|
+
if (!existsSync(join(packDir, "pack.json"))) throw new ArenaError(`pack '${ref}' not found at ${packDir}`, "PACK_NOT_FOUND");
|
|
3326
|
+
return loadPack(packDir, opts);
|
|
3327
|
+
}
|
|
3328
|
+
throw new ArenaError(`pack '${ref}' not found`, "PACK_NOT_FOUND");
|
|
3329
|
+
}
|
|
3330
|
+
//#endregion
|
|
3331
|
+
export { defineConfig as A, judgeAllRuns as B, resultPath as C, writeAggregateReport as D, runStepId as E, pluginCombinations as F, extractUsageTokens as G, parseJudgeResponse as H, runArena as I, createPermissionHandler as J, installPlugins as K, DEFAULT_JUDGE_PROMPT as L, resolveAdapter as M, aggregateResults as N, renderReport as O, computeAnalysis as P, buildJudgePrompt as R, resolveRegistryDir as S, arenaResultToEvents as T, DevinAdapter as U, judgeRun as V, countLlmCalls as W, sanitizeEnv as X, runAcpSession as Y, openRegistry as _, buildBoard as a, publishBatch as b, sparkline as c, publishFile as d, publishResult as f, createS3Registry as g, createGitRegistry as h, resolvePack as i, loadArenaConfig as j, ArenaError as k, explodeResult as l, createFileRegistry as m, lintPack as n, renderBoard as o, arenaResultV1Schema as p, transcriptDir as q, loadPack as r, renderBoardHtml as s, initPack as t, newRunId as u, parseArenaResultV1 as v, arenaResultGraph as w, reindexRegistry as x, promptHash as y, compareCombos as z };
|
|
1896
3332
|
|
|
1897
|
-
//# sourceMappingURL=
|
|
3333
|
+
//# sourceMappingURL=pack-DoL1QL1y.mjs.map
|